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([]) +})