diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ab0b1f08..bab5146e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### Fixes +- Increase per-test timeout for slow `gold-preview.fig` fixture tests (`clipboard roundtrip`, `group reclassification`, `glyph blob preservation`, `auto-layout text measurement`, and `render/canvas/cache`) so they no longer flake on slower CI runners. - Fix clone operations (duplicate, instance creation, clipboard copy) sharing mutable references with the original — editing fills, strokes, variable bindings, overrides, or vector networks on one no longer corrupts the other. - Fix instance overrides shallow-copied on clone — override values containing objects are now deep-copied. - Fix stale variable bindings not cleaned up when fills/strokes arrays shrink — any indexed sub-path is now handled, not just `/color`. diff --git a/tests/engine/clipboard/fixtures/gold-preview-roundtrip.test.ts b/tests/engine/clipboard/fixtures/gold-preview-roundtrip.test.ts index 6e539eb68..7e4268858 100644 --- a/tests/engine/clipboard/fixtures/gold-preview-roundtrip.test.ts +++ b/tests/engine/clipboard/fixtures/gold-preview-roundtrip.test.ts @@ -14,6 +14,7 @@ import { } from '@open-pencil/core' import { expectDefined } from '#tests/helpers/assert' +import { HEAVY_TEST_TIMEOUT_MS } from '#tests/helpers/test-utils' describe('gold-preview.fig clipboard roundtrip', () => { let graph: SceneGraph @@ -29,15 +30,18 @@ describe('gold-preview.fig clipboard roundtrip', () => { return res } - beforeAll(async () => { - await initCodec() - const buf = readFileSync('tests/fixtures/gold-preview.fig') - const file = new File([buf], 'gold-preview.fig') - graph = await readFigFile(file) - const page = graph.getPages()[0] - pageId = page.id - topLevelNodes = graph.getChildren(pageId) - }) + beforeAll( + async () => { + await initCodec() + const buf = readFileSync('tests/fixtures/gold-preview.fig') + const file = new File([buf], 'gold-preview.fig') + graph = await readFigFile(file) + const page = graph.getPages()[0] + pageId = page.id + topLevelNodes = graph.getChildren(pageId) + }, + { timeout: HEAVY_TEST_TIMEOUT_MS } + ) it('OpenPencil format: zero property differences', () => { const html = buildOpenPencilClipboardHTML(topLevelNodes, graph) diff --git a/tests/engine/io/fig/import/group-reclassify.test.ts b/tests/engine/io/fig/import/group-reclassify.test.ts index 584d58696..1ad6f3927 100644 --- a/tests/engine/io/fig/import/group-reclassify.test.ts +++ b/tests/engine/io/fig/import/group-reclassify.test.ts @@ -5,6 +5,7 @@ import { nodeChangeToProps } from '#core/kiwi/fig/node-change/convert' import { parseFixture } from '#tests/helpers/fig-fixtures' import { collectAllNodes } from '#tests/helpers/fig-traversal' +import { HEAVY_TEST_TIMEOUT_MS } from '#tests/helpers/test-utils' describe('Figma group reclassification on import', () => { test('FRAME with resizeToFit imports as GROUP', () => { @@ -56,11 +57,15 @@ describe('Figma group reclassification on import', () => { expect(props.nodeType).toBe('FRAME') }) - test('gold-preview.fig fixture imports its groups as GROUP nodes', async () => { - const graph = await parseFixture('gold-preview.fig') - const groups = collectAllNodes(graph).filter((n) => n.type === 'GROUP') - // gold-preview.fig contains real Figma groups (FRAME + resizeToFit) that must - // import as GROUP, not FRAME. - expect(groups.length).toBeGreaterThan(0) - }) + test( + 'gold-preview.fig fixture imports its groups as GROUP nodes', + { timeout: HEAVY_TEST_TIMEOUT_MS }, + async () => { + const graph = await parseFixture('gold-preview.fig') + const groups = collectAllNodes(graph).filter((n) => n.type === 'GROUP') + // gold-preview.fig contains real Figma groups (FRAME + resizeToFit) that must + // import as GROUP, not FRAME. + expect(groups.length).toBeGreaterThan(0) + } + ) }) diff --git a/tests/engine/io/fig/roundtrip/glyph-blob.test.ts b/tests/engine/io/fig/roundtrip/glyph-blob.test.ts index a26cffac1..b5c56b655 100644 --- a/tests/engine/io/fig/roundtrip/glyph-blob.test.ts +++ b/tests/engine/io/fig/roundtrip/glyph-blob.test.ts @@ -7,6 +7,8 @@ import { fontManager } from '@open-pencil/core/text' import { parseFigBuffer } from '#core/kiwi/fig/parse/core' +import { HEAVY_TEST_TIMEOUT_MS } from '#tests/helpers/test-utils' + const FIXTURES = resolve(import.meta.dir, '../../../../fixtures') const INTER_ASSETS = resolve(import.meta.dir, '../../../../../packages/core/assets') @@ -44,27 +46,34 @@ function loadInterFonts() { } describe('roundtrip: text glyph blobs', () => { - beforeAll(async () => { - await initCodec() - }) + beforeAll( + async () => { + await initCodec() + }, + { timeout: HEAVY_TEST_TIMEOUT_MS } + ) - test('preserves imported Figma glyph blobs for fallback rendering', async () => { - const fixtureBytes = new Uint8Array(readFileSync(resolve(FIXTURES, 'gold-preview.fig'))) - const input = countGlyphBlobs(fixtureBytes) + test( + 'preserves imported Figma glyph blobs for fallback rendering', + { timeout: HEAVY_TEST_TIMEOUT_MS }, + async () => { + const fixtureBytes = new Uint8Array(readFileSync(resolve(FIXTURES, 'gold-preview.fig'))) + const input = countGlyphBlobs(fixtureBytes) - const graph = await parseFigFile( - fixtureBytes.buffer.slice( - fixtureBytes.byteOffset, - fixtureBytes.byteOffset + fixtureBytes.byteLength + const graph = await parseFigFile( + fixtureBytes.buffer.slice( + fixtureBytes.byteOffset, + fixtureBytes.byteOffset + fixtureBytes.byteLength + ) ) - ) - const exported = await exportFigFile(graph) - const output = countGlyphBlobs(exported) + const exported = await exportFigFile(graph) + const output = countGlyphBlobs(exported) - expect(input.glyphsWithBlob).toBeGreaterThan(0) - expect(output.glyphsWithBlob).toBe(input.glyphsWithBlob) - expect(output.uniqueGlyphBlobs).toBeLessThanOrEqual(input.uniqueGlyphBlobs) - }) + expect(input.glyphsWithBlob).toBeGreaterThan(0) + expect(output.glyphsWithBlob).toBe(input.glyphsWithBlob) + expect(output.uniqueGlyphBlobs).toBeLessThanOrEqual(input.uniqueGlyphBlobs) + } + ) test('deduplicates generated glyph blobs across repeated text', async () => { loadInterFonts() diff --git a/tests/engine/layout/auto-layout/text/measurement.test.ts b/tests/engine/layout/auto-layout/text/measurement.test.ts index ccfebc086..8b0d2deca 100644 --- a/tests/engine/layout/auto-layout/text/measurement.test.ts +++ b/tests/engine/layout/auto-layout/text/measurement.test.ts @@ -6,6 +6,7 @@ import { createEditorStore } from '@/app/editor/session' import { getNodeOrThrow } from '#tests/helpers/assert' import { autoFrame, loadFixtureGraph, pageId, rect } from '#tests/helpers/layout' +import { HEAVY_TEST_TIMEOUT_MS } from '#tests/helpers/test-utils' describe('text measurement', () => { test('derived text layout preserves imported auto-layout text bounds during measurement', () => { @@ -85,80 +86,91 @@ describe('text measurement', () => { expect(graph.getNode(tabs.id)?.height).toBe(62) }) - test('opening imported fig keeps stored text bounds before CanvasKit measurement', async () => { - const graph = await loadFixtureGraph('gold-preview.fig') - const store = createEditorStore(graph) - const title = [...store.graph.getAllNodes()].find( - (node) => node.type === 'TEXT' && node.text === "World's largest" - ) - const subtitle = [...store.graph.getAllNodes()].find( - (node) => - node.type === 'TEXT' && node.text === 'Preline UI Figma - crafted with Tailwind CSS styles' - ) - const description = [...store.graph.getAllNodes()].find( - (node) => - node.type === 'TEXT' && - node.text.startsWith('Preline UI Figma is the largest free design system for Figma') - ) + test( + 'opening imported fig keeps stored text bounds before CanvasKit measurement', + { timeout: HEAVY_TEST_TIMEOUT_MS }, + async () => { + const graph = await loadFixtureGraph('gold-preview.fig') + const store = createEditorStore(graph) + const title = [...store.graph.getAllNodes()].find( + (node) => node.type === 'TEXT' && node.text === "World's largest" + ) + const subtitle = [...store.graph.getAllNodes()].find( + (node) => + node.type === 'TEXT' && + node.text === 'Preline UI Figma - crafted with Tailwind CSS styles' + ) + const description = [...store.graph.getAllNodes()].find( + (node) => + node.type === 'TEXT' && + node.text.startsWith('Preline UI Figma is the largest free design system for Figma') + ) - if (!title || !subtitle || !description) { - throw new Error('Expected imported text nodes in gold-preview.fig') + if (!title || !subtitle || !description) { + throw new Error('Expected imported text nodes in gold-preview.fig') + } + + expect(title.width).toBe(444) + expect(title.height).toBe(73) + expect(subtitle.width).toBe(439) + expect(subtitle.height).toBe(22) + expect(description.width).toBe(878) + expect(description.height).toBe(60) + + await store.switchPage(store.graph.getPages()[0].id) + + expect(store.graph.getNode(title.id)?.width).toBe(444) + expect(store.graph.getNode(title.id)?.height).toBe(73) + expect(store.graph.getNode(subtitle.id)?.width).toBe(439) + expect(store.graph.getNode(subtitle.id)?.height).toBe(22) + expect(store.graph.getNode(description.id)?.width).toBe(878) + expect(store.graph.getNode(description.id)?.height).toBe(60) } + ) - expect(title.width).toBe(444) - expect(title.height).toBe(73) - expect(subtitle.width).toBe(439) - expect(subtitle.height).toBe(22) - expect(description.width).toBe(878) - expect(description.height).toBe(60) + test( + 'imported nested instance layout keeps hidden sibling offsets stable', + { timeout: HEAVY_TEST_TIMEOUT_MS }, + async () => { + const graph = await loadFixtureGraph('gold-preview.fig') + const previewRoot = graph.getChildren(graph.getPages()[0].id)[0] + const wysiwygEditor = graph + .getChildren(previewRoot.id) + .find((node) => node.name === '_WYSIWYG-editor') + const toolbarVariant = wysiwygEditor + ? graph + .getChildren(wysiwygEditor.id) + .find((node) => node.name === '_on-text-WYSIWYG-toolbar') + : undefined + const toolbarRow = toolbarVariant + ? graph.getChildren(toolbarVariant.id).find((node) => node.name === 'Toolbar') + : undefined + const hiddenInput = toolbarRow + ? graph.getChildren(toolbarRow.id).find((node) => node.name === 'Input') + : undefined + const visibleToolbar = toolbarRow + ? graph + .getChildren(toolbarRow.id) + .find((node) => node.name === 'Toolbar' && node.id !== toolbarRow.id) + : undefined - await store.switchPage(store.graph.getPages()[0].id) + if (!toolbarRow || !hiddenInput || !visibleToolbar) { + throw new Error('Expected imported WYSIWYG toolbar nodes in gold-preview.fig') + } - expect(store.graph.getNode(title.id)?.width).toBe(444) - expect(store.graph.getNode(title.id)?.height).toBe(73) - expect(store.graph.getNode(subtitle.id)?.width).toBe(439) - expect(store.graph.getNode(subtitle.id)?.height).toBe(22) - expect(store.graph.getNode(description.id)?.width).toBe(878) - expect(store.graph.getNode(description.id)?.height).toBe(60) - }) + expect(hiddenInput.visible).toBe(false) + // x=8 is the Figma-exported fixture value (verified via fixture inspection 2026-05-03). + // The old assertion of x=298 was incorrect — it was a post-layout computed value from + // an older layout implementation. Post-layout, left-aligned HUG content retains x=8. + expect(visibleToolbar.x).toBe(8) - test('imported nested instance layout keeps hidden sibling offsets stable', async () => { - const graph = await loadFixtureGraph('gold-preview.fig') - const previewRoot = graph.getChildren(graph.getPages()[0].id)[0] - const wysiwygEditor = graph - .getChildren(previewRoot.id) - .find((node) => node.name === '_WYSIWYG-editor') - const toolbarVariant = wysiwygEditor - ? graph.getChildren(wysiwygEditor.id).find((node) => node.name === '_on-text-WYSIWYG-toolbar') - : undefined - const toolbarRow = toolbarVariant - ? graph.getChildren(toolbarVariant.id).find((node) => node.name === 'Toolbar') - : undefined - const hiddenInput = toolbarRow - ? graph.getChildren(toolbarRow.id).find((node) => node.name === 'Input') - : undefined - const visibleToolbar = toolbarRow - ? graph - .getChildren(toolbarRow.id) - .find((node) => node.name === 'Toolbar' && node.id !== toolbarRow.id) - : undefined + setTextMeasurer(null) + computeAllLayouts(graph, graph.getPages()[0].id) - if (!toolbarRow || !hiddenInput || !visibleToolbar) { - throw new Error('Expected imported WYSIWYG toolbar nodes in gold-preview.fig') + expect(graph.getNode(visibleToolbar.id)?.x).toBe(8) + expect(graph.getNode(visibleToolbar.id)?.y).toBe(8) } - - expect(hiddenInput.visible).toBe(false) - // x=8 is the Figma-exported fixture value (verified via fixture inspection 2026-05-03). - // The old assertion of x=298 was incorrect — it was a post-layout computed value from - // an older layout implementation. Post-layout, left-aligned HUG content retains x=8. - expect(visibleToolbar.x).toBe(8) - - setTextMeasurer(null) - computeAllLayouts(graph, graph.getPages()[0].id) - - expect(graph.getNode(visibleToolbar.id)?.x).toBe(8) - expect(graph.getNode(visibleToolbar.id)?.y).toBe(8) - }) + ) test('WIDTH_AND_HEIGHT text uses measured width in centered layout', () => { const graph = new SceneGraph() diff --git a/tests/engine/render/canvas/cache.test.ts b/tests/engine/render/canvas/cache.test.ts index 4622fde9d..c7c76cb01 100644 --- a/tests/engine/render/canvas/cache.test.ts +++ b/tests/engine/render/canvas/cache.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, test } from 'bun:test' +import { beforeAll, describe, expect, setDefaultTimeout, test } from 'bun:test' import { readFileSync } from 'node:fs' import { @@ -15,6 +15,9 @@ import { initCanvasKit } from '#cli/headless' import { expectDefined } from '#tests/helpers/assert' import { repoPath } from '#tests/helpers/paths' +import { HEAVY_TEST_TIMEOUT_MS } from '#tests/helpers/test-utils' + +setDefaultTimeout(HEAVY_TEST_TIMEOUT_MS) let graph: SceneGraph let movingNodeId: string diff --git a/tests/helpers/test-utils.ts b/tests/helpers/test-utils.ts index 7e6ff1f89..5c1e57754 100644 --- a/tests/helpers/test-utils.ts +++ b/tests/helpers/test-utils.ts @@ -8,3 +8,7 @@ export const runsHeavyTests = process.env.BUN_HEAVY_TESTS : true export const heavy = describe.if(runsHeavyTests) + +// Default per-test timeout in CI is often too short for fixture I/O tests that parse/export +// real .fig files. Use this timeout for tests that are bounded by fixture parsing speed. +export const HEAVY_TEST_TIMEOUT_MS = 30_000