diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d9d271de4..663975e82 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -295,7 +295,6 @@ export { FIG_KIWI_DEFAULT_VERSION, buildFigKiwi, parseFigKiwiChunks, - decompressFigKiwiData, decompressFigKiwiDataAsync, buildFontDigestMap, sceneNodeToKiwi, diff --git a/packages/core/src/io/formats/fig/export.ts b/packages/core/src/io/formats/fig/export.ts index 79e28cfd0..47e3f6012 100644 --- a/packages/core/src/io/formats/fig/export.ts +++ b/packages/core/src/io/formats/fig/export.ts @@ -108,15 +108,22 @@ function assignVariableGuids( graph: SceneGraph, localIdCounter: { value: number }, varIdToGuid: Map, - modeIdToGuid: Map + modeIdToGuid: Map, + assignedGuidValues: Set ): void { for (const [colId, col] of graph.variableCollections) { - varIdToGuid.set(colId, { sessionID: 0, localID: localIdCounter.value++ }) + const colGuid = { sessionID: 0, localID: localIdCounter.value++ } + varIdToGuid.set(colId, colGuid) + assignedGuidValues.add(`${colGuid.sessionID}:${colGuid.localID}`) for (const mode of col.modes) { - modeIdToGuid.set(mode.modeId, { sessionID: 0, localID: localIdCounter.value++ }) + const modeGuid = { sessionID: 0, localID: localIdCounter.value++ } + modeIdToGuid.set(mode.modeId, modeGuid) + assignedGuidValues.add(`${modeGuid.sessionID}:${modeGuid.localID}`) } for (const varId of col.variableIds) { - varIdToGuid.set(varId, { sessionID: 0, localID: localIdCounter.value++ }) + const varGuid = { sessionID: 0, localID: localIdCounter.value++ } + varIdToGuid.set(varId, varGuid) + assignedGuidValues.add(`${varGuid.sessionID}:${varGuid.localID}`) } } } @@ -230,21 +237,30 @@ function buildCanvasEntries( pages: FigExportPage[], docGuid: GUID, localIdCounter: { value: number }, - nodeIdToGuid: Map + nodeIdToGuid: Map, + assignedGuidValues: Set ): { canvasEntries: CanvasExportEntry[]; internalCanvasGuid: GUID | null } { const canvasEntries: CanvasExportEntry[] = [] let internalCanvasGuid: GUID | null = null for (let p = 0; p < pages.length; p++) { const page = pages[p] - const canvasGuid = page.source.id - ? stringToGuid(page.source.id) - : { sessionID: 0, localID: localIdCounter.value++ } + const canvasGuid = (() => { + if (!page.source.id) return { sessionID: 0, localID: localIdCounter.value++ } + + const importedGuid = stringToGuid(page.source.id) + const key = `${importedGuid.sessionID}:${importedGuid.localID}` + + if (!assignedGuidValues.has(key)) return importedGuid + + return { sessionID: 0, localID: localIdCounter.value++ } + })() // Advance counter past any source.id-derived GUID to prevent collisions // with subsequently generated variable/collection GUIDs. if (page.source.id && canvasGuid.sessionID === 0) { localIdCounter.value = Math.max(localIdCounter.value, canvasGuid.localID + 1) } nodeIdToGuid.set(page.id, canvasGuid) + assignedGuidValues.add(`${canvasGuid.sessionID}:${canvasGuid.localID}`) if (page.internalOnly) internalCanvasGuid = canvasGuid const canvasNc = makeCanvasNodeChange( @@ -265,6 +281,7 @@ function buildCanvasEntries( if (graph.variableCollections.size > 0 && internalCanvasGuid === null) { internalCanvasGuid = { sessionID: 0, localID: localIdCounter.value++ } + assignedGuidValues.add(`${internalCanvasGuid.sessionID}:${internalCanvasGuid.localID}`) canvasEntries.push({ page: { id: '', name: 'Internal Only Canvas', internalOnly: true } as FigExportPage, canvasGuid: internalCanvasGuid, @@ -320,36 +337,47 @@ export async function exportFigFile( const blobs: Uint8Array[] = [] const pages = graph.getPages(true) const nodeIdToGuid = new Map() + const assignedGuidValues = new Set() + // Reserve the document GUID to prevent imported nodes with source.id "0:0" + // from reusing the document's own GUID slot. + assignedGuidValues.add(`${docGuid.sessionID}:${docGuid.localID}`) const varIdToGuid = new Map() const modeIdToGuid = new Map() const fontDigestMap = await buildFontDigestMap(graph) const glyphBlobMap = new Map() const blobIndexByHex = new Map() + // Scan ALL imported source.ids BEFORE any new GUID assignment to find + // max sessionID:0 and sessionID:1 localID values. This guarantees the + // counter is past every imported GUID before any canvas, variable, or + // node claims a new counter-based GUID — preventing collisions. + let maxLocalId0 = localIdCounter.value - 1 + let maxLocalId1 = localIdCounter.value - 1 + for (const node of graph.nodes.values()) { + if (node.source.id) { + const g = stringToGuid(node.source.id) + if (g.sessionID === 0 && g.localID > maxLocalId0) { + maxLocalId0 = g.localID + } + if (g.sessionID === 1 && g.localID > maxLocalId1) { + maxLocalId1 = g.localID + } + } + } + localIdCounter.value = Math.max(localIdCounter.value, maxLocalId0 + 1, maxLocalId1 + 1) + const { canvasEntries, internalCanvasGuid } = buildCanvasEntries( graph, pages, docGuid, localIdCounter, - nodeIdToGuid + nodeIdToGuid, + assignedGuidValues ) - // Scan ALL imported source.ids to find max sessionID:0 localID, - // preventing collisions between variable GUIDs and any imported node GUID. - let maxLocalId0 = localIdCounter.value - 1 - for (const node of graph.nodes.values()) { - if (node.source.id) { - const guid = stringToGuid(node.source.id) - if (guid.sessionID === 0 && guid.localID > maxLocalId0) { - maxLocalId0 = guid.localID - } - } - } - localIdCounter.value = Math.max(localIdCounter.value, maxLocalId0 + 1) - // Assign variable GUIDs AFTER canvas entries so that source.id-derived // canvas GUIDs don't collide with generated variable GUIDs. - assignVariableGuids(graph, localIdCounter, varIdToGuid, modeIdToGuid) + assignVariableGuids(graph, localIdCounter, varIdToGuid, modeIdToGuid, assignedGuidValues) for (const entry of canvasEntries) nodeChanges.push(entry.canvasNc) @@ -372,7 +400,8 @@ export async function exportFigFile( fontDigestMap, varIdToGuid, glyphBlobMap, - blobIndexByHex + blobIndexByHex, + assignedGuidValues ) ) } diff --git a/packages/core/src/kiwi/fig/container/kiwi.ts b/packages/core/src/kiwi/fig/container/kiwi.ts index 5ac08bb10..5ae556861 100644 --- a/packages/core/src/kiwi/fig/container/kiwi.ts +++ b/packages/core/src/kiwi/fig/container/kiwi.ts @@ -19,20 +19,22 @@ export function parseFigKiwiChunks(binary: Uint8Array): Uint8Array[] | null { return chunks.length >= 2 ? chunks : null } -export function decompressFigKiwiData(compressed: Uint8Array): Uint8Array { - try { - return inflateSync(compressed) - } catch { - throw new Error('Failed to decompress fig-kiwi data') - } -} - export async function decompressFigKiwiDataAsync(compressed: Uint8Array): Promise { + if ( + compressed.length >= 4 && + compressed[0] === 0x28 && + compressed[1] === 0xb5 && + compressed[2] === 0x2f && + compressed[3] === 0xfd + ) { + const { decompress } = await import('fzstd') + return decompress(compressed) + } try { return inflateSync(compressed) } catch { - const fzstd = await import('fzstd') - return fzstd.decompress(compressed) + const { decompress } = await import('fzstd') + return decompress(compressed) } } @@ -41,9 +43,18 @@ export function buildFigKiwi( dataRaw: Uint8Array, version = FIG_KIWI_DEFAULT_VERSION ): Uint8Array { - const dataDeflated = deflateSync(dataRaw) + let dataCompressed: Uint8Array + const zstdCompress: ((data: Uint8Array) => Uint8Array) | undefined = (() => { + const g = globalThis as { Bun?: { zstdCompressSync?: (data: Uint8Array) => Uint8Array } } + return g.Bun?.zstdCompressSync + })() + if (zstdCompress) { + dataCompressed = zstdCompress(dataRaw) + } else { + dataCompressed = deflateSync(dataRaw) + } - const total = 8 + 4 + 4 + schemaDeflated.length + 4 + dataDeflated.length + const total = 8 + 4 + 4 + schemaDeflated.length + 4 + dataCompressed.length const out = new Uint8Array(total) const view = new DataView(out.buffer) @@ -56,9 +67,9 @@ export function buildFigKiwi( out.set(schemaDeflated, offset) offset += schemaDeflated.length - view.setUint32(offset, dataDeflated.length, true) + view.setUint32(offset, dataCompressed.length, true) offset += 4 - out.set(dataDeflated, offset) + out.set(dataCompressed, offset) return out } diff --git a/packages/core/src/kiwi/fig/node-change/export-node.ts b/packages/core/src/kiwi/fig/node-change/export-node.ts index b5e7e94d8..8f987e438 100644 --- a/packages/core/src/kiwi/fig/node-change/export-node.ts +++ b/packages/core/src/kiwi/fig/node-change/export-node.ts @@ -41,6 +41,9 @@ interface SceneNodeToKiwiContext { blobs: Uint8Array[] blobIndexByHex?: Map nodeIdToGuid?: Map + /** Reverse index of assigned GUID values ("sessionID:localID") for O(1) + * collision detection. Populated alongside every nodeIdToGuid.set() call. */ + assignedGuidValues?: Set fontDigestMap?: Map glyphBlobMap?: Map varIdToGuid?: Map @@ -287,13 +290,28 @@ function getOrCreateNodeGuid( nodeId: string, localIdCounter: { value: number } ): GUID | undefined { - if (!context.graph.getNode(nodeId)) return undefined + const node = context.graph.getNode(nodeId) + if (!node) return undefined const existing = context.nodeIdToGuid?.get(nodeId) if (existing) return existing - const node = context.graph.getNode(nodeId) - const importedGuid = node?.source.id ? parseGuidOrNull(node.source.id) : null + const importedGuid = node.source.id ? parseGuidOrNull(node.source.id) : null + + // When source.id maps to a GUID value that is already assigned to a + // different node (e.g. two nodes from different canvases with the same + // source.id "1:94"), fall back to the counter to avoid collisions. + if (importedGuid && context.assignedGuidValues) { + const key = `${importedGuid.sessionID}:${importedGuid.localID}` + if (context.assignedGuidValues.has(key)) { + const guid: GUID = { sessionID: 1, localID: localIdCounter.value++ } + context.nodeIdToGuid?.set(nodeId, guid) + context.assignedGuidValues.add(`${guid.sessionID}:${guid.localID}`) + return guid + } + } + const guid = importedGuid ?? { sessionID: 1, localID: localIdCounter.value++ } context.nodeIdToGuid?.set(nodeId, guid) + context.assignedGuidValues?.add(`${guid.sessionID}:${guid.localID}`) return guid } @@ -684,7 +702,9 @@ export function sceneNodeToKiwiWithContext( applyInstancePayload(context, node, nc, localIdCounter) if (node.type === 'COMPONENT_SET') upsertPluginData(node, NODE_TYPE_PLUGIN_KEY, node.type) if (nc.type === 'CANVAS') nc.pageType = 'DESIGN' - if (node.type === 'BOOLEAN_OPERATION') nc.booleanOperation = node.booleanOperation ?? 'UNION' + if (node.type === 'BOOLEAN_OPERATION') + nc.booleanOperation = + node.booleanOperation === 'EXCLUDE' ? 'XOR' : (node.booleanOperation ?? 'UNION') if (strokePaints.length > 0) nc.strokePaints = strokePaints context.serializeLayoutProps(node, nc) diff --git a/packages/core/src/kiwi/fig/node-change/serialize.ts b/packages/core/src/kiwi/fig/node-change/serialize.ts index 46d51dc2d..eddf94280 100644 --- a/packages/core/src/kiwi/fig/node-change/serialize.ts +++ b/packages/core/src/kiwi/fig/node-change/serialize.ts @@ -7,7 +7,6 @@ import { encodeVectorNetworkBlob, buildStyleOverrideTable } from '#core/vector' export { buildFigKiwi, - decompressFigKiwiData, decompressFigKiwiDataAsync, FIG_KIWI_DEFAULT_VERSION, parseFigKiwiChunks @@ -515,7 +514,8 @@ export function sceneNodeToKiwi( fontDigestMap?: Map, varIdToGuid?: Map, glyphBlobMap = new Map(), - blobIndexByHex?: Map + blobIndexByHex?: Map, + assignedGuidValues?: Set ): KiwiNodeChange[] { // Build assetRef to guid mapping for converting colorVar references in raw paints const assetRefToVarGuid = varIdToGuid ? buildAssetRefToVarGuidMap(graph, varIdToGuid) : undefined @@ -524,6 +524,7 @@ export function sceneNodeToKiwi( blobs, blobIndexByHex, nodeIdToGuid, + assignedGuidValues, fontDigestMap, glyphBlobMap, varIdToGuid, diff --git a/packages/core/src/kiwi/fig/parse/core.ts b/packages/core/src/kiwi/fig/parse/core.ts index 2144fa601..36d912560 100644 --- a/packages/core/src/kiwi/fig/parse/core.ts +++ b/packages/core/src/kiwi/fig/parse/core.ts @@ -72,11 +72,7 @@ export function parseFigKiwiContainer(data: Uint8Array): FigKiwiPayload | null { if (isZstdCompressed(compressed)) { dataRaw = zstdDecompress(compressed) } else { - try { - dataRaw = inflateSync(compressed) - } catch { - dataRaw = compressed - } + dataRaw = inflateSync(compressed) } return { schemaDeflated: chunks[0], dataRaw, version } diff --git a/src/app/document/io/read.ts b/src/app/document/io/read.ts index 9aba9e75d..1349afed8 100644 --- a/src/app/document/io/read.ts +++ b/src/app/document/io/read.ts @@ -86,9 +86,9 @@ export function createReloadActions({ editor.replaceGraph(imported) editor.undo.clear() - setSavedVersion(state.sceneVersion) restoreReloadState(editor, state, snapshot) editor.requestRender() + setSavedVersion(state.sceneVersion) } return { reloadFromDisk } diff --git a/tests/engine/clipboard/fixtures/gold-preview-roundtrip.test.ts b/tests/engine/clipboard/fixtures/gold-preview-roundtrip.test.ts index 6e539eb68..1ac0792fe 100644 --- a/tests/engine/clipboard/fixtures/gold-preview-roundtrip.test.ts +++ b/tests/engine/clipboard/fixtures/gold-preview-roundtrip.test.ts @@ -37,7 +37,7 @@ describe('gold-preview.fig clipboard roundtrip', () => { const page = graph.getPages()[0] pageId = page.id topLevelNodes = graph.getChildren(pageId) - }) + }, 30_000) it('OpenPencil format: zero property differences', () => { const html = buildOpenPencilClipboardHTML(topLevelNodes, graph) @@ -71,7 +71,7 @@ describe('gold-preview.fig clipboard roundtrip', () => { } } expect(diffs).toBe(0) - }) + }, 30_000) it('OpenPencil format: compressed data is under 1MB', () => { const html = buildOpenPencilClipboardHTML(topLevelNodes, graph) @@ -129,5 +129,5 @@ describe('gold-preview.fig clipboard roundtrip', () => { errors.push(`arcData: "${o.name}" expected ${o.arcData != null}, got ${p.arcData != null}`) } if (errors.length > 0) throw new Error(`${errors.length} mismatches:\n${errors.join('\n')}`) - }) + }, 30_000) }) diff --git a/tests/engine/io/fig/export/guid-collision.test.ts b/tests/engine/io/fig/export/guid-collision.test.ts new file mode 100644 index 000000000..5c5a1c931 --- /dev/null +++ b/tests/engine/io/fig/export/guid-collision.test.ts @@ -0,0 +1,136 @@ +import { beforeAll, describe, expect, test } from 'bun:test' + +import { exportFigFile, initCodec, parseFigFile, SceneGraph } from '@open-pencil/core' + +/** + * Regression test: two distinct nodes sharing the same source.id + * (as happens with component-instance children) must receive + * different GUIDs on export, preventing silent data loss on reimport. + */ +describe('export: GUID collision prevention', () => { + beforeAll(async () => { + await initCodec() + }) + + test('two nodes with same source.id get different GUIDs on export', async () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + + // Create two nodes that share the same Figma source.id + // (simulates component master + instance child) + const rect1 = graph.createNode('RECTANGLE', page.id, { + name: 'Master Child', + width: 100, + height: 50 + }) + graph.updateNode(rect1.id, { + source: { ...rect1.source, id: '1:94', format: 'fig' } + }) + + const rect2 = graph.createNode('RECTANGLE', page.id, { + name: 'Instance Child', + width: 100, + height: 50 + }) + graph.updateNode(rect2.id, { + source: { ...rect2.source, id: '1:94', format: 'fig' } + }) + + const figBytes = await exportFigFile(graph) + const reimported = await parseFigFile(figBytes.buffer as ArrayBuffer) + + // Both nodes must survive reimport — no silent last-write-wins + const allNodes = [...reimported.getAllNodes()] + const rects = allNodes.filter( + (n) => n.type === 'RECTANGLE' && (n.name === 'Master Child' || n.name === 'Instance Child') + ) + expect(rects.length).toBe(2) + }) + + test('cloned node does not collide with original GUID', async () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + + const rect = graph.createNode('RECTANGLE', page.id, { + name: 'Original', + width: 100, + height: 50 + }) + graph.updateNode(rect.id, { + source: { ...rect.source, id: '1:200', format: 'fig' } + }) + + // Clone the node — cloneTree should clear source.id + const clone = graph.cloneTree(rect.id, page.id) + expect(clone).not.toBeNull() + if (!clone) throw new Error('Expected clone to exist') + expect(clone.source.id).toBeNull() + + const figBytes = await exportFigFile(graph) + const reimported = await parseFigFile(figBytes.buffer as ArrayBuffer) + + const allNodes = [...reimported.getAllNodes()] + const rects = allNodes.filter((n) => n.type === 'RECTANGLE') + // Both original and clone must survive + expect(rects.length).toBe(2) + }) + + test('export roundtrip preserves three nodes with identical source.id', async () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + + for (let i = 0; i < 3; i++) { + const rect = graph.createNode('RECTANGLE', page.id, { + name: `Rect ${i}`, + width: 50, + height: 50 + }) + graph.updateNode(rect.id, { + source: { ...rect.source, id: '1:500', format: 'fig' } + }) + } + + const figBytes = await exportFigFile(graph) + const reimported = await parseFigFile(figBytes.buffer as ArrayBuffer) + + const allNodes = [...reimported.getAllNodes()] + const rects = allNodes.filter((n) => n.type === 'RECTANGLE') + expect(rects.length).toBe(3) + }) + + test('nodes with session-0 source.id do not collide with document GUID', async () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + + // The document GUID is always {sessionID:0, localID:0}. + // Imported nodes with source.id in the 0:* namespace must not reuse + // that slot — the export must reserve the document GUID first. + const rect1 = graph.createNode('RECTANGLE', page.id, { + name: 'S0 Node A', + width: 100, + height: 50 + }) + graph.updateNode(rect1.id, { + source: { ...rect1.source, id: '0:94', format: 'fig' } + }) + + const rect2 = graph.createNode('RECTANGLE', page.id, { + name: 'S0 Node B', + width: 100, + height: 50 + }) + graph.updateNode(rect2.id, { + source: { ...rect2.source, id: '0:94', format: 'fig' } + }) + + const figBytes = await exportFigFile(graph) + const reimported = await parseFigFile(figBytes.buffer as ArrayBuffer) + + const allNodes = [...reimported.getAllNodes()] + const rects = allNodes.filter( + (n) => n.type === 'RECTANGLE' && (n.name === 'S0 Node A' || n.name === 'S0 Node B') + ) + // Both nodes must survive — the document GUID (0:0) must not swallow them + expect(rects.length).toBe(2) + }) +}) diff --git a/tests/engine/io/fig/export/parse-failures.test.ts b/tests/engine/io/fig/export/parse-failures.test.ts new file mode 100644 index 000000000..24c8b2a8c --- /dev/null +++ b/tests/engine/io/fig/export/parse-failures.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'bun:test' + +import { parseFigKiwiContainer } from '#core/kiwi/fig/parse/core' + +/** + * Build a minimal fig-kiwi container with a valid header + schema chunk + * but a corrupt (non-compressible) data chunk that will cause inflateSync + * to throw. This verifies that the decompressor does NOT silently fall + * back to raw compressed bytes. + */ +function buildCorruptFigKiwi(): Uint8Array { + const schemaDeflated = new Uint8Array([0x78, 0x01, 0x01, 0x00, 0x00]) // minimal deflate + // Data chunk: random bytes that are neither valid zlib nor valid zstd + const dataCorrupt = new Uint8Array(32) + for (let i = 0; i < dataCorrupt.length; i++) dataCorrupt[i] = (i * 37) & 0xff + + const header = new TextEncoder().encode('fig-kiwi') + + const total = 8 + 4 + 4 + schemaDeflated.length + 4 + dataCorrupt.length + const out = new Uint8Array(total) + const view = new DataView(out.buffer, out.byteOffset, out.byteLength) + let offset = 0 + out.set(header, offset) + offset += 8 + view.setUint32(offset, 101, true) + offset += 4 + view.setUint32(offset, schemaDeflated.length, true) + offset += 4 + out.set(schemaDeflated, offset) + offset += schemaDeflated.length + view.setUint32(offset, dataCorrupt.length, true) + offset += 4 + out.set(dataCorrupt, offset) + + return out +} + +describe('parseFigKiwiContainer: decompression failures', () => { + test('throws on corrupt data chunk (not raw bytes)', () => { + const buf = buildCorruptFigKiwi() + expect(() => parseFigKiwiContainer(buf)).toThrow() + }) + + test('returns null for missing header', () => { + const buf = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]) + expect(parseFigKiwiContainer(buf)).toBeNull() + }) + + test('returns null for fewer than 2 chunks', () => { + const header = new TextEncoder().encode('fig-kiwi') + const schemaDeflated = new Uint8Array([0x78, 0x01]) + // No data chunk — only one chunk total + + const total = 8 + 4 + 4 + schemaDeflated.length + const out = new Uint8Array(total) + const view = new DataView(out.buffer, out.byteOffset, out.byteLength) + let offset = 0 + out.set(header, offset) + offset += 8 + view.setUint32(offset, 101, true) + offset += 4 + view.setUint32(offset, schemaDeflated.length, true) + offset += 4 + out.set(schemaDeflated, offset) + + expect(parseFigKiwiContainer(out)).toBeNull() + }) +}) diff --git a/tests/engine/io/fig/export/text.test.ts b/tests/engine/io/fig/export/text.test.ts index bdd663c27..f039aebe0 100644 --- a/tests/engine/io/fig/export/text.test.ts +++ b/tests/engine/io/fig/export/text.test.ts @@ -1,6 +1,12 @@ import { describe, expect, setDefaultTimeout, test } from 'bun:test' -import { exportFigFile, initCodec, parseFigFile, SceneGraph } from '@open-pencil/core' +import { + decompressFigKiwiDataAsync, + exportFigFile, + initCodec, + parseFigFile, + SceneGraph +} from '@open-pencil/core' import type { JsonObject } from '@open-pencil/core/types' import { expectDefined } from '#tests/helpers/assert' @@ -93,7 +99,7 @@ describe('text node export', () => { const compiled = compileSchema(schema) as { decodeMessage(data: Uint8Array): Record } - const dataRaw = inflateSync(chunks?.[1] ?? new Uint8Array()) + const dataRaw = await decompressFigKiwiDataAsync(chunks?.[1] ?? new Uint8Array()) const message = compiled.decodeMessage(dataRaw) const nodeChanges = message.nodeChanges as JsonObject[] @@ -149,7 +155,9 @@ describe('text node export', () => { const compiled = compileSchema(schema) as { decodeMessage(data: Uint8Array): Record } - const message = compiled.decodeMessage(inflateSync(chunks?.[1] ?? new Uint8Array())) + const message = compiled.decodeMessage( + await decompressFigKiwiDataAsync(chunks?.[1] ?? new Uint8Array()) + ) const nodeChanges = message.nodeChanges as JsonObject[] const textNc = expectDefined( nodeChanges.find((nc) => nc.type === 'TEXT'), @@ -200,7 +208,9 @@ describe('text node export', () => { const compiled = compileSchema(schema) as { decodeMessage(data: Uint8Array): Record } - const message = compiled.decodeMessage(inflateSync(chunks?.[1] ?? new Uint8Array())) + const message = compiled.decodeMessage( + await decompressFigKiwiDataAsync(chunks?.[1] ?? new Uint8Array()) + ) const nodeChanges = message.nodeChanges as JsonObject[] const textNc = expectDefined( nodeChanges.find((nc) => nc.type === 'TEXT'), @@ -247,7 +257,7 @@ describe('text node export', () => { const compiled = compileSchema(schema) as { decodeMessage(data: Uint8Array): Record } - const dataRaw = inflateSync(chunks?.[1] ?? new Uint8Array()) + const dataRaw = await decompressFigKiwiDataAsync(chunks?.[1] ?? new Uint8Array()) const message = compiled.decodeMessage(dataRaw) const nodeChanges = message.nodeChanges as JsonObject[] diff --git a/tests/engine/io/fig/heavy/component-metadata.test.ts b/tests/engine/io/fig/heavy/component-metadata.test.ts index 238ee8f4a..0ff2a7774 100644 --- a/tests/engine/io/fig/heavy/component-metadata.test.ts +++ b/tests/engine/io/fig/heavy/component-metadata.test.ts @@ -13,7 +13,7 @@ function importFixture(name: string) { return importNodeChanges(nodeChanges, blobs, new Map(images)) } -setDefaultTimeout(20_000) +setDefaultTimeout(30_000) heavy('fig component metadata import', () => { test('preserves remote library component identity fields', () => { @@ -58,5 +58,5 @@ heavy('fig component metadata import', () => { expect(Object.keys(variant.componentPropertyValues).some((key) => key.includes(':'))).toBe( false ) - }, 10_000) + }) }) diff --git a/tests/engine/io/fig/import/group-reclassify.test.ts b/tests/engine/io/fig/import/group-reclassify.test.ts index 584d58696..1463a873b 100644 --- a/tests/engine/io/fig/import/group-reclassify.test.ts +++ b/tests/engine/io/fig/import/group-reclassify.test.ts @@ -62,5 +62,5 @@ describe('Figma group reclassification on import', () => { // gold-preview.fig contains real Figma groups (FRAME + resizeToFit) that must // import as GROUP, not FRAME. expect(groups.length).toBeGreaterThan(0) - }) + }, 30_000) }) diff --git a/tests/engine/io/fig/roundtrip/exhaustive.test.ts b/tests/engine/io/fig/roundtrip/exhaustive.test.ts index 7ff894d22..5bf0d8efe 100644 --- a/tests/engine/io/fig/roundtrip/exhaustive.test.ts +++ b/tests/engine/io/fig/roundtrip/exhaustive.test.ts @@ -64,8 +64,8 @@ const SPECS: FixtureSpec[] = [ thumbnailHeight: 239, imageCount: 3, figKiwiVersion: 101, - g1ExportSize: 594770, - g2ExportSize: 594770 + g1ExportSize: 496909, + g2ExportSize: 496909 } ] diff --git a/tests/engine/io/fig/roundtrip/glyph-blob.test.ts b/tests/engine/io/fig/roundtrip/glyph-blob.test.ts index a26cffac1..a88404449 100644 --- a/tests/engine/io/fig/roundtrip/glyph-blob.test.ts +++ b/tests/engine/io/fig/roundtrip/glyph-blob.test.ts @@ -46,7 +46,7 @@ function loadInterFonts() { describe('roundtrip: text glyph blobs', () => { beforeAll(async () => { await initCodec() - }) + }, 30_000) test('preserves imported Figma glyph blobs for fallback rendering', async () => { const fixtureBytes = new Uint8Array(readFileSync(resolve(FIXTURES, 'gold-preview.fig'))) @@ -64,7 +64,7 @@ describe('roundtrip: text glyph blobs', () => { expect(input.glyphsWithBlob).toBeGreaterThan(0) expect(output.glyphsWithBlob).toBe(input.glyphsWithBlob) expect(output.uniqueGlyphBlobs).toBeLessThanOrEqual(input.uniqueGlyphBlobs) - }) + }, 30_000) 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..1dba52aca 100644 --- a/tests/engine/layout/auto-layout/text/measurement.test.ts +++ b/tests/engine/layout/auto-layout/text/measurement.test.ts @@ -120,7 +120,7 @@ describe('text measurement', () => { 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) - }) + }, 30_000) test('imported nested instance layout keeps hidden sibling offsets stable', async () => { const graph = await loadFixtureGraph('gold-preview.fig') diff --git a/tests/engine/render/canvas/cache.test.ts b/tests/engine/render/canvas/cache.test.ts index 4622fde9d..07a52bc73 100644 --- a/tests/engine/render/canvas/cache.test.ts +++ b/tests/engine/render/canvas/cache.test.ts @@ -32,7 +32,7 @@ beforeAll(async () => { : undefined if (!input) throw new Error('gold-preview Input fixture node not found') movingNodeId = input.id -}) +}, 60_000) function renderPreview(renderer: SkiaRenderer, sceneVersion: number): Uint8Array { renderer.render(graph, new Set(), {}, sceneVersion) diff --git a/tests/engine/scene-graph/clone-binding-corruption.test.ts b/tests/engine/scene-graph/clone/binding-corruption.test.ts similarity index 100% rename from tests/engine/scene-graph/clone-binding-corruption.test.ts rename to tests/engine/scene-graph/clone/binding-corruption.test.ts diff --git a/tests/engine/scene-graph/clone-node-props.test.ts b/tests/engine/scene-graph/clone/node-props.test.ts similarity index 100% rename from tests/engine/scene-graph/clone-node-props.test.ts rename to tests/engine/scene-graph/clone/node-props.test.ts diff --git a/tests/engine/scene-graph/clone/tree.test.ts b/tests/engine/scene-graph/clone/tree.test.ts new file mode 100644 index 000000000..2df9a6b36 --- /dev/null +++ b/tests/engine/scene-graph/clone/tree.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from 'bun:test' + +import { SceneGraph } from '@open-pencil/core' + +describe('SceneGraph.cloneTree', () => { + test('clone clears source.id from the clone', () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const rect = graph.createNode('RECTANGLE', page.id, { + name: 'Original', + width: 100, + height: 50 + }) + // Simulate an imported node with a Figma source.id + graph.updateNode(rect.id, { + source: { ...rect.source, id: '1:42', orderKey: '!', format: 'fig' } + }) + const original = graph.getNode(rect.id) + expect(original).toBeDefined() + expect(original.source.id).toBe('1:42') + + const clone = graph.cloneTree(rect.id, page.id) + expect(clone).not.toBeNull() + // Clone must NOT carry the original's Figma GUID + expect(clone.source.id).toBeNull() + expect(clone.source.orderKey).toBeNull() + // But format should be preserved + expect(clone.source.format).toBe('fig') + }) + + test('clone of clone does not leak source.id', () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const rect = graph.createNode('RECTANGLE', page.id, { + name: 'Original', + width: 100, + height: 50 + }) + graph.updateNode(rect.id, { + source: { ...rect.source, id: '1:99', format: 'fig' } + }) + + const clone1 = graph.cloneTree(rect.id, page.id) + expect(clone1).not.toBeNull() + const clone2 = graph.cloneTree(clone1.id, page.id) + expect(clone2).not.toBeNull() + expect(clone2.source.id).toBeNull() + }) + + test('clone preserves visual properties', () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const rect = graph.createNode('RECTANGLE', page.id, { + name: 'Original', + width: 100, + height: 50 + }) + graph.updateNode(rect.id, { + source: { ...rect.source, id: '1:42', format: 'fig' }, + fills: [ + { + type: 'SOLID', + color: { r: 1, g: 0, b: 0, a: 1 }, + visible: true, + blendMode: 'NORMAL' as const + } + ] + }) + + const clone = graph.cloneTree(rect.id, page.id) + expect(clone).not.toBeNull() + expect(clone.name).toBe('Original') + expect(clone.width).toBe(100) + expect(clone.height).toBe(50) + expect(clone.fills).toEqual(rect.fills) + }) + + test('clone recursively clears source.id on children', () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const frame = graph.createNode('FRAME', page.id, { + name: 'Frame', + width: 200, + height: 200 + }) + graph.updateNode(frame.id, { + source: { ...frame.source, id: '2:10', format: 'fig' } + }) + const child = graph.createNode('RECTANGLE', frame.id, { + name: 'Child', + width: 50, + height: 50 + }) + graph.updateNode(child.id, { + source: { ...child.source, id: '2:11', format: 'fig' } + }) + + const clone = graph.cloneTree(frame.id, page.id) + expect(clone).not.toBeNull() + expect(clone.source.id).toBeNull() + const clonedChild = graph.getChildren(clone.id)[0] + expect(clonedChild.source.id).toBeNull() + }) + test('clone deep-copies source.fig so mutations do not affect original', () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const rect = graph.createNode('RECTANGLE', page.id, { + name: 'Original', + width: 100, + height: 50 + }) + graph.updateNode(rect.id, { + source: { + ...rect.source, + id: '1:42', + orderKey: '!', + format: 'fig', + fig: { + rawSize: { x: 100, y: 50 }, + rawTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, + rawNodeFields: { visible: true, opacity: 1 }, + layout: null, + symbolOverrides: [], + componentPropAssignments: [], + derivedSymbolData: [], + derivedSymbolDataLayoutVersion: null, + uniformScaleFactor: null + } + } + }) + + const original = graph.getNode(rect.id) + expect(original.source.fig.rawNodeFields).toEqual({ visible: true, opacity: 1 }) + + const clone = graph.cloneTree(rect.id, page.id) + expect(clone).not.toBeNull() + + // Mutate the clone's source.fig (simulating what clearEditedSourceMetadata does) + clone.source.fig.rawNodeFields = {} + clone.source.fig.rawSize = null + + // Original must be unaffected + expect(original.source.fig.rawNodeFields).toEqual({ visible: true, opacity: 1 }) + expect(original.source.fig.rawSize).toEqual({ x: 100, y: 50 }) + }) +}) diff --git a/tests/helpers/fig-fixtures.ts b/tests/helpers/fig-fixtures.ts index 806abac0c..df616d243 100644 --- a/tests/helpers/fig-fixtures.ts +++ b/tests/helpers/fig-fixtures.ts @@ -24,6 +24,7 @@ export const VALID_NODE_TYPES = new Set([ 'COMPONENT', 'COMPONENT_SET', 'INSTANCE', + 'BOOLEAN_OPERATION', 'CONNECTOR', 'SHAPE_WITH_TEXT' ])