openpencil/tests/engine/scene-graph/plugin-data.test.ts

506 lines
17 KiB
TypeScript
Raw Normal View History

2026-03-28 14:07:21 +00:00
import { describe, expect, test } from 'bun:test'
import {
exportFigFile,
FigmaAPI,
importNodeChanges,
initCodec,
parseFigFile,
SceneGraph,
type NodeChange
} from '@open-pencil/core'
import { deduplicateNodeChangePluginData } from '#core/kiwi'
import { expectDefined } from '#tests/helpers/assert'
function doc(): NodeChange {
return {
guid: { sessionID: 0, localID: 0 },
type: 'DOCUMENT',
name: 'Document',
visible: true,
opacity: 1,
phase: 'CREATED',
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
} as NodeChange
}
function canvas(localID = 1): NodeChange {
return {
guid: { sessionID: 0, localID },
parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' },
type: 'CANVAS',
name: 'Page 1',
visible: true,
opacity: 1,
phase: 'CREATED',
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
} as NodeChange
}
function node(
type: string,
localID: number,
parentLocalID: number,
overrides: Partial<NodeChange> = {}
): NodeChange {
return {
guid: { sessionID: 1, localID },
parentIndex: {
guid: { sessionID: 0, localID: parentLocalID },
position: String.fromCharCode(33 + localID)
},
type,
name: `${type}_${localID}`,
visible: true,
opacity: 1,
phase: 'CREATED',
size: { x: 100, y: 100 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
...overrides
} as NodeChange
}
2026-03-28 14:07:21 +00:00
describe('plugin data', () => {
test('figma proxy supports private and shared plugin data methods', () => {
const graph = new SceneGraph()
const api = new FigmaAPI(graph)
const frame = api.createFrame()
frame.setPluginData('okhcl', '{"l":0.7}')
frame.setPluginData('empty', 'temp')
frame.setPluginData('empty', '')
frame.setSharedPluginData('tokens', 'accent', '{"h":240}')
frame.setSharedPluginData('tokens', 'remove-me', 'x')
frame.setSharedPluginData('tokens', 'remove-me', '')
expect(frame.getPluginData('okhcl')).toBe('{"l":0.7}')
expect(frame.getPluginData('missing')).toBe('')
expect(frame.getPluginDataKeys()).toEqual(['okhcl'])
expect(frame.getSharedPluginData('tokens', 'accent')).toBe('{"h":240}')
expect(frame.getSharedPluginData('tokens', 'missing')).toBe('')
expect(frame.getSharedPluginDataKeys('tokens')).toEqual(['accent'])
})
test('roundtrips private and shared plugin data through fig export/import', async () => {
await initCodec()
const graph = new SceneGraph()
const api = new FigmaAPI(graph)
const frame = api.createFrame()
frame.name = 'Plugin data frame'
frame.setPluginData('okhcl', '{"l":0.7,"c":0.12,"h":240}')
frame.setSharedPluginData('tokens', 'accent', '{"ref":"brand/500"}')
const bytes = await exportFigFile(graph)
2026-05-03 18:03:40 +00:00
const parsed = await parseFigFile(
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
)
2026-03-28 14:07:21 +00:00
const parsedFrame = [...parsed.getAllNodes()].find((node) => node.name === 'Plugin data frame')
expect(parsedFrame).toBeDefined()
// Private plugin data roundtrips directly in pluginData array
2026-03-28 14:07:21 +00:00
expect(parsedFrame?.pluginData).toContainEqual({
pluginId: 'open-pencil',
key: 'okhcl',
value: '{"l":0.7,"c":0.12,"h":240}'
})
// Shared plugin data is stored in pluginData with key format "namespace/key".
// Verify via the FigmaAPI proxy which reads through pluginData.
const parsedApi = new FigmaAPI(parsed)
const parsedProxy = parsedApi.getNodeById(parsedFrame?.id ?? '')
expect(parsedProxy).toBeDefined()
expect(parsedProxy?.getSharedPluginData('tokens', 'accent')).toBe('{"ref":"brand/500"}')
expect(parsedProxy?.getSharedPluginDataKeys('tokens')).toEqual(['accent'])
2026-03-28 14:07:21 +00:00
})
test('preserves plugin relaunch data from imported fig files', async () => {
await initCodec()
const bytes = new Uint8Array(await Bun.file('./tests/fixtures/material3.fig').arrayBuffer())
2026-05-03 18:03:40 +00:00
const graph = await parseFigFile(
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
)
const nodeWithRelaunch = [...graph.getAllNodes()].find(
(node) => node.pluginRelaunchData.length > 0
)
2026-03-28 14:07:21 +00:00
expect(nodeWithRelaunch).toBeDefined()
expect(nodeWithRelaunch?.pluginRelaunchData[0]).toMatchObject({
pluginId: expect.any(String),
command: expect.any(String),
message: expect.any(String),
isDeleted: expect.any(Boolean)
})
})
})
/**
* Plugin data deduplication tests.
*
* Deduplication is performed by deduplicateNodeChangePluginData (fig-parse-core.ts)
* before NodeChanges are passed to importNodeChanges. Corrupted or pathological
* .fig files may contain millions of duplicate entries; deduplicateNodeChangePluginData
* prevents them from reaching the scene graph.
*/
describe('plugin data deduplication', () => {
test('deduplicateNodeChangePluginData removes duplicate pluginData entries', () => {
const entries = [
{ pluginID: 'open-pencil', key: 'textDirection', value: 'RTL' },
{ pluginID: 'open-pencil', key: 'textDirection', value: 'RTL' },
{ pluginID: 'open-pencil', key: 'textDirection', value: 'RTL' }
]
const changes: NodeChange[] = [doc(), canvas(), node('FRAME', 10, 1, { pluginData: entries })]
deduplicateNodeChangePluginData(changes)
const frameChange = expectDefined(
changes.find((nc) => nc.type === 'FRAME'),
'frame change'
)
expect(frameChange.pluginData).toHaveLength(1)
expect(expectDefined(frameChange.pluginData?.[0], 'plugin data entry')).toEqual({
pluginID: 'open-pencil',
key: 'textDirection',
value: 'RTL'
})
})
test('importNodeChanges with unique pluginData entries preserves all', () => {
const entries = [
{ pluginID: 'open-pencil', key: 'textDirection', value: 'RTL' },
{ pluginID: 'open-pencil', key: 'layoutDirection', value: 'LTR' },
{ pluginID: 'my-plugin', key: 'customKey', value: 'hello' }
]
const graph = importNodeChanges([
doc(),
canvas(),
node('FRAME', 10, 1, { pluginData: entries })
])
const page = graph.getPages()[0]
const frame = graph.getChildren(page.id)[0]
expect(frame.pluginData).toHaveLength(3)
expect(frame.pluginData).toContainEqual({
pluginId: 'open-pencil',
key: 'textDirection',
value: 'RTL'
})
expect(frame.pluginData).toContainEqual({
pluginId: 'open-pencil',
key: 'layoutDirection',
value: 'LTR'
})
expect(frame.pluginData).toContainEqual({
pluginId: 'my-plugin',
key: 'customKey',
value: 'hello'
})
})
test('importNodeChanges with single pluginData entry preserves it (zero-copy path)', () => {
const entries = [{ pluginID: 'open-pencil', key: 'textDirection', value: 'LTR' }]
const graph = importNodeChanges([
doc(),
canvas(),
node('FRAME', 10, 1, { pluginData: entries })
])
const page = graph.getPages()[0]
const frame = graph.getChildren(page.id)[0]
expect(frame.pluginData).toHaveLength(1)
expect(frame.pluginData[0]).toEqual({
pluginId: 'open-pencil',
key: 'textDirection',
value: 'LTR'
})
})
test('importNodeChanges with empty pluginData returns empty array', () => {
const graph = importNodeChanges([doc(), canvas(), node('FRAME', 10, 1, { pluginData: [] })])
const page = graph.getPages()[0]
const frame = graph.getChildren(page.id)[0]
expect(frame.pluginData).toEqual([])
// Must be an array, not undefined
expect(Array.isArray(frame.pluginData)).toBe(true)
})
})
describe('FigmaNodeProxy plugin data lazy shared computation', () => {
test('getSharedPluginData computes from pluginData on demand', async () => {
await initCodec()
const entries = [{ pluginID: 'tokens', key: 'tokens/color', value: '{"h":240}' }]
const graph = importNodeChanges([
doc(),
canvas(),
node('FRAME', 10, 1, { pluginData: entries })
])
const page = graph.getPages()[0]
const frame = graph.getChildren(page.id)[0]
// Accessing via proxy reads directly from pluginData
const api = new FigmaAPI(graph)
const proxy = api.getNodeById(frame.id)
const value = proxy?.getSharedPluginData('tokens', 'color') ?? ''
expect(value).toBe('{"h":240}')
})
test('getSharedPluginDataKeys computes from pluginData on demand', async () => {
await initCodec()
const entries = [
{ pluginID: 'tokens', key: 'tokens/accent', value: '#ff0000' },
{ pluginID: 'tokens', key: 'tokens/bg', value: '#ffffff' }
]
const graph = importNodeChanges([
doc(),
canvas(),
node('FRAME', 10, 1, { pluginData: entries })
])
const api = new FigmaAPI(graph)
const page = graph.getPages()[0]
const frame = graph.getChildren(page.id)[0]
const proxy = api.getNodeById(frame.id)
const keys = proxy?.getSharedPluginDataKeys('tokens') ?? []
expect(keys).toContain('accent')
expect(keys).toContain('bg')
})
test('setSharedPluginData on lazy-populated node works correctly', () => {
const graph = new SceneGraph()
const api = new FigmaAPI(graph)
const frame = api.createFrame()
// Set shared data directly (no lazy computation needed)
frame.setSharedPluginData('tokens', 'primary', '#0000ff')
expect(frame.getSharedPluginData('tokens', 'primary')).toBe('#0000ff')
expect(frame.getSharedPluginDataKeys('tokens')).toEqual(['primary'])
// Update the value
frame.setSharedPluginData('tokens', 'primary', '#0000cc')
expect(frame.getSharedPluginData('tokens', 'primary')).toBe('#0000cc')
expect(frame.getSharedPluginDataKeys('tokens')).toEqual(['primary'])
})
})
describe('FigmaNodeProxy plugin data split-brain regression', () => {
test('update via setSharedPluginData does not duplicate on roundtrip', async () => {
await initCodec()
// Simulate an imported fig node with shared data encoded in pluginData
const graph = importNodeChanges([
doc(),
canvas(),
node('FRAME', 10, 1, {
pluginData: [{ pluginID: 'tokens', key: 'tokens/accent', value: 'v1' }]
})
])
const page = graph.getPages()[0]
const frame = graph.getChildren(page.id)[0]
const api = new FigmaAPI(graph)
const proxy = expectDefined(api.getNodeById(frame.id), 'frame proxy')
// Update: old pluginData entry must be replaced, not kept alongside the new one
proxy.setSharedPluginData('tokens', 'accent', 'v2')
const bytes = await exportFigFile(graph)
const parsed = await parseFigFile(
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
)
const parsedPage = parsed.getPages()[0]
const parsedFrame = parsed.getChildren(parsedPage.id)[0]
// Exactly one tokens/accent entry, with the updated value
const accentEntries = parsedFrame.pluginData.filter((e) => e.key === 'tokens/accent')
expect(accentEntries).toHaveLength(1)
expect(accentEntries[0].value).toBe('v2')
const parsedApi = new FigmaAPI(parsed)
const parsedProxy = expectDefined(parsedApi.getNodeById(parsedFrame.id), 'parsed frame proxy')
expect(parsedProxy.getSharedPluginData('tokens', 'accent')).toBe('v2')
})
test('deletion via setSharedPluginData survives roundtrip', async () => {
await initCodec()
const graph = importNodeChanges([
doc(),
canvas(),
node('FRAME', 10, 1, {
pluginData: [{ pluginID: 'tokens', key: 'tokens/accent', value: 'v1' }]
})
])
const page = graph.getPages()[0]
const frame = graph.getChildren(page.id)[0]
const api = new FigmaAPI(graph)
const proxy = expectDefined(api.getNodeById(frame.id), 'frame proxy')
// Delete by setting empty string — must purge from pluginData
proxy.setSharedPluginData('tokens', 'accent', '')
const bytes = await exportFigFile(graph)
const parsed = await parseFigFile(
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
)
const parsedPage = parsed.getPages()[0]
const parsedFrame = parsed.getChildren(parsedPage.id)[0]
// No tokens/accent entry should survive
expect(parsedFrame.pluginData.some((e) => e.key === 'tokens/accent')).toBe(false)
const parsedApi = new FigmaAPI(parsed)
const parsedProxy = expectDefined(parsedApi.getNodeById(parsedFrame.id), 'parsed frame proxy')
expect(parsedProxy.getSharedPluginData('tokens', 'accent')).toBe('')
})
})
describe('extractPluginRelaunchData deduplication via importNodeChanges', () => {
test('duplicate relaunch entries are deduplicated', async () => {
await initCodec()
const relaunchEntries = [
{ pluginID: 'my-plugin', command: 'edit', message: 'Edit widget', isDeleted: false },
{ pluginID: 'my-plugin', command: 'edit', message: 'Edit widget', isDeleted: false }
]
const changes = [doc(), canvas(), node('FRAME', 10, 1, { pluginRelaunchData: relaunchEntries })]
deduplicateNodeChangePluginData(changes)
const graph = importNodeChanges(changes)
const page = graph.getPages()[0]
const frame = graph.getChildren(page.id)[0]
// Duplicates should be collapsed
expect(frame.pluginRelaunchData).toHaveLength(1)
expect(frame.pluginRelaunchData[0]).toEqual({
pluginId: 'my-plugin',
command: 'edit',
message: 'Edit widget',
isDeleted: false
})
})
test('unique relaunch entries are all preserved', async () => {
await initCodec()
const relaunchEntries = [
{ pluginID: 'plugin-a', command: 'edit', message: 'Edit', isDeleted: false },
{ pluginID: 'plugin-b', command: 'configure', message: 'Config', isDeleted: true }
]
const graph = importNodeChanges([
doc(),
canvas(),
node('FRAME', 10, 1, { pluginRelaunchData: relaunchEntries })
])
const page = graph.getPages()[0]
const frame = graph.getChildren(page.id)[0]
expect(frame.pluginRelaunchData).toHaveLength(2)
})
test('empty relaunch data returns empty array', () => {
const graph = importNodeChanges([
doc(),
canvas(),
node('FRAME', 10, 1, { pluginRelaunchData: [] })
])
const page = graph.getPages()[0]
const frame = graph.getChildren(page.id)[0]
expect(frame.pluginRelaunchData).toEqual([])
})
})
describe('plugin data namespace collision guards', () => {
test('getPluginDataKeys excludes encoded shared keys but allows private slash keys', () => {
const graph = new SceneGraph()
const api = new FigmaAPI(graph)
const frame = api.createFrame()
frame.setPluginData('foo', 'private-value')
frame.setPluginData('foo/bar', 'private-slash-value')
frame.setSharedPluginData('open-pencil', 'bar', 'shared-value')
const keys = frame.getPluginDataKeys()
expect(keys).toContain('foo')
expect(keys).toContain('foo/bar')
expect(keys).not.toContain('open-pencil/bar')
expect(keys).not.toContain('bar')
})
test('getSharedPluginData does not read private open-pencil plugin data', () => {
const graph = new SceneGraph()
const api = new FigmaAPI(graph)
const frame = api.createFrame()
// Write private data via setPluginData
frame.setPluginData('textDirection', 'RTL')
// getSharedPluginData with the 'open-pencil' namespace should NOT
// return the private entry (which uses {pluginId: 'open-pencil', key: 'textDirection'})
const sharedValue = frame.getSharedPluginData('open-pencil', 'textDirection')
expect(sharedValue).toBe('')
// Private data is still accessible via getPluginData
const privateValue = frame.getPluginData('textDirection')
expect(privateValue).toBe('RTL')
})
test('setSharedPluginData does not delete private open-pencil plugin data', () => {
const graph = new SceneGraph()
const api = new FigmaAPI(graph)
const frame = api.createFrame()
// Write private data
frame.setPluginData('layoutDirection', 'LTR')
// Set shared data using the 'open-pencil' namespace —
// this should NOT delete the private 'layoutDirection' entry
frame.setSharedPluginData('open-pencil', 'custom-key', 'shared')
const privateValue = frame.getPluginData('layoutDirection')
expect(privateValue).toBe('LTR')
// The shared data is stored correctly
const sharedValue = frame.getSharedPluginData('open-pencil', 'custom-key')
expect(sharedValue).toBe('shared')
// And the private data key list does not include the shared-format key
const keys = frame.getPluginDataKeys()
expect(keys).toContain('layoutDirection')
expect(keys).not.toContain('open-pencil/custom-key')
expect(keys).not.toContain('custom-key')
})
test('getSharedPluginDataKeys does not leak private open-pencil keys', () => {
const graph = new SceneGraph()
const api = new FigmaAPI(graph)
const frame = api.createFrame()
// Write private data
frame.setPluginData('textDirection', 'RTL')
// Write shared data for another namespace (should still work)
frame.setSharedPluginData('tokens', 'accent', '#ff0000')
const sharedKeys = frame.getSharedPluginDataKeys('open-pencil')
expect(sharedKeys).not.toContain('textDirection')
// Non-open-pencil shared data is accessible
const tokensKeys = frame.getSharedPluginDataKeys('tokens')
expect(tokensKeys).toContain('accent')
})
})