From ae9f1e35a3758870eb408eff5ffbb0055d766e57 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 5 Oct 2026 14:13:53 +0000 Subject: [PATCH] test: run each heavy unit test file in its own process (#899) * test: run each heavy unit test file in its own process `test:unit` and `test:unit:heavy` ran every file in one `bun test` process. A heavy fixture file alone takes 2-6 GB, and what one file keeps stays alive for the rest of the run. Quick files still share one process; heavy files now run one per process, so the peak is the heaviest single file. Coverage runs keep one process, since coverage is collected per process. stale-overrides parses material3.fig and joins the heavy list, and a guard fails when a quick test reads a large fixture corpus. * test(core): keep one material3 graph alive at a time in stale-overrides The edited-document test held the exported reader graph, the reopened graph, and the parsed node changes at once, and the first test's graph was still alive when the second started. Each step now runs in its own function and returns only what the next check needs, and the file collects garbage between steps and tests. The checks are unchanged; peak memory drops from 5.6 GB to 3.6 GB. --- .../fig/import/stale-overrides.test.ts | 55 ++++++++++++------- tools/dev/unit-tests/src/run.ts | 35 ++++++++---- tools/dev/unit-tests/src/shards.ts | 11 ++++ tools/dev/unit-tests/tests/shards.test.ts | 13 +++++ 4 files changed, 84 insertions(+), 30 deletions(-) diff --git a/packages/core/tests/io/formats/fig/import/stale-overrides.test.ts b/packages/core/tests/io/formats/fig/import/stale-overrides.test.ts index 383bfcc88..079db58fd 100644 --- a/packages/core/tests/io/formats/fig/import/stale-overrides.test.ts +++ b/packages/core/tests/io/formats/fig/import/stale-overrides.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from 'bun:test' +import { afterEach, expect, test } from 'bun:test' import { exportFigFile, initCodec, parseFigFile } from '@open-pencil/core' import { parseFigBuffer } from '@open-pencil/fig' @@ -9,6 +9,12 @@ import { openReaderSession } from '#core/kiwi/fig/session/reader' import { readFixtureArrayBuffer } from '#core-tests/helpers/fig/fixtures' import { HEAVY_TEST_TIMEOUT_MS, runsHeavyTests } from '#core-tests/helpers/test-utils' +// Each full material3.fig graph takes gigabytes, and the collector does not run on its own +// between steps, so release them explicitly. +afterEach(() => { + Bun.gc(true) +}) + // material3.fig retains overrides on 58114:20598 that address 57994:10133, a node the // archive no longer contains. Figma keeps such records; opening the file must not. test.if(runsHeavyTests)( @@ -29,29 +35,38 @@ test.if(runsHeavyTests)( // The Internal Only Canvas keeps instances of deleted components. Export loads every page, // so an edited document must still export and reopen with those instances intact. +// Each step keeps only what the next one checks, so one full graph is alive at a time. +async function exportEditedDocument(): Promise<{ bytes: Uint8Array; orphanCount: number }> { + await initCodec() + const reader = openReaderSession(readFixtureArrayBuffer('material3.fig'), 'first-page') + const page = reader.graph.getPages()[0] + reader.graph.updateNode(page.id, { name: 'Edited page' }) + registerReaderSession(readFixtureArrayBuffer('material3.fig'), reader.session, reader.diagnostics) + const bytes = await exportFigFile(reader.graph) + const orphans = readerDiagnostics(reader.graph).filter((entry) => entry.kind === 'component') + return { bytes, orphanCount: orphans.length } +} + +async function reopenedPageNames(bytes: Uint8Array): Promise { + const reopened = await parseFigFile(bytes.slice().buffer as ArrayBuffer) + return reopened.getPages().map((node) => node.name) +} + +function exportedOrphanCount(bytes: Uint8Array): number { + const { nodeChanges } = parseFigBuffer(bytes.slice().buffer as ArrayBuffer) + return nodeChanges.filter((node) => node.type === 'INSTANCE' && !node.symbolData?.symbolID).length +} + test.if(runsHeavyTests)( 'an edited document with instances of deleted components exports and reopens', async () => { - await initCodec() - const reader = openReaderSession(readFixtureArrayBuffer('material3.fig'), 'first-page') - const page = reader.graph.getPages()[0] - reader.graph.updateNode(page.id, { name: 'Edited page' }) - registerReaderSession( - readFixtureArrayBuffer('material3.fig'), - reader.session, - reader.diagnostics - ) - const bytes = await exportFigFile(reader.graph) - const orphans = readerDiagnostics(reader.graph).filter((entry) => entry.kind === 'component') - expect(orphans.length).toBeGreaterThan(0) - const reopened = await parseFigFile(bytes.slice().buffer as ArrayBuffer) - expect(reopened.getPages().some((node) => node.name === 'Edited page')).toBe(true) + const { bytes, orphanCount } = await exportEditedDocument() + expect(orphanCount).toBeGreaterThan(0) + Bun.gc(true) + expect(await reopenedPageNames(bytes)).toContain('Edited page') + Bun.gc(true) // Each orphan is still an instance record, with no component to point at. - const { nodeChanges } = parseFigBuffer(bytes.slice().buffer as ArrayBuffer) - const exportedOrphans = nodeChanges.filter( - (node) => node.type === 'INSTANCE' && !node.symbolData?.symbolID - ) - expect(exportedOrphans).toHaveLength(orphans.length) + expect(exportedOrphanCount(bytes)).toBe(orphanCount) }, HEAVY_TEST_TIMEOUT_MS * 4 ) diff --git a/tools/dev/unit-tests/src/run.ts b/tools/dev/unit-tests/src/run.ts index bafe3a90a..b9fe27492 100644 --- a/tools/dev/unit-tests/src/run.ts +++ b/tools/dev/unit-tests/src/run.ts @@ -3,7 +3,7 @@ import { listHeavyUnitTests, listUnitTests, type UnitTestGroup, unitTestGroupNames } from './shards' /** - * Runs `bun test` over one shard group. + * Runs `bun test` over one shard group: quick files in one process, heavy files one per process. * * bun tools/dev/unit-tests/src/run.ts [group] [--include-heavy | --heavy-only] [-- ] * @@ -25,17 +25,32 @@ if (!unitTestGroupNames().includes(group)) { const heavyOnly = flags.has('--heavy-only') const includeHeavy = heavyOnly || flags.has('--include-heavy') -const files = heavyOnly - ? await listHeavyUnitTests(group) - : await listUnitTests(group, { includeHeavy }) +const lightFiles = heavyOnly ? [] : await listUnitTests(group) +const heavyFiles = includeHeavy ? await listHeavyUnitTests(group) : [] -if (files.length === 0) { +if (lightFiles.length === 0 && heavyFiles.length === 0) { console.log(`No unit tests found for shard ${group}`) process.exit(0) } -const child = Bun.spawn([process.execPath, 'test', ...bunTestArgs, ...files], { - stdio: ['inherit', 'inherit', 'inherit'], - env: { ...process.env, BUN_HEAVY_TESTS: includeHeavy ? 'true' : 'false' } -}) -process.exit(await child.exited) +async function runBunTest(files: string[]): Promise { + const child = Bun.spawn([process.execPath, 'test', ...bunTestArgs, ...files], { + stdio: ['inherit', 'inherit', 'inherit'], + env: { ...process.env, BUN_HEAVY_TESTS: includeHeavy ? 'true' : 'false' } + }) + return child.exited +} + +// Light files share one process. Each heavy file gets its own, because one alone can take +// several gigabytes and a shared process would keep what earlier files left behind. Coverage +// is collected per process, so a coverage run keeps every file in one. +const coverage = bunTestArgs.some((arg) => arg.startsWith('--coverage')) +const batches = coverage + ? [[...lightFiles, ...heavyFiles]] + : [...(lightFiles.length > 0 ? [lightFiles] : []), ...heavyFiles.map((file) => [file])] +const failed: string[] = [] +for (const files of batches) { + if ((await runBunTest(files)) !== 0) failed.push(files.length === 1 ? files[0] : 'quick tests') +} +if (failed.length > 0) console.error(`\nFailed: ${failed.join(', ')}`) +process.exit(failed.length > 0 ? 1 : 0) diff --git a/tools/dev/unit-tests/src/shards.ts b/tools/dev/unit-tests/src/shards.ts index a9dae9b41..2c6d12efc 100644 --- a/tools/dev/unit-tests/src/shards.ts +++ b/tools/dev/unit-tests/src/shards.ts @@ -74,12 +74,23 @@ export const UNIT_TEST_GROUPS = { export type UnitTestGroup = keyof typeof UNIT_TEST_GROUPS | 'all' +/** + * Fixture corpora of tens of megabytes; parsing one takes gigabytes, so every test that reads + * them is heavy and runs in its own process. + */ +export const LARGE_FIXTURES = ['material3.fig', 'nuxtui.fig'] as const + +/** + * Tests that parse large fixtures. Quick runs skip them, and full runs give each its own + * process, since one alone can take several gigabytes. + */ export const HEAVY_UNIT_TEST_PATTERNS = [ 'tests/engine/clipboard/fixtures/', 'tests/engine/io/fig/heavy/', 'packages/core/tests/io/formats/fig/roundtrip/exhaustive.test.ts', 'packages/core/tests/io/formats/fig/roundtrip/glyph-blob.test.ts', 'packages/core/tests/io/formats/fig/roundtrip/variables.test.ts', + 'packages/core/tests/io/formats/fig/import/stale-overrides.test.ts', 'tests/engine/io/fig/export/text.test.ts', 'tests/engine/io/fig/export/worker.test.ts', 'tests/engine/io/fig/import/group-reclassify.test.ts', diff --git a/tools/dev/unit-tests/tests/shards.test.ts b/tools/dev/unit-tests/tests/shards.test.ts index 00fdbfdc6..0ceef86d0 100644 --- a/tools/dev/unit-tests/tests/shards.test.ts +++ b/tools/dev/unit-tests/tests/shards.test.ts @@ -4,6 +4,7 @@ import { resolveWorkspaceRoot } from '@open-pencil/package-artifacts-tools' import { isHeavyUnitTest, + LARGE_FIXTURES, listHeavyUnitTests, listUnitTests, pathsForUnitTestGroup, @@ -92,3 +93,15 @@ test('heavy unit test listing contains only heavy tests', async () => { expect(heavyFiles).toContain('tests/engine/io/fig/heavy/fixtures.test.ts') expect(heavyFiles.every(isHeavyUnitTest)).toBe(true) }) + +test('every test that reads a large fixture is heavy', async () => { + const quick = await listUnitTests('all') + const readers = await Promise.all( + quick.map(async (file) => { + const source = await Bun.file(`${REPO_ROOT}/${file}`).text() + return LARGE_FIXTURES.some((fixture) => source.includes(fixture)) ? [file] : [] + }) + ) + // One of these can take several gigabytes; add it to HEAVY_UNIT_TEST_PATTERNS. + expect(readers.flat()).toEqual([]) +})