2026-05-05 23:53:19 +00:00
|
|
|
import { describe, expect, setDefaultTimeout, test } from 'bun:test'
|
2026-05-05 22:49:25 +00:00
|
|
|
|
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>
2026-06-23 15:05:23 +00:00
|
|
|
import {
|
|
|
|
|
decompressFigKiwiDataAsync,
|
|
|
|
|
exportFigFile,
|
|
|
|
|
initCodec,
|
|
|
|
|
parseFigFile,
|
|
|
|
|
SceneGraph
|
|
|
|
|
} from '@open-pencil/core'
|
2026-06-06 19:24:31 +00:00
|
|
|
import type { JsonObject } from '@open-pencil/scene-graph/primitives'
|
2026-05-05 23:22:08 +00:00
|
|
|
|
2026-05-05 23:05:02 +00:00
|
|
|
import { expectDefined } from '#tests/helpers/assert'
|
2026-05-05 23:53:19 +00:00
|
|
|
import { parseFixture } from '#tests/helpers/fig-fixtures'
|
2026-05-21 00:54:10 +00:00
|
|
|
import { runsHeavyTests } from '#tests/helpers/test-utils'
|
2026-05-05 22:49:25 +00:00
|
|
|
|
|
|
|
|
setDefaultTimeout(60_000)
|
|
|
|
|
|
|
|
|
|
describe('text node export', () => {
|
|
|
|
|
test('text nodes have derivedTextData and textUserLayoutVersion', async () => {
|
|
|
|
|
await initCodec()
|
|
|
|
|
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = graph.getPages()[0]
|
|
|
|
|
graph.createNode('TEXT', page.id, {
|
|
|
|
|
name: 'Greeting',
|
|
|
|
|
text: 'Hello World',
|
|
|
|
|
width: 120,
|
|
|
|
|
height: 24,
|
|
|
|
|
fontFamily: 'Inter',
|
|
|
|
|
fontWeight: 400,
|
|
|
|
|
fontSize: 16
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const exported = await exportFigFile(graph)
|
|
|
|
|
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
|
|
|
|
|
|
|
|
|
|
const textNode = [...reimported.getAllNodes()].find((n) => n.name === 'Greeting')
|
|
|
|
|
expect(textNode).toBeDefined()
|
|
|
|
|
expect(expectDefined(textNode, 'textNode').type).toBe('TEXT')
|
|
|
|
|
expect(textNode.text).toBe('Hello World')
|
|
|
|
|
expect(textNode.fontFamily).toBe('Inter')
|
|
|
|
|
expect(textNode.fontSize).toBe(16)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('text node has lines in textData', async () => {
|
|
|
|
|
await initCodec()
|
|
|
|
|
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = graph.getPages()[0]
|
|
|
|
|
graph.createNode('TEXT', page.id, {
|
|
|
|
|
name: 'Multiline',
|
|
|
|
|
text: 'Line 1\nLine 2\nLine 3',
|
|
|
|
|
width: 100,
|
|
|
|
|
height: 60,
|
|
|
|
|
fontFamily: 'Inter',
|
|
|
|
|
fontWeight: 400,
|
|
|
|
|
fontSize: 14
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const exported = await exportFigFile(graph)
|
|
|
|
|
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
|
|
|
|
|
|
|
|
|
|
const textNode = [...reimported.getAllNodes()].find((n) => n.name === 'Multiline')
|
|
|
|
|
expect(textNode).toBeDefined()
|
|
|
|
|
expect(expectDefined(textNode, 'textNode').text).toBe('Line 1\nLine 2\nLine 3')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('derivedTextData fields present in raw binary', async () => {
|
|
|
|
|
await initCodec()
|
|
|
|
|
|
|
|
|
|
const { unzipSync, inflateSync } = await import('fflate')
|
2026-05-24 09:15:29 +00:00
|
|
|
const { decodeBinarySchema, compileSchema, ByteBuffer } =
|
2026-06-06 08:48:33 +00:00
|
|
|
await import('@open-pencil/kiwi/schema-runtime')
|
2026-05-05 22:49:25 +00:00
|
|
|
const { parseFigKiwiChunks } = await import('@open-pencil/core')
|
|
|
|
|
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = graph.getPages()[0]
|
|
|
|
|
graph.createNode('TEXT', page.id, {
|
|
|
|
|
name: 'Raw Test',
|
|
|
|
|
text: 'Check binary',
|
|
|
|
|
width: 80,
|
|
|
|
|
height: 18,
|
|
|
|
|
fontFamily: 'Roboto',
|
|
|
|
|
fontWeight: 700,
|
|
|
|
|
fontSize: 12
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const exported = await exportFigFile(graph)
|
|
|
|
|
const zip = unzipSync(new Uint8Array(exported))
|
|
|
|
|
const canvasData = zip['canvas.fig'] ?? zip['canvas']
|
|
|
|
|
expect(canvasData).toBeDefined()
|
|
|
|
|
|
|
|
|
|
const chunks = parseFigKiwiChunks(canvasData)
|
|
|
|
|
expect(chunks).not.toBeNull()
|
|
|
|
|
expect(chunks?.length).toBeGreaterThanOrEqual(2)
|
|
|
|
|
|
|
|
|
|
const schemaBytes = inflateSync(chunks?.[0] ?? new Uint8Array())
|
|
|
|
|
const schema = decodeBinarySchema(new ByteBuffer(schemaBytes))
|
|
|
|
|
const compiled = compileSchema(schema) as {
|
|
|
|
|
decodeMessage(data: Uint8Array): Record<string, unknown>
|
|
|
|
|
}
|
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>
2026-06-23 15:05:23 +00:00
|
|
|
const dataRaw = await decompressFigKiwiDataAsync(chunks?.[1] ?? new Uint8Array())
|
2026-05-05 22:49:25 +00:00
|
|
|
const message = compiled.decodeMessage(dataRaw)
|
|
|
|
|
|
2026-05-24 21:26:02 +00:00
|
|
|
const nodeChanges = message.nodeChanges as JsonObject[]
|
2026-05-05 22:49:25 +00:00
|
|
|
const textNc = nodeChanges.find((nc) => nc.type === 'TEXT')
|
|
|
|
|
expect(textNc).toBeDefined()
|
|
|
|
|
|
|
|
|
|
expect(textNc.textData.characters).toBe('Check binary')
|
|
|
|
|
expect(textNc.textData.lines).toBeDefined()
|
|
|
|
|
expect(textNc.textData.lines.length).toBeGreaterThanOrEqual(1)
|
|
|
|
|
expect(textNc.textData.lines[0].lineType).toBe('PLAIN')
|
|
|
|
|
|
|
|
|
|
expect(textNc.textUserLayoutVersion).toBe(4)
|
|
|
|
|
|
|
|
|
|
expect(textNc.derivedTextData).toBeDefined()
|
|
|
|
|
expect(textNc.derivedTextData.layoutSize).toBeDefined()
|
|
|
|
|
expect(textNc.derivedTextData.layoutSize.x).toBe(80)
|
|
|
|
|
expect(textNc.derivedTextData.layoutSize.y).toBe(18)
|
|
|
|
|
|
|
|
|
|
expect(textNc.derivedTextData.fontMetaData).toBeDefined()
|
|
|
|
|
expect(textNc.derivedTextData.fontMetaData.length).toBe(1)
|
2026-05-16 07:52:51 +00:00
|
|
|
expect(textNc.fontName.style).toBe('Bold')
|
2026-05-05 22:49:25 +00:00
|
|
|
expect(textNc.derivedTextData.fontMetaData[0].key.family).toBe('Roboto')
|
2026-05-16 07:52:51 +00:00
|
|
|
expect(textNc.derivedTextData.fontMetaData[0].key.style).toBe('Bold')
|
2026-05-05 22:49:25 +00:00
|
|
|
expect(textNc.derivedTextData.fontMetaData[0].fontWeight).toBe(700)
|
|
|
|
|
expect(textNc.derivedTextData.fontMetaData[0].fontStyle).toBe('NORMAL')
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-16 07:52:51 +00:00
|
|
|
test('uses Figma font style names for weighted text', async () => {
|
|
|
|
|
await initCodec()
|
|
|
|
|
|
|
|
|
|
const { unzipSync, inflateSync } = await import('fflate')
|
2026-05-24 09:15:29 +00:00
|
|
|
const { decodeBinarySchema, compileSchema, ByteBuffer } =
|
2026-06-06 08:48:33 +00:00
|
|
|
await import('@open-pencil/kiwi/schema-runtime')
|
2026-05-16 07:52:51 +00:00
|
|
|
const { parseFigKiwiChunks } = await import('@open-pencil/core')
|
|
|
|
|
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = graph.getPages()[0]
|
|
|
|
|
graph.createNode('TEXT', page.id, {
|
|
|
|
|
name: 'Weighted',
|
|
|
|
|
text: 'Semi bold',
|
|
|
|
|
width: 120,
|
|
|
|
|
height: 24,
|
|
|
|
|
fontFamily: 'Inter',
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
fontSize: 16
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const exported = await exportFigFile(graph)
|
|
|
|
|
const zip = unzipSync(new Uint8Array(exported))
|
|
|
|
|
const chunks = parseFigKiwiChunks(zip['canvas.fig'] ?? zip['canvas'])
|
|
|
|
|
const schemaBytes = inflateSync(chunks?.[0] ?? new Uint8Array())
|
|
|
|
|
const schema = decodeBinarySchema(new ByteBuffer(schemaBytes))
|
|
|
|
|
const compiled = compileSchema(schema) as {
|
|
|
|
|
decodeMessage(data: Uint8Array): Record<string, unknown>
|
|
|
|
|
}
|
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>
2026-06-23 15:05:23 +00:00
|
|
|
const message = compiled.decodeMessage(
|
|
|
|
|
await decompressFigKiwiDataAsync(chunks?.[1] ?? new Uint8Array())
|
|
|
|
|
)
|
2026-05-24 21:26:02 +00:00
|
|
|
const nodeChanges = message.nodeChanges as JsonObject[]
|
2026-05-24 09:15:29 +00:00
|
|
|
const textNc = expectDefined(
|
|
|
|
|
nodeChanges.find((nc) => nc.type === 'TEXT'),
|
|
|
|
|
'text node change'
|
|
|
|
|
)
|
2026-05-24 21:26:02 +00:00
|
|
|
const derivedTextData = textNc.derivedTextData as JsonObject
|
2026-05-16 07:52:51 +00:00
|
|
|
const fontMetaData = expectDefined(
|
2026-05-24 21:26:02 +00:00
|
|
|
derivedTextData.fontMetaData as JsonObject[] | undefined,
|
2026-05-16 07:52:51 +00:00
|
|
|
'font metadata'
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-24 21:26:02 +00:00
|
|
|
expect((textNc.fontName as JsonObject).style).toBe('Semi Bold')
|
|
|
|
|
expect((fontMetaData[0].key as JsonObject).style).toBe('Semi Bold')
|
2026-05-16 07:52:51 +00:00
|
|
|
})
|
|
|
|
|
|
2026-05-16 08:46:14 +00:00
|
|
|
test('auto-layout text children export height auto-resize for Figma rendering', async () => {
|
|
|
|
|
await initCodec()
|
|
|
|
|
|
|
|
|
|
const { unzipSync, inflateSync } = await import('fflate')
|
2026-05-24 09:15:29 +00:00
|
|
|
const { decodeBinarySchema, compileSchema, ByteBuffer } =
|
2026-06-06 08:48:33 +00:00
|
|
|
await import('@open-pencil/kiwi/schema-runtime')
|
2026-05-16 08:46:14 +00:00
|
|
|
const { parseFigKiwiChunks } = await import('@open-pencil/core')
|
|
|
|
|
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = graph.getPages()[0]
|
|
|
|
|
const frame = graph.createNode('FRAME', page.id, {
|
|
|
|
|
name: 'Card',
|
|
|
|
|
layoutMode: 'VERTICAL',
|
|
|
|
|
width: 280,
|
|
|
|
|
height: 160
|
|
|
|
|
})
|
|
|
|
|
graph.createNode('TEXT', frame.id, {
|
|
|
|
|
name: 'Body',
|
|
|
|
|
text: 'Track your key metrics and performance indicators in real time.',
|
|
|
|
|
width: 240,
|
|
|
|
|
height: 36,
|
|
|
|
|
fontFamily: 'Inter',
|
|
|
|
|
fontSize: 13,
|
|
|
|
|
fontWeight: 400,
|
|
|
|
|
textAutoResize: 'NONE'
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const exported = await exportFigFile(graph)
|
|
|
|
|
const zip = unzipSync(new Uint8Array(exported))
|
|
|
|
|
const chunks = parseFigKiwiChunks(zip['canvas.fig'] ?? zip['canvas'])
|
|
|
|
|
const schemaBytes = inflateSync(chunks?.[0] ?? new Uint8Array())
|
|
|
|
|
const schema = decodeBinarySchema(new ByteBuffer(schemaBytes))
|
|
|
|
|
const compiled = compileSchema(schema) as {
|
|
|
|
|
decodeMessage(data: Uint8Array): Record<string, unknown>
|
|
|
|
|
}
|
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>
2026-06-23 15:05:23 +00:00
|
|
|
const message = compiled.decodeMessage(
|
|
|
|
|
await decompressFigKiwiDataAsync(chunks?.[1] ?? new Uint8Array())
|
|
|
|
|
)
|
2026-05-24 21:26:02 +00:00
|
|
|
const nodeChanges = message.nodeChanges as JsonObject[]
|
2026-05-24 09:15:29 +00:00
|
|
|
const textNc = expectDefined(
|
|
|
|
|
nodeChanges.find((nc) => nc.type === 'TEXT'),
|
|
|
|
|
'text node change'
|
|
|
|
|
)
|
2026-05-16 08:46:14 +00:00
|
|
|
|
|
|
|
|
expect(textNc.textAutoResize).toBe('HEIGHT')
|
|
|
|
|
expect(textNc.lineHeight).toBeUndefined()
|
|
|
|
|
expect(textNc.stackChildAlignSelf).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-05 22:49:25 +00:00
|
|
|
test('style runs produce multiple fontMetaData entries', async () => {
|
|
|
|
|
await initCodec()
|
|
|
|
|
|
|
|
|
|
const { unzipSync, inflateSync } = await import('fflate')
|
2026-05-24 09:15:29 +00:00
|
|
|
const { decodeBinarySchema, compileSchema, ByteBuffer } =
|
2026-06-06 08:48:33 +00:00
|
|
|
await import('@open-pencil/kiwi/schema-runtime')
|
2026-05-05 22:49:25 +00:00
|
|
|
const { parseFigKiwiChunks } = await import('@open-pencil/core')
|
|
|
|
|
|
|
|
|
|
const graph = new SceneGraph()
|
|
|
|
|
const page = graph.getPages()[0]
|
|
|
|
|
graph.createNode('TEXT', page.id, {
|
|
|
|
|
name: 'Styled',
|
|
|
|
|
text: 'Bold and Normal',
|
|
|
|
|
width: 150,
|
|
|
|
|
height: 20,
|
|
|
|
|
fontFamily: 'Inter',
|
|
|
|
|
fontWeight: 400,
|
|
|
|
|
fontSize: 16,
|
|
|
|
|
styleRuns: [
|
|
|
|
|
{ start: 0, length: 4, style: { fontWeight: 700 } },
|
|
|
|
|
{ start: 5, length: 10, style: {} }
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const exported = await exportFigFile(graph)
|
|
|
|
|
const zip = unzipSync(new Uint8Array(exported))
|
|
|
|
|
const canvasData = zip['canvas.fig'] ?? zip['canvas']
|
|
|
|
|
const chunks = parseFigKiwiChunks(canvasData)
|
|
|
|
|
expect(chunks).toBeDefined()
|
|
|
|
|
|
|
|
|
|
const schemaBytes = inflateSync(chunks?.[0] ?? new Uint8Array())
|
|
|
|
|
const schema = decodeBinarySchema(new ByteBuffer(schemaBytes))
|
|
|
|
|
const compiled = compileSchema(schema) as {
|
|
|
|
|
decodeMessage(data: Uint8Array): Record<string, unknown>
|
|
|
|
|
}
|
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>
2026-06-23 15:05:23 +00:00
|
|
|
const dataRaw = await decompressFigKiwiDataAsync(chunks?.[1] ?? new Uint8Array())
|
2026-05-05 22:49:25 +00:00
|
|
|
const message = compiled.decodeMessage(dataRaw)
|
|
|
|
|
|
2026-05-24 21:26:02 +00:00
|
|
|
const nodeChanges = message.nodeChanges as JsonObject[]
|
2026-05-05 22:49:25 +00:00
|
|
|
const textNc = nodeChanges.find((nc) => nc.type === 'TEXT')
|
|
|
|
|
|
2026-05-24 21:26:02 +00:00
|
|
|
const derivedTextData = textNc?.derivedTextData as JsonObject | undefined
|
|
|
|
|
const fontMetaData = derivedTextData?.fontMetaData as JsonObject[] | undefined
|
2026-05-05 22:49:25 +00:00
|
|
|
expect(fontMetaData?.length).toBe(2)
|
|
|
|
|
|
2026-05-24 21:26:02 +00:00
|
|
|
const families = (fontMetaData ?? []).map((m) => (m.key as JsonObject)?.style as string)
|
2026-05-05 22:49:25 +00:00
|
|
|
expect(families).toContain('Bold')
|
|
|
|
|
expect(families).toContain('Regular')
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-24 09:15:29 +00:00
|
|
|
test.if(runsHeavyTests)(
|
|
|
|
|
'material3.fig text nodes have derivedTextData after round-trip',
|
|
|
|
|
async () => {
|
|
|
|
|
const original = await parseFixture('material3.fig')
|
2026-05-05 22:49:25 +00:00
|
|
|
|
2026-05-24 09:15:29 +00:00
|
|
|
const textNodes = [...original.getAllNodes()].filter((n) => n.type === 'TEXT')
|
|
|
|
|
expect(textNodes.length).toBeGreaterThan(0)
|
2026-05-05 22:49:25 +00:00
|
|
|
|
2026-05-24 09:15:29 +00:00
|
|
|
const exported = await exportFigFile(original)
|
|
|
|
|
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
|
2026-05-05 22:49:25 +00:00
|
|
|
|
2026-05-24 09:15:29 +00:00
|
|
|
const reimportedText = [...reimported.getAllNodes()].filter((n) => n.type === 'TEXT')
|
|
|
|
|
expect(reimportedText.length).toBe(textNodes.length)
|
2026-05-05 22:49:25 +00:00
|
|
|
|
2026-05-24 09:15:29 +00:00
|
|
|
for (const node of reimportedText.slice(0, 10)) {
|
|
|
|
|
expect(node.text.length).toBeGreaterThan(0)
|
|
|
|
|
}
|
2026-05-05 22:49:25 +00:00
|
|
|
}
|
2026-05-24 09:15:29 +00:00
|
|
|
)
|
2026-05-05 22:49:25 +00:00
|
|
|
})
|