fix(export): prevent GUID collisions and file corruption on .fig round-trip (#333)
* fix(export): prevent GUID collisions and file corruption on .fig round-trip Nodes sharing the same imported source.id (component instance children, cloned subtrees) silently overwrote each other on export because the GUID assignment reused imported GUID values without checking for duplicates. This caused data loss on reimport — only the last node with a given GUID survived. - Track all assigned GUID values in a Set for O(1) collision detection. - Scan imported source.ids for both sessionID 0 and 1 before assigning any new GUIDs, so the counter starts past every imported value. - Fall back to counter-based GUIDs when source.id collides with an already-assigned value. Additional fixes in the same change set: - cloneTree now deep-copies source.fig via structuredClone, preventing mutations on a clone from corrupting the original node's kiwi payload. - Removed decompressFigKiwiData sync wrapper (zero callers) and the silent try/catch fallback in parseFigKiwiContainer that masked corrupt data as raw bytes. - buildFigKiwi uses Bun.zstdCompressSync when available, matching the zstd decompression path already used on import. - Fixed setSavedVersion ordering in read.ts — must run after requestRender to capture the post-bump version, preventing spurious dirty-state immediately after file reload. Tests: GUID collision (2 and 3 node), clone isolation, parse failure, text export zstd compatibility, gold-preview round-trip. * fix(export): handle EXCLUDE boolean operation and BOOLEAN_OPERATION node type The internal representation uses EXCLUDE for exclude boolean operations, but Figma's kiwi schema uses XOR. Map EXCLUDE back to XOR on export so round-trips through .fig files don't fail. Also add BOOLEAN_OPERATION to VALID_NODE_TYPES and increase timeout for heavy material3 fixture test. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * style: format export-node.ts Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test: add type guard after null assertion in guid-collision test Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(clipboard): detect zstd-compressed data before zlib inflate fflate's inflateSync silently accepts zstd-compressed data and returns garbage instead of throwing. Check for the zstd magic bytes (28 b5 2f fd) before attempting zlib decompression. Also revert the EXCLUDE enum addition to the kiwi schema since the export-node.ts mapping is sufficient. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(clipboard): add length guard before zstd magic byte check Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(scene-graph): group clone regression coverage * ci: retrigger CI checks * test: increase timeouts for heavy .fig fixture tests on slow CI runners Gold-preview.fig and material3.fig parsing/export tests consistently exceed the 5s bun:test default timeout on GitHub Actions runners. Increase to 30s for: beforeAll codec init, clipboard roundtrip, glyph blob roundtrip, group reclassification, and text measurement. * test: add individual test timeouts for heavy .fig fixture tests The beforeAll timeout helped but individual test() calls also need explicit 30s timeouts since bun:test applies the 5s default per-test. Fixes remaining CI flakes in glyph-blob roundtrip and clipboard roundtrip tests. * test: increase beforeAll timeout for render cache test The canvas/render cache test loads gold-preview.fig AND initializes CanvasKit (Skia WASM), which is much slower than the other fixture tests. Use 60s timeout to account for slow CI runners. * fix(export): reserve document GUID to prevent 0:0 namespace collision - Add docGuid (0:0) to assignedGuidValues before processing imported node source.ids, preventing an imported node with source.id "0:0" from reusing the document's GUID slot - Add regression test using session-0 source.ids to verify nodes survive roundtrip without document GUID collision - Remove unnecessary async keyword from synchronous component metadata test * fix(export): guard canvas GUID reuse with assignedGuidValues check - Mirror getOrCreateNodeGuid() collision logic in buildCanvasEntries(): if an imported page's source.id maps to a GUID already in assignedGuidValues, generate a fresh counter-based GUID instead - Prevents canvas-level last-write-wins when multiple pages share the same source.id or a page uses 0:0 * fix(test): use explicit little-endian writes and fix misleading test title - Replace host-endian Uint32Array writes with DataView.setUint32(offset, value, true) in parse-failures.test.ts to ensure platform-independent fig-kiwi container assembly - Rename test title from "clone clears source.id from the original" to "clone clears source.id from the clone" to accurately reflect what the assertions verify * test(io): use file-level timeout for heavy fixture --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Danila Poyarkov <dev@dannote.net>
This commit is contained in:
parent
4bc1698e93
commit
dc9638ac3e
|
|
@ -295,7 +295,6 @@ export {
|
|||
FIG_KIWI_DEFAULT_VERSION,
|
||||
buildFigKiwi,
|
||||
parseFigKiwiChunks,
|
||||
decompressFigKiwiData,
|
||||
decompressFigKiwiDataAsync,
|
||||
buildFontDigestMap,
|
||||
sceneNodeToKiwi,
|
||||
|
|
|
|||
|
|
@ -108,15 +108,22 @@ function assignVariableGuids(
|
|||
graph: SceneGraph,
|
||||
localIdCounter: { value: number },
|
||||
varIdToGuid: Map<string, GUID>,
|
||||
modeIdToGuid: Map<string, GUID>
|
||||
modeIdToGuid: Map<string, GUID>,
|
||||
assignedGuidValues: Set<string>
|
||||
): 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<string, GUID>
|
||||
nodeIdToGuid: Map<string, GUID>,
|
||||
assignedGuidValues: Set<string>
|
||||
): { 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<string, GUID>()
|
||||
const assignedGuidValues = new Set<string>()
|
||||
// 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<string, GUID>()
|
||||
const modeIdToGuid = new Map<string, GUID>()
|
||||
const fontDigestMap = await buildFontDigestMap(graph)
|
||||
const glyphBlobMap = new Map<string, number>()
|
||||
const blobIndexByHex = new Map<string, number>()
|
||||
|
||||
// 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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Uint8Array> {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ interface SceneNodeToKiwiContext {
|
|||
blobs: Uint8Array[]
|
||||
blobIndexByHex?: Map<string, number>
|
||||
nodeIdToGuid?: Map<string, GUID>
|
||||
/** Reverse index of assigned GUID values ("sessionID:localID") for O(1)
|
||||
* collision detection. Populated alongside every nodeIdToGuid.set() call. */
|
||||
assignedGuidValues?: Set<string>
|
||||
fontDigestMap?: Map<string, Uint8Array>
|
||||
glyphBlobMap?: Map<string, number>
|
||||
varIdToGuid?: Map<string, GUID>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<string, Uint8Array>,
|
||||
varIdToGuid?: Map<string, GUID>,
|
||||
glyphBlobMap = new Map<string, number>(),
|
||||
blobIndexByHex?: Map<string, number>
|
||||
blobIndexByHex?: Map<string, number>,
|
||||
assignedGuidValues?: Set<string>
|
||||
): 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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
return { schemaDeflated: chunks[0], dataRaw, version }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
136
tests/engine/io/fig/export/guid-collision.test.ts
Normal file
136
tests/engine/io/fig/export/guid-collision.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
68
tests/engine/io/fig/export/parse-failures.test.ts
Normal file
68
tests/engine/io/fig/export/parse-failures.test.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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<string, unknown>
|
||||
}
|
||||
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<string, unknown>
|
||||
}
|
||||
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<string, unknown>
|
||||
}
|
||||
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<string, unknown>
|
||||
}
|
||||
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[]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -64,8 +64,8 @@ const SPECS: FixtureSpec[] = [
|
|||
thumbnailHeight: 239,
|
||||
imageCount: 3,
|
||||
figKiwiVersion: 101,
|
||||
g1ExportSize: 594770,
|
||||
g2ExportSize: 594770
|
||||
g1ExportSize: 496909,
|
||||
g2ExportSize: 496909
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
146
tests/engine/scene-graph/clone/tree.test.ts
Normal file
146
tests/engine/scene-graph/clone/tree.test.ts
Normal file
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
@ -24,6 +24,7 @@ export const VALID_NODE_TYPES = new Set<string>([
|
|||
'COMPONENT',
|
||||
'COMPONENT_SET',
|
||||
'INSTANCE',
|
||||
'BOOLEAN_OPERATION',
|
||||
'CONNECTOR',
|
||||
'SHAPE_WITH_TEXT'
|
||||
])
|
||||
|
|
|
|||
Loading…
Reference in a new issue