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:
Joseph Cumines 2026-06-24 01:05:23 +10:00 committed by GitHub
parent 4bc1698e93
commit dc9638ac3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 485 additions and 68 deletions

View file

@ -295,7 +295,6 @@ export {
FIG_KIWI_DEFAULT_VERSION,
buildFigKiwi,
parseFigKiwiChunks,
decompressFigKiwiData,
decompressFigKiwiDataAsync,
buildFontDigestMap,
sceneNodeToKiwi,

View file

@ -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
)
)
}

View file

@ -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
}

View file

@ -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)

View file

@ -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,

View file

@ -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 }

View file

@ -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 }

View file

@ -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)
})

View 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)
})
})

View 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()
})
})

View file

@ -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[]

View file

@ -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)
})
})

View file

@ -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)
})

View file

@ -64,8 +64,8 @@ const SPECS: FixtureSpec[] = [
thumbnailHeight: 239,
imageCount: 3,
figKiwiVersion: 101,
g1ExportSize: 594770,
g2ExportSize: 594770
g1ExportSize: 496909,
g2ExportSize: 496909
}
]

View file

@ -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()

View file

@ -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')

View file

@ -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)

View 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 })
})
})

View file

@ -24,6 +24,7 @@ export const VALID_NODE_TYPES = new Set<string>([
'COMPONENT',
'COMPONENT_SET',
'INSTANCE',
'BOOLEAN_OPERATION',
'CONNECTOR',
'SHAPE_WITH_TEXT'
])