openpencil/tests/engine/scene-graph/clone/binding-corruption.test.ts
Joseph Cumines dc9638ac3e
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 18:05:23 +03:00

395 lines
14 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/core'
import { collectSubtrees } from '@open-pencil/core/editor/clipboard/subtree-history'
function pageId(graph: SceneGraph): string {
return graph.getPages()[0].id
}
function setupColorVars(graph: SceneGraph, ...ids: string[]): void {
graph.addCollection({
id: 'col1',
name: 'Colors',
modes: [{ modeId: 'm1', name: 'Light' }],
defaultModeId: 'm1',
variableIds: []
})
for (const id of ids) {
graph.addVariable({
id,
name: `Var ${id}`,
type: 'COLOR',
collectionId: 'col1',
valuesByMode: { m1: { r: 0.5, g: 0.5, b: 0.5, a: 1 } },
description: '',
hiddenFromPublishing: false
})
}
}
// ─── cloneTree deep-copies boundVariables, overrides, styleRuns ─────────────
describe('cloneTree deep-copies boundVariables, overrides, and styleRuns', () => {
test('clone.boundVariables is not the same reference as original', () => {
const graph = new SceneGraph()
setupColorVars(graph, 'v1', 'v2')
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Original',
fills: [{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 }, visible: true, opacity: 1 }]
})
graph.bindVariable(node.id, 'fills/0/color', 'v1')
const original = graph.getNode(node.id)
const clone =
graph.cloneTree(node.id, pageId(graph)) ??
(() => {
throw new Error('clone failed')
})()
expect(clone).not.toBeNull()
expect(clone.boundVariables).not.toBe(original.boundVariables)
})
test('binding on clone does not corrupt original node', () => {
const graph = new SceneGraph()
setupColorVars(graph, 'v1', 'v2')
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Original',
fills: [{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 }, visible: true, opacity: 1 }]
})
graph.bindVariable(node.id, 'fills/0/color', 'v1')
const original = graph.getNode(node.id)
const clone =
graph.cloneTree(node.id, pageId(graph)) ??
(() => {
throw new Error('clone failed')
})()
graph.bindVariable(clone.id, 'fills/0/color', 'v2')
// Original must still have v1
expect(original.boundVariables['fills/0/color']).toBe('v1')
// Clone must have v2
expect(clone.boundVariables['fills/0/color']).toBe('v2')
})
test('clone.overrides is not the same reference as original', () => {
const graph = new SceneGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Original',
overrides: { someKey: 'someValue' }
})
const original = graph.getNode(node.id)
const clone =
graph.cloneTree(node.id, pageId(graph)) ??
(() => {
throw new Error('clone failed')
})()
expect(clone.overrides).not.toBe(original.overrides)
})
test('clone.styleRuns is not the same reference as original', () => {
const graph = new SceneGraph()
const node = graph.createNode('TEXT', pageId(graph), {
name: 'Original',
text: 'Hello',
fontSize: 14,
fontFamily: 'Inter',
styleRuns: [{ start: 0, length: 5, style: { fontWeight: 700 } }]
})
const original = graph.getNode(node.id)
const clone =
graph.cloneTree(node.id, pageId(graph)) ??
(() => {
throw new Error('clone failed')
})()
expect(clone.styleRuns).not.toBe(original.styleRuns)
// Deep check: styleRuns elements must also be independent
if (original.styleRuns.length > 0 && clone.styleRuns.length > 0) {
expect(clone.styleRuns[0]).not.toBe(original.styleRuns[0])
}
})
test('clone.fills is not the same reference as original', () => {
const graph = new SceneGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Original',
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, visible: true, opacity: 1 }]
})
const original = graph.getNode(node.id)
const clone =
graph.cloneTree(node.id, pageId(graph)) ??
(() => {
throw new Error('clone failed')
})()
expect(clone.fills).not.toBe(original.fills)
// Fill elements should also be independent
if (original.fills.length > 0 && clone.fills.length > 0) {
expect(clone.fills[0]).not.toBe(original.fills[0])
}
})
})
// ─── instance child binding independence ──────────────────────────────────
describe('instance child bindings are independent from component', () => {
test('instance child boundVariables is not shared with component child', () => {
const graph = new SceneGraph()
setupColorVars(graph, 'v1', 'v2')
const page = pageId(graph)
// Create component with child
const component = graph.createNode('COMPONENT', page, {
name: 'Button',
width: 100,
height: 40
})
const child = graph.createNode('RECTANGLE', component.id, {
name: 'Bg',
width: 100,
height: 40,
fills: [{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 }, visible: true, opacity: 1 }]
})
graph.bindVariable(child.id, 'fills/0/color', 'v1')
const instance =
graph.createInstance(component.id, page) ??
(() => {
throw new Error('instance failed')
})()
const instanceChild = graph.getChildren(instance.id)[0]
// Instance child's boundVariables must be independent
const componentChild = graph.getNode(child.id)?.boundVariables
expect(componentChild).toBeDefined()
expect(instanceChild.boundVariables).not.toBe(componentChild)
})
test('binding on instance child does not corrupt component child', () => {
const graph = new SceneGraph()
setupColorVars(graph, 'v1', 'v2')
const page = pageId(graph)
const component = graph.createNode('COMPONENT', page, {
name: 'Button',
width: 100,
height: 40
})
const child = graph.createNode('RECTANGLE', component.id, {
name: 'Bg',
width: 100,
height: 40,
fills: [{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 }, visible: true, opacity: 1 }]
})
graph.bindVariable(child.id, 'fills/0/color', 'v1')
const compChild = graph.getNode(child.id)
const instance =
graph.createInstance(component.id, page) ??
(() => {
throw new Error('instance failed')
})()
const instanceChild = graph.getChildren(instance.id)[0]
// Bind different variable on instance child
graph.bindVariable(instanceChild.id, 'fills/0/color', 'v2')
// Component child must still have v1
expect(compChild.boundVariables['fills/0/color']).toBe('v1')
// Instance child must have v2
expect(instanceChild.boundVariables['fills/0/color']).toBe('v2')
})
test('instance child overrides is not shared with component child', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const component = graph.createNode('COMPONENT', page, {
name: 'Button',
width: 100,
height: 40
})
const child = graph.createNode('RECTANGLE', component.id, {
name: 'Bg',
width: 100,
height: 40
})
const compChild = graph.getNode(child.id)
const instance =
graph.createInstance(component.id, page) ??
(() => {
throw new Error('instance failed')
})()
const instanceChild = graph.getChildren(instance.id)[0]
expect(instanceChild.overrides).not.toBe(compChild.overrides)
})
test('instance child source.fig is not shared with component child', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const component = graph.createNode('COMPONENT', page, {
name: 'Button',
width: 100,
height: 40
})
const child = graph.createNode('RECTANGLE', component.id, {
name: 'Bg',
width: 100,
height: 40
})
// Set source.fig on component child
graph.updateNode(child.id, {
source: {
...graph.getNode(child.id).source,
format: 'fig',
fig: {
rawSize: { x: 100, y: 40 },
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 compChild = graph.getNode(child.id)
const instance =
graph.createInstance(component.id, page) ??
(() => {
throw new Error('instance failed')
})()
const instanceChild = graph.getChildren(instance.id)[0]
// source.fig must be deep-copied, not shared
if (compChild.source.fig && instanceChild.source.fig) {
expect(instanceChild.source.fig).not.toBe(compChild.source.fig)
// Mutating instance source.fig must not affect component
instanceChild.source.fig.rawSize = { x: 999, y: 999 }
expect(compChild.source.fig.rawSize).toEqual({ x: 100, y: 40 })
}
})
})
// ─── set_fill/set_stroke clean up stale bindings ─────────────────────────
describe('set_fill/set_stroke clean up stale bindings', () => {
test('set_fill removes fills/N/color bindings that are out of range', () => {
const graph = new SceneGraph()
setupColorVars(graph, 'v1', 'v2')
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Rect',
fills: [
{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 }, visible: true, opacity: 1 },
{ type: 'SOLID', color: { r: 0.2, g: 0.2, b: 0.2, a: 1 }, visible: true, opacity: 1 }
]
})
// Bind both fills
graph.bindVariable(node.id, 'fills/0/color', 'v1')
graph.bindVariable(node.id, 'fills/1/color', 'v2')
const n = graph.getNode(node.id)
expect(n.boundVariables['fills/0/color']).toBe('v1')
expect(n.boundVariables['fills/1/color']).toBe('v2')
// Replace fills with an empty array via updateNode (same path tools use)
graph.updateNode(node.id, { fills: [] })
// fills/1/color binding must be removed (index 1 > length 0)
expect(n.boundVariables['fills/1/color']).toBeUndefined()
})
test('set_fill removes top-level fills binding (dead data)', () => {
const graph = new SceneGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'Rect' })
const n = graph.getNode(node.id)
// Set a top-level 'fills' binding (dead data — renderer never reads this)
n.boundVariables['fills'] = 'v1'
// Replace fills via updateNode (same path tools use)
graph.updateNode(node.id, {
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, visible: true, opacity: 1 }]
})
// Top-level 'fills' binding should be cleaned up
expect(n.boundVariables['fills']).toBeUndefined()
})
})
// ─── collectSubtrees uses structuredClone ──────────────────────────────────
describe('collectSubtrees deep-copies nodes', () => {
test('collectSubtrees does not share boundVariables reference', () => {
const graph = new SceneGraph()
setupColorVars(graph, 'v1')
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Rect',
fills: [{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 }, visible: true, opacity: 1 }]
})
graph.bindVariable(node.id, 'fills/0/color', 'v1')
const original = graph.getNode(node.id)
const collected = collectSubtrees(graph, [node.id])
expect(collected.length).toBe(1)
expect(collected[0].boundVariables).not.toBe(original.boundVariables)
})
})
// ─── undo of binding on clone restores original correctly ──────────────────
describe('undo of binding on clone restores original state', () => {
test('bind on clone, then undo restores original binding state', () => {
const graph = new SceneGraph()
setupColorVars(graph, 'v1', 'v2')
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Original',
fills: [{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 }, visible: true, opacity: 1 }]
})
graph.bindVariable(node.id, 'fills/0/color', 'v1')
const clone = graph.cloneTree(node.id, pageId(graph))
graph.bindVariable(clone.id, 'fills/0/color', 'v2')
expect(graph.getNode(clone.id).boundVariables['fills/0/color']).toBe('v2')
// Original should be unaffected
expect(graph.getNode(node.id).boundVariables['fills/0/color']).toBe('v1')
})
})
// ─── variable deletion and re-clone safety ──────────────────────────────
describe('variable deletion and re-clone safety', () => {
test('deleting a variable removes bindings and re-cloning works safely', () => {
const graph = new SceneGraph()
setupColorVars(graph, 'v1', 'v2')
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Original',
fills: [{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 }, visible: true, opacity: 1 }]
})
graph.bindVariable(node.id, 'fills/0/color', 'v1')
const clone1 = graph.cloneTree(node.id, pageId(graph))
expect(graph.getNode(clone1.id).boundVariables['fills/0/color']).toBe('v1')
// Delete the variable — this cleans up bindings on all nodes
graph.removeVariable('v1')
// Bindings are removed from both original and clone
expect(graph.getNode(node.id).boundVariables['fills/0/color']).toBeUndefined()
expect(graph.getNode(clone1.id).boundVariables['fills/0/color']).toBeUndefined()
// Clone again — should not crash, clone has no stale binding
const clone2 = graph.cloneTree(node.id, pageId(graph))
expect(clone2).toBeDefined()
expect(graph.getNode(clone2.id).boundVariables['fills/0/color']).toBeUndefined()
})
})