diff --git a/scripts/changelog/imagegen/index.ts b/scripts/changelog/imagegen/index.ts index ea1a60c5..98bfbef6 100644 --- a/scripts/changelog/imagegen/index.ts +++ b/scripts/changelog/imagegen/index.ts @@ -21,13 +21,15 @@ export type RunResult = { export type RunDependencies = { /** Seam for tests, so the pipeline can be exercised without calling OpenAI. */ generate?: typeof generateBackground; + /** Seam for tests, so renders can share one browser instead of launching each time. */ + render?: typeof renderCard; }; /** Runs the full pipeline for the given options. */ export async function run( options: Options, log: (message: string) => void = () => {}, - { generate = generateBackground }: RunDependencies = {}, + { generate = generateBackground, render = renderCard }: RunDependencies = {}, ): Promise { const spec = options.motif ? buildImageSpec({ motif: options.motif, colorGrade: options.colorGrade }) @@ -54,7 +56,7 @@ export async function run( const background = await prepareBackground(source, { palette: options.palette }); log('Rendering card…'); - const png = await renderCard( + const png = await render( { categoryLines: splitLabelLines(options.category), dateLines: formatCardDate(options.date), diff --git a/scripts/changelog/imagegen/render.ts b/scripts/changelog/imagegen/render.ts index f6fc6d57..fd05667e 100644 --- a/scripts/changelog/imagegen/render.ts +++ b/scripts/changelog/imagegen/render.ts @@ -1,29 +1,36 @@ // ABOUTME: Renders the card HTML to a PNG buffer with headless Chromium. // ABOUTME: The viewport is the Figma frame; --scale multiplies the exported resolution. -import { chromium } from 'playwright'; +import { type Browser, chromium } from 'playwright'; import { CARD_HEIGHT, CARD_WIDTH, type CardContent, renderCardHtml } from './template'; export type RenderOptions = { /** Resolution multiplier: 2 exports a 2840x1600 PNG. */ scale?: number; + /** An already-launched browser to render in; the caller owns its lifecycle. */ + browser?: Browser; }; /** Renders a card to PNG bytes. */ export async function renderCard( content: CardContent, - { scale = 2 }: RenderOptions = {}, + { scale = 2, browser }: RenderOptions = {}, ): Promise { - const browser = await chromium.launch(); + const ownBrowser = browser === undefined; + const target = browser ?? (await chromium.launch()); try { - const page = await browser.newPage({ + const page = await target.newPage({ viewport: { width: CARD_WIDTH, height: CARD_HEIGHT }, deviceScaleFactor: scale, }); - await page.setContent(renderCardHtml(content), { waitUntil: 'load' }); - await page.evaluate(() => document.fonts.ready); - return await page.screenshot({ type: 'png' }); + try { + await page.setContent(renderCardHtml(content), { waitUntil: 'load' }); + await page.evaluate(() => document.fonts.ready); + return await page.screenshot({ type: 'png' }); + } finally { + await page.close(); + } } finally { - await browser.close(); + if (ownBrowser) await target.close(); } } diff --git a/tests/imagegen/helpers/browser.ts b/tests/imagegen/helpers/browser.ts new file mode 100644 index 00000000..3888c00a --- /dev/null +++ b/tests/imagegen/helpers/browser.ts @@ -0,0 +1,15 @@ +// ABOUTME: Shares one headless Chromium across all imagegen test files, because repeated +// ABOUTME: chromium.launch() calls in the same Bun test process can deadlock on spawn. +import { type Browser, chromium } from 'playwright'; + +let shared: Promise | undefined; + +/** + * Launches Chromium once per test process and reuses it. Never closed here: + * Playwright kills the browser when the test process exits, and an afterAll + * in one file must not tear it down under another file's tests. + */ +export function getSharedBrowser(): Promise { + shared ??= chromium.launch(); + return shared; +} diff --git a/tests/imagegen/integration/render.test.ts b/tests/imagegen/integration/render.test.ts index cfab346d..eba3051d 100644 --- a/tests/imagegen/integration/render.test.ts +++ b/tests/imagegen/integration/render.test.ts @@ -4,12 +4,13 @@ import { beforeAll, describe, setDefaultTimeout, test } from 'bun:test'; import assert from 'node:assert/strict'; import { PNG } from 'pngjs'; -// The render hook launches headless Chromium, which is slow enough on a loaded -// two-core CI runner to blow past a 120s budget. +// Renders share one Chromium via getSharedBrowser, but the process-wide first +// launch can land in this file and needs generous headroom on a loaded runner. setDefaultTimeout(240000); import { renderCard } from '../../../scripts/changelog/imagegen/render'; import { CARD_HEIGHT, CARD_WIDTH } from '../../../scripts/changelog/imagegen/template'; +import { getSharedBrowser } from '../helpers/browser'; /** A flat mid-grey background, so overlays are easy to detect. */ function solidBackgroundDataUri(r: number, g: number, b: number): string { @@ -35,7 +36,7 @@ describe('renderCard', () => { const scale = 1; beforeAll(async () => { - image = PNG.sync.read(await renderCard(content, { scale })); + image = PNG.sync.read(await renderCard(content, { scale, browser: await getSharedBrowser() })); }); const brightness = (x: number, y: number) => { diff --git a/tests/imagegen/integration/run.test.ts b/tests/imagegen/integration/run.test.ts index 8b213a29..10625039 100644 --- a/tests/imagegen/integration/run.test.ts +++ b/tests/imagegen/integration/run.test.ts @@ -4,8 +4,8 @@ import { afterAll, beforeAll, describe, setDefaultTimeout, test } from 'bun:test import assert from 'node:assert/strict'; import { mkdtemp, readFile, rm } from 'node:fs/promises'; -// The pipeline launches a fresh headless Chromium per render. On a loaded -// two-core CI runner one launch has taken over 120s, so allow generous headroom. +// Renders share one Chromium via getSharedBrowser, but the process-wide first +// launch can land in this file and needs generous headroom on a loaded runner. setDefaultTimeout(240000); import { tmpdir } from 'node:os'; @@ -15,6 +15,12 @@ import sharp from 'sharp'; import { run } from '../../../scripts/changelog/imagegen/index'; import { parseOptions } from '../../../scripts/changelog/imagegen/options'; import { getPalette } from '../../../scripts/changelog/imagegen/palettes'; +import { renderCard } from '../../../scripts/changelog/imagegen/render'; +import { getSharedBrowser } from '../helpers/browser'; + +/** Renders in the process-wide shared browser instead of launching one per card. */ +const render: typeof renderCard = async (content, options) => + renderCard(content, { ...options, browser: await getSharedBrowser() }); const MOTIF = 'A quiet harbour at dawn where five ships dock at one long pier.'; @@ -67,7 +73,7 @@ describe('run (generate path)', () => { '1', ]); - const result = await run(options, () => {}, { generate: fakeGenerator(calls) }); + const result = await run(options, () => {}, { generate: fakeGenerator(calls), render }); assert.equal(calls.length, 1); assert.equal(calls[0]!.size, '1536x864'); @@ -98,7 +104,7 @@ describe('run (generate path)', () => { '1', ]); - const result = await run(options, () => {}, { generate: fakeGenerator([]) }); + const result = await run(options, () => {}, { generate: fakeGenerator([]), render }); assert.equal(result.source, join(workdir, 'kept-source.png')); const { width, height } = await sharp(await readFile(result.source!)).metadata(); @@ -121,7 +127,7 @@ describe('run (generate path)', () => { '1', ]); - await run(options, () => {}, { generate: fakeGenerator([]) }); + await run(options, () => {}, { generate: fakeGenerator([]), render }); // Re-dithering the kept original must reproduce exactly the palette in use. const palette = getPalette('Ocean');