import { describe, expect, setDefaultTimeout, test } from 'bun:test' import { exportFigFile, FigmaAPI, initCodec, parseFigFile, SceneGraph, type Color } from '@open-pencil/core' import { expectDefined } from '#tests/helpers/assert' import { parseFixture } from '#tests/helpers/fig-fixtures' import { runsHeavyTests } from '#tests/helpers/test-utils' setDefaultTimeout(60_000) describe('variable roundtrip', () => { test('variables and collections survive export → re-import', async () => { await initCodec() const graph = new SceneGraph() const col = graph.createCollection('Design Tokens') graph.createVariable('color/primary', 'COLOR', col.id, { r: 0.23, g: 0.51, b: 0.96, a: 1 }) graph.createVariable('spacing/base', 'FLOAT', col.id, 8) graph.createVariable('visible', 'BOOLEAN', col.id, true) graph.createVariable('label', 'STRING', col.id, 'Hello') const exported = await exportFigFile(graph) const reimported = await parseFigFile(exported.buffer as ArrayBuffer) expect(reimported.variables.size).toBe(4) expect(reimported.variableCollections.size).toBe(1) const reimportedCol = [...reimported.variableCollections.values()][0] expect(reimportedCol.name).toBe('Design Tokens') expect(reimportedCol.variableIds).toHaveLength(4) const vars = [...reimported.variables.values()] const colorVar = vars.find((v) => v.name === 'color/primary') expect(colorVar).toBeDefined() expect(expectDefined(colorVar, 'colorVar').type).toBe('COLOR') const colorVal = Object.values(colorVar.valuesByMode)[0] as Color expect(colorVal.r).toBeCloseTo(0.23, 1) const floatVar = vars.find((v) => v.name === 'spacing/base') expect(floatVar).toBeDefined() expect(expectDefined(floatVar, 'floatVar').type).toBe('FLOAT') expect(Object.values(floatVar.valuesByMode)[0]).toBe(8) const boolVar = vars.find((v) => v.name === 'visible') expect(boolVar).toBeDefined() expect(expectDefined(boolVar, 'boolVar').type).toBe('BOOLEAN') expect(Object.values(boolVar.valuesByMode)[0]).toBe(true) const strVar = vars.find((v) => v.name === 'label') expect(strVar).toBeDefined() expect(expectDefined(strVar, 'strVar').type).toBe('STRING') expect(Object.values(strVar.valuesByMode)[0]).toBe('Hello') }) test('variable bindings survive export → re-import', async () => { await initCodec() const graph = new SceneGraph() const col = graph.createCollection('Tokens') const floatVar = graph.createVariable('radius', 'FLOAT', col.id, 12) const fillVar = graph.createVariable('surface', 'COLOR', col.id, { r: 1, g: 1, b: 1, a: 1 }) const strokeVar = graph.createVariable('border', 'COLOR', col.id, { r: 0.1, g: 0.2, b: 0.3, a: 1 }) const page = graph.getPages()[0] const rect = graph.createNode('RECTANGLE', page.id, { name: 'Bound Rect', width: 100, height: 100, cornerRadius: 12, fills: [ { type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true, blendMode: 'NORMAL' } ], strokes: [ { color: { r: 0.1, g: 0.2, b: 0.3, a: 1 }, weight: 1, opacity: 1, visible: true, align: 'INSIDE', cap: 'NONE', join: 'MITER', dashPattern: [] } ] }) graph.bindVariable(rect.id, 'cornerRadius', floatVar.id) graph.bindVariable(rect.id, 'fills/0/color', fillVar.id) graph.bindVariable(rect.id, 'strokes/0/color', strokeVar.id) const exported = await exportFigFile(graph) const reimported = await parseFigFile(exported.buffer as ArrayBuffer) const reimportedRect = [...reimported.getAllNodes()].find((n) => n.name === 'Bound Rect') expect(reimportedRect).toBeDefined() expect(Object.keys(expectDefined(reimportedRect, 'reimportedRect').boundVariables)).toContain( 'cornerRadius' ) expect(Object.keys(reimportedRect.boundVariables)).toContain('fills/0/color') expect(Object.keys(reimportedRect.boundVariables)).toContain('strokes/0/color') }) test.if(runsHeavyTests)( 'material3.fig variables survive round-trip', async () => { const original = await parseFixture('material3.fig') const exported = await exportFigFile(original) const reimported = await parseFigFile(exported.buffer as ArrayBuffer) expect(reimported.variables.size).toBe(original.variables.size) expect(reimported.variableCollections.size).toBeGreaterThanOrEqual( [...original.variableCollections.values()].filter((c) => c.variableIds.length > 0).length ) }, 120_000 ) test('pluginID casing is consistent across full codec pipeline', async () => { await initCodec() // Create a graph with multiple nodes having pluginData entries const graph = new SceneGraph() const api = new FigmaAPI(graph) const frame = api.createFrame() frame.name = 'PluginID Test' frame.setPluginData('key1', 'value1') frame.setPluginData('key2', 'value2') const rect = api.createRectangle() rect.name = 'Plugin Rect' rect.setPluginData('testKey', 'testValue') // Round-trip through the codec const exported = await exportFigFile(graph) const reimported = await parseFigFile(exported.buffer as ArrayBuffer) // Find all nodes with pluginData let nodesWithPluginData = 0 let totalEntries = 0 for (const node of reimported.getAllNodes()) { if (node.pluginData && node.pluginData.length > 0) { nodesWithPluginData++ for (const entry of node.pluginData) { totalEntries++ // Every entry MUST use pluginId (lowercase d, matching SceneGraph type) expect(entry).toHaveProperty('pluginId') expect(typeof entry.pluginId).toBe('string') expect(entry.pluginId.length).toBeGreaterThan(0) } } // Also check pluginRelaunchData entries if (node.pluginRelaunchData && node.pluginRelaunchData.length > 0) { for (const entry of node.pluginRelaunchData) { expect(entry).toHaveProperty('pluginId') expect(typeof entry.pluginId).toBe('string') } } } // Should have at least 2 nodes with plugin data (frame + rect) expect(nodesWithPluginData).toBeGreaterThanOrEqual(2) expect(totalEntries).toBeGreaterThanOrEqual(3) }) })