figma-rust-mcp 3.2.12 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,21 +1,25 @@
1
- import { test } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { readFileSync } from 'node:fs';
4
- import vm from 'node:vm';
5
- import { spawnSync } from 'node:child_process';
6
-
7
- function runtime(figma = {}) {
8
- const context = vm.createContext({ figma, handlers: {}, console, setTimeout, Map, Set, Uint8Array });
9
- for (const file of ['utils', 'token-helpers', 'handlers-read-detail', 'handlers-read', 'handlers-tokens', 'handlers-write-ops']) {
10
- vm.runInContext(readFileSync(`plugin-src/${file}.js`, 'utf8'), context, { filename: file });
11
- }
12
- return context;
13
- }
14
- const plain = value => JSON.parse(JSON.stringify(value));
15
- const gradient = (transform = [[1, 0, 0], [0, 1, 0]]) => ({
16
- type: 'GRADIENT_LINEAR', gradientTransform: transform, opacity: .5,
17
- gradientStops: [{ position: 0, color: { r: 1, g: 0, b: 0, a: .5 } }, { position: 1, color: { r: 0, g: 0, b: 1, a: 1 } }],
18
- });
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import vm from 'node:vm';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { configureAgent, detectAgents } from '../bin/agent-config.js';
9
+ import { parse as parseJsonc } from 'jsonc-parser';
10
+
11
+ function runtime(figma = {}) {
12
+ const context = vm.createContext({ figma, handlers: {}, console, setTimeout, Map, Set, Uint8Array });
13
+ for (const file of ['utils', 'token-helpers', 'read-helpers', 'handlers-read-detail', 'handlers-read', 'handlers-tokens', 'handlers-write-ops', 'node-reader']) {
14
+ vm.runInContext(readFileSync(`plugin-src/${file}.js`, 'utf8'), context, { filename: file });
15
+ }
16
+ return context;
17
+ }
18
+ const plain = value => JSON.parse(JSON.stringify(value));
19
+ const gradient = (transform = [[1, 0, 0], [0, 1, 0]]) => ({
20
+ type: 'GRADIENT_LINEAR', gradientTransform: transform, opacity: .5,
21
+ gradientStops: [{ position: 0, color: { r: 1, g: 0, b: 0, a: .5 } }, { position: 1, color: { r: 0, g: 0, b: 1, a: 1 } }],
22
+ });
19
23
  const shadow = type => ({ type, color: { r: 0, g: 0, b: 0, a: .125 }, offset: { x: -1, y: 3 }, radius: 6, spread: -2, visible: true });
20
24
 
21
25
  test('compact tree and scan preserve mixed typography instead of using the first run', async () => {
@@ -48,392 +52,525 @@ test('compact tree and scan preserve mixed typography instead of using the first
48
52
  assert.equal(r.resolveTextStyle(node).fontSize, 15.5);
49
53
  assert.equal(r.resolveTextStyle(node).fontWeight, undefined);
50
54
  });
51
-
52
- test('equivalent CSS syntaxes preserve alpha in reads and writes', () => {
53
- const r = runtime();
54
- for (const color of ['rgba(255, 0, 0, .5)', 'rgb(100% 0% 0% / 50%)', 'hsl(0 100% 50% / .5)', 'hsla(0, 100%, 50%, .5)']) {
55
- assert.deepEqual(plain(r.parseColorValue(color)), { r: 1, g: 0, b: 0, a: .5 });
56
- assert.equal(r.hexToRgbA(color).a, .5);
57
- assert.equal(r.solidFill(color)[0].opacity, .5);
58
- }
59
- assert.equal(r.parseColorValue('#f008').a, 136 / 255);
60
- assert.equal(r.parseColorValue('transparent').a, 0);
61
- assert.equal(r.colorToCss({ r: 0, g: 0, b: 0, a: .9999 }), 'rgba(0, 0, 0, 0.9999)');
62
- assert.throws(() => r.parseColorValue('rgba(garbage)'), /Invalid/);
63
- });
64
-
65
- test('gradient projection includes translations, aspect ratio, stop and paint alpha', () => {
66
- const r = runtime();
67
- const p = r.serializePaint(gradient());
68
- assert.deepEqual(plain(p.gradientTransform), [[1, 0, 0], [0, 1, 0]]);
69
- assert.match(r.paintToCss(p, 200, 100), /^linear-gradient\(90deg, rgba\(255, 0, 0, 0.25\) 0%, rgba\(0, 0, 255, 0.5\) 100%\)$/);
70
- const diagonal = r.paintToCss(r.serializePaint(gradient([[1, 1, 0], [0, 1, 0]])), 200, 100);
71
- assert.match(diagonal, /153\.434948/);
72
- assert.match(diagonal, /50%/);
73
- const shifted = r.paintToCss(r.serializePaint(gradient([[.5, 0, .25], [0, 1, 0]])), 200, 100);
74
- assert.match(shifted, /-50%/);
75
- assert.match(shifted, /150%/);
76
- assert.throws(() => r.paintToCss({ ...p, type: 'GRADIENT_DIAMOND' }, 100, 100), /SVG/);
77
- });
78
-
79
- test('get_css uses the numeric paint, never hex slicing a serialized rgba', async () => {
80
- const node = { id: '1:2', name: 'alpha', type: 'RECTANGLE', x: 0, y: 0, width: 200, height: 100,
81
- fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 }, opacity: .25 }], effects: [shadow('DROP_SHADOW'), shadow('INNER_SHADOW')] };
82
- const r = runtime();
83
- r.findNodeByIdAsync = async () => node;
84
- const result = await r.handlers.get_css({ id: node.id });
85
- assert.match(result.css, /background-color: rgba\(255, 0, 0, 0.25\);/);
86
- assert.doesNotMatch(result.css, /NaN|0.0625/);
87
- assert.match(result.css, /inset -1px 3px 6px -2px rgba\(0, 0, 0, 0.125\)/);
88
- assert.equal(result.detail.effects.length, 2);
89
- });
90
-
91
- test('styles retain full paints, effects, units and disabled layers', async () => {
92
- const r = runtime({
93
- getLocalPaintStylesAsync: async () => [{ id: 'p', name: 'Gradient', paints: [gradient()] }],
94
- getLocalTextStylesAsync: async () => [{ id: 't', name: 'Body', fontName: { family: 'Inter', style: 'Regular' }, fontSize: 16, lineHeight: { unit: 'PERCENT', value: 150 }, letterSpacing: { unit: 'PERCENT', value: 2 } }],
95
- getLocalEffectStylesAsync: async () => [{ id: 's', name: 'Card', effects: [shadow('DROP_SHADOW'), shadow('INNER_SHADOW'), { type: 'LAYER_BLUR', radius: 8, visible: false }] }],
96
- getLocalGridStylesAsync: async () => [],
97
- });
98
- const styles = await r.handlers.get_styles();
99
- assert.equal(styles.schemaVersion, 2);
100
- assert.equal(styles.paintStyles[0].paints[0].gradientStops[0].rgba.a, .5);
101
- assert.equal(styles.effectStyles[0].effects.length, 3);
102
- assert.equal(styles.textStyles[0].lineHeightUnit, 'PERCENT');
103
- assert.equal(r.effectsToCss(styles.effectStyles[0].effects).filter, '');
104
- });
105
-
106
- test('aliases use mode names across collections and retain false/zero values', async () => {
107
- const cols = [
108
- { id: 'semantic', name: 'Semantic', defaultModeId: 's-light', modes: [{ modeId: 's-light', name: 'Light' }, { modeId: 's-dark', name: 'Dark' }], variableIds: ['alias', 'zero'] },
109
- { id: 'primitive', name: 'Primitive', defaultModeId: 'p-light', modes: [{ modeId: 'p-light', name: 'Light' }, { modeId: 'p-dark', name: 'Dark' }], variableIds: ['base'] },
110
- ];
111
- const variables = [
112
- { id: 'alias', name: 'semantic', variableCollectionId: 'semantic', resolvedType: 'BOOLEAN', valuesByMode: { 's-light': { type: 'VARIABLE_ALIAS', id: 'base' }, 's-dark': { type: 'VARIABLE_ALIAS', id: 'base' } } },
113
- { id: 'zero', name: 'zero', variableCollectionId: 'semantic', resolvedType: 'FLOAT', valuesByMode: { 's-light': 0, 's-dark': 1 } },
114
- { id: 'base', name: 'base', variableCollectionId: 'primitive', resolvedType: 'BOOLEAN', valuesByMode: { 'p-light': false, 'p-dark': true } },
115
- ];
116
- const r = runtime({ variables: {
117
- getLocalVariableCollectionsAsync: async () => cols, getLocalVariablesAsync: async () => variables,
118
- getVariableByIdAsync: async id => variables.find(v => v.id === id),
119
- getVariableCollectionByIdAsync: async id => cols.find(c => c.id === id),
120
- } });
121
- const result = await r.handlers.get_variables();
122
- const alias = result.collections[0].variables[0];
123
- assert.equal(alias.values['s-light'].resolvedValue, false);
124
- assert.equal(alias.values['s-dark'].resolvedValue, true);
125
- assert.equal(result.resolvedTokens.semantic, false);
126
- assert.equal(result.resolvedTokens.zero, 0);
127
- assert.equal(result.collections[0].defaultModeId, 's-light');
128
- assert.deepEqual(plain(result.diagnostics), []);
129
- const consumer = { resolvedVariableModes: { semantic: 's-dark', primitive: 'p-light' } };
130
- variables[0].resolveForConsumer = () => ({ value: false, resolvedType: 'BOOLEAN' });
131
- assert.equal((await r.resolveVariableValueAsync(variables[0], consumer)).resolvedValue, false);
132
- });
133
-
134
- test('export_node validates format and works through batch and aliases', async () => {
135
- const r = runtime();
136
- r.handlers.export_svg = async () => ({ svg: '<svg/>' });
137
- r.handlers.export_image = async params => ({ format: params.format });
138
- assert.equal((await r.resolveOperationHandler('exportNode')({ format: 'svg' })).svg, '<svg/>');
139
- assert.equal((await r.handlers.export_node({ format: 'jpeg' })).format, 'JPG');
140
- const batch = await r.handlers.batch({ operations: [{ operation: 'export_node', params: { format: 'SVG' } }, { operation: 'export_node', params: { format: 'bad' } }] });
141
- assert.equal(batch.succeeded, 1);
142
- assert.match(batch.results[1].error, /supports PNG/);
143
- });
144
-
145
- test('multiple paints preserve top-first ordering and node opacity stays separate', () => {
146
- const r = runtime();
147
- const paints = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 }, opacity: .5 }, { type: 'SOLID', color: { r: 0, g: 0, b: 1 }, opacity: 1 }].map(r.serializePaint);
148
- const css = r.paintsToCss(paints, 200, 100);
149
- assert.match(css.value, /^linear-gradient\(rgba\(255, 0, 0, 0.5\)/);
150
- assert.match(css.value, /linear-gradient\(#0000ff, #0000ff\)$/);
151
- });
152
-
153
- test('UI badge uses release version initially and follows the server on reconnect', () => {
154
- const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
155
- const html = readFileSync('plugin-runtime/ui.html', 'utf8');
156
- assert.ok(html.includes(`id="runtime-version">v${pkg.version}</span>`));
157
- const start = html.indexOf(' function updateRuntimeVersion(');
158
- const end = html.indexOf(' var runtimeCapabilities', start);
159
- const badge = {};
160
- const context = vm.createContext({ document: { getElementById: () => badge } });
161
- vm.runInContext(html.slice(start, end), context);
162
- context.updateRuntimeVersion('3.2.7');
163
- assert.equal(badge.textContent, 'v3.2.7');
164
- context.updateRuntimeVersion('3.3.0');
165
- assert.equal(badge.textContent, 'v3.3.0');
166
- assert.ok(html.includes('updateRuntimeVersion(serverVer)'));
167
- assert.ok(html.includes('type: "runtime-ready"'));
168
- });
169
-
170
- test('build rejects wrong release tags and checked-in bundle matches source', () => {
171
- const check = spawnSync(process.execPath, ['scripts/build-plugin.js', '--check'], { encoding: 'utf8' });
172
- assert.equal(check.status, 0, check.stderr);
173
- const rejected = spawnSync(process.execPath, ['scripts/build-plugin.js'], {
174
- encoding: 'utf8', env: { ...process.env, GITHUB_REF_TYPE: 'tag', GITHUB_REF_NAME: 'v0.0.0-invalid' },
175
- });
176
- assert.notEqual(rejected.status, 0);
177
- assert.match(rejected.stderr, /does not match build version/);
178
- });
179
-
180
-
181
- test('tree reads preserve same-size screens and all repeated siblings', () => {
182
- const r = runtime();
183
- vm.runInContext(readFileSync('plugin-src/read-helpers.js', 'utf8'), r);
184
- const screens = Array.from({ length: 12 }, (_, i) => ({
185
- id: `screen:${i}`, name: `Screen ${i}`, type: 'FRAME', width: 402, height: 800,
186
- layoutMode: 'VERTICAL', itemSpacing: i,
187
- children: [{ id: `item:${i}`, name: `Item ${i}`, type: 'FRAME', width: 100, height: 40 }],
188
- }));
189
- const root = { id: 'root', name: 'Screens', type: 'SECTION', children: screens };
190
- for (const detail of ['compact', 'full']) {
191
- const tree = plain(r.extractDesignTree(root, 0, 10, detail, true));
192
- assert.equal(tree.children.length, 12);
193
- assert.deepEqual(tree.children.map(node => node.id), screens.map(node => node.id));
194
- assert.equal(tree.children[11].layout.itemSpacing, 11);
195
- assert.equal(tree.children[11].children[0].id, 'item:11');
196
- }
197
- });
198
-
199
-
200
- test('startup index defers document-wide components and coalesces duplicate scans', async () => {
201
- let componentQueries = 0, styleQueries = 0;
202
- const page = { id: 'page:1', children: [{ id: 'frame:1', name: 'Frame', type: 'FRAME' }] };
203
- const r = runtime({ currentPage: page, root: { name: 'File', getPluginData: () => '' } });
204
- r.handlers.get_styles = async () => { styleQueries++; return { schemaVersion: 2 }; };
205
- r.handlers.get_variables = async () => ({ schemaVersion: 2 });
206
- r.handlers.get_local_components = async () => { componentQueries++; return { components: [], componentSets: [] }; };
207
- const [a, b] = await Promise.all([
208
- r.handlers.index_scan({ deferComponents: true }),
209
- r.handlers.index_scan({ deferComponents: true }),
210
- ]);
211
- assert.equal(componentQueries, 0);
212
- assert.equal(styleQueries, 1);
213
- assert.equal(a, b);
214
- assert.equal(a.pageId, page.id);
215
- assert.equal(a.componentsDeferred, true);
216
- assert.equal(a.pageNodes[0].id, 'frame:1');
217
- const [light, full] = await Promise.all([
218
- r.handlers.index_scan({ deferComponents: true }), r.handlers.index_scan({}),
219
- ]);
220
- assert.equal(light.components, null);
221
- assert.equal(full.componentsDeferred, false);
222
- assert.equal(componentQueries, 1);
223
- });
224
-
225
- test('component catalogue walks pages cooperatively without bulk loading or root searches', async () => {
226
- let loaded = 0, yields = 0, reads = 0, lastYieldReads = 0, maxSliceReads = 0;
227
- const page = { type: 'PAGE', name: 'Components', loadAsync: async () => { loaded++; } };
228
- page.children = Array.from({ length: 1000 }, (_, i) => ({
229
- id: `c:${i}`, name: `Component ${i}`, parent: page, width: 10, height: 10,
230
- get type() { reads++; return i % 2 ? 'COMPONENT_SET' : 'COMPONENT'; },
231
- }));
232
- const r = runtime({
233
- root: { children: [page], findAllWithCriteria: () => assert.fail('root-wide search') },
234
- loadAllPagesAsync: () => assert.fail('bulk page load'),
235
- });
236
- r.yieldToUI = async () => {
237
- yields++; maxSliceReads = Math.max(maxSliceReads, reads - lastYieldReads); lastYieldReads = reads;
238
- };
239
- const result = await r.handlers.get_local_components();
240
- assert.equal(loaded, 1);
241
- assert.equal(result.components.length, 500);
242
- assert.equal(result.componentSets.length, 500);
243
- assert.ok(yields >= 10, 'large traversal must yield before finishing');
244
- assert.ok(maxSliceReads <= 200, 'no more than 100 nodes between traversal yields');
245
- });
246
-
247
- test('startup subscribes to active-page changes without loading the whole file', async () => {
248
- const events = new Map(), timers = [], messages = [];
249
- let pageListener;
250
- const page = {
251
- id: 'page:1', children: [], selection: [],
252
- on: (name, callback) => { assert.equal(name, 'nodechange'); pageListener = callback; },
253
- off: () => {},
254
- };
255
- const r = runtime({
256
- currentPage: page, root: { name: 'File', getPluginData: () => 'session' },
257
- clientStorage: { getAsync: async () => null }, ui: { postMessage: msg => messages.push(msg) },
258
- on: (name, callback) => { events.set(name, callback); },
259
- loadAllPagesAsync: () => assert.fail('startup bulk page load'),
260
- });
261
- r.setTimeout = (callback, delay) => { timers.push({ callback, delay }); return timers.length; };
55
+
56
+ test('equivalent CSS syntaxes preserve alpha in reads and writes', () => {
57
+ const r = runtime();
58
+ for (const color of ['rgba(255, 0, 0, .5)', 'rgb(100% 0% 0% / 50%)', 'hsl(0 100% 50% / .5)', 'hsla(0, 100%, 50%, .5)']) {
59
+ assert.deepEqual(plain(r.parseColorValue(color)), { r: 1, g: 0, b: 0, a: .5 });
60
+ assert.equal(r.hexToRgbA(color).a, .5);
61
+ assert.equal(r.solidFill(color)[0].opacity, .5);
62
+ }
63
+ assert.equal(r.parseColorValue('#f008').a, 136 / 255);
64
+ assert.equal(r.parseColorValue('transparent').a, 0);
65
+ assert.equal(r.colorToCss({ r: 0, g: 0, b: 0, a: .9999 }), 'rgba(0, 0, 0, 0.9999)');
66
+ assert.throws(() => r.parseColorValue('rgba(garbage)'), /Invalid/);
67
+ });
68
+
69
+ test('gradient projection includes translations, aspect ratio, stop and paint alpha', () => {
70
+ const r = runtime();
71
+ const p = r.serializePaint(gradient());
72
+ assert.deepEqual(plain(p.gradientTransform), [[1, 0, 0], [0, 1, 0]]);
73
+ assert.match(r.paintToCss(p, 200, 100), /^linear-gradient\(90deg, rgba\(255, 0, 0, 0.25\) 0%, rgba\(0, 0, 255, 0.5\) 100%\)$/);
74
+ const diagonal = r.paintToCss(r.serializePaint(gradient([[1, 1, 0], [0, 1, 0]])), 200, 100);
75
+ assert.match(diagonal, /153\.434948/);
76
+ assert.match(diagonal, /50%/);
77
+ const shifted = r.paintToCss(r.serializePaint(gradient([[.5, 0, .25], [0, 1, 0]])), 200, 100);
78
+ assert.match(shifted, /-50%/);
79
+ assert.match(shifted, /150%/);
80
+ assert.throws(() => r.paintToCss({ ...p, type: 'GRADIENT_DIAMOND' }, 100, 100), /SVG/);
81
+ });
82
+
83
+ test('get_css uses the numeric paint, never hex slicing a serialized rgba', async () => {
84
+ const node = { id: '1:2', name: 'alpha', type: 'RECTANGLE', x: 0, y: 0, width: 200, height: 100,
85
+ fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 }, opacity: .25 }], effects: [shadow('DROP_SHADOW'), shadow('INNER_SHADOW')] };
86
+ const r = runtime();
87
+ r.findNodeByIdAsync = async () => node;
88
+ const result = await r.handlers.get_css({ id: node.id });
89
+ assert.match(result.css, /background-color: rgba\(255, 0, 0, 0.25\);/);
90
+ assert.doesNotMatch(result.css, /NaN|0.0625/);
91
+ assert.match(result.css, /inset -1px 3px 6px -2px rgba\(0, 0, 0, 0.125\)/);
92
+ assert.equal(result.detail.effects.length, 2);
93
+ });
94
+
95
+ test('styles retain full paints, effects, units and disabled layers', async () => {
96
+ const r = runtime({
97
+ getLocalPaintStylesAsync: async () => [{ id: 'p', name: 'Gradient', paints: [gradient()] }],
98
+ getLocalTextStylesAsync: async () => [{ id: 't', name: 'Body', fontName: { family: 'Inter', style: 'Regular' }, fontSize: 16, lineHeight: { unit: 'PERCENT', value: 150 }, letterSpacing: { unit: 'PERCENT', value: 2 } }],
99
+ getLocalEffectStylesAsync: async () => [{ id: 's', name: 'Card', effects: [shadow('DROP_SHADOW'), shadow('INNER_SHADOW'), { type: 'LAYER_BLUR', radius: 8, visible: false }] }],
100
+ getLocalGridStylesAsync: async () => [],
101
+ });
102
+ const styles = await r.handlers.get_styles();
103
+ assert.equal(styles.schemaVersion, 2);
104
+ assert.equal(styles.paintStyles[0].paints[0].gradientStops[0].rgba.a, .5);
105
+ assert.equal(styles.effectStyles[0].effects.length, 3);
106
+ assert.equal(styles.textStyles[0].lineHeightUnit, 'PERCENT');
107
+ assert.equal(r.effectsToCss(styles.effectStyles[0].effects).filter, '');
108
+ });
109
+
110
+ test('aliases use mode names across collections and retain false/zero values', async () => {
111
+ const cols = [
112
+ { id: 'semantic', name: 'Semantic', defaultModeId: 's-light', modes: [{ modeId: 's-light', name: 'Light' }, { modeId: 's-dark', name: 'Dark' }], variableIds: ['alias', 'zero'] },
113
+ { id: 'primitive', name: 'Primitive', defaultModeId: 'p-light', modes: [{ modeId: 'p-light', name: 'Light' }, { modeId: 'p-dark', name: 'Dark' }], variableIds: ['base'] },
114
+ ];
115
+ const variables = [
116
+ { id: 'alias', name: 'semantic', variableCollectionId: 'semantic', resolvedType: 'BOOLEAN', valuesByMode: { 's-light': { type: 'VARIABLE_ALIAS', id: 'base' }, 's-dark': { type: 'VARIABLE_ALIAS', id: 'base' } } },
117
+ { id: 'zero', name: 'zero', variableCollectionId: 'semantic', resolvedType: 'FLOAT', valuesByMode: { 's-light': 0, 's-dark': 1 } },
118
+ { id: 'base', name: 'base', variableCollectionId: 'primitive', resolvedType: 'BOOLEAN', valuesByMode: { 'p-light': false, 'p-dark': true } },
119
+ ];
120
+ const r = runtime({ variables: {
121
+ getLocalVariableCollectionsAsync: async () => cols, getLocalVariablesAsync: async () => variables,
122
+ getVariableByIdAsync: async id => variables.find(v => v.id === id),
123
+ getVariableCollectionByIdAsync: async id => cols.find(c => c.id === id),
124
+ } });
125
+ const result = await r.handlers.get_variables();
126
+ const alias = result.collections[0].variables[0];
127
+ assert.equal(alias.values['s-light'].resolvedValue, false);
128
+ assert.equal(alias.values['s-dark'].resolvedValue, true);
129
+ assert.equal(result.resolvedTokens.semantic, false);
130
+ assert.equal(result.resolvedTokens.zero, 0);
131
+ assert.equal(result.collections[0].defaultModeId, 's-light');
132
+ assert.deepEqual(plain(result.diagnostics), []);
133
+ const consumer = { resolvedVariableModes: { semantic: 's-dark', primitive: 'p-light' } };
134
+ variables[0].resolveForConsumer = () => ({ value: false, resolvedType: 'BOOLEAN' });
135
+ assert.equal((await r.resolveVariableValueAsync(variables[0], consumer)).resolvedValue, false);
136
+ });
137
+
138
+ test('export_node validates format and works through batch and aliases', async () => {
139
+ const r = runtime();
140
+ r.handlers.export_svg = async () => ({ svg: '<svg/>' });
141
+ r.handlers.export_image = async params => ({ format: params.format });
142
+ assert.equal((await r.resolveOperationHandler('exportNode')({ format: 'svg' })).svg, '<svg/>');
143
+ assert.equal((await r.handlers.export_node({ format: 'jpeg' })).format, 'JPG');
144
+ const batch = await r.handlers.batch({ operations: [{ operation: 'export_node', params: { format: 'SVG' } }, { operation: 'export_node', params: { format: 'bad' } }] });
145
+ assert.equal(batch.succeeded, 1);
146
+ assert.match(batch.results[1].error, /supports PNG/);
147
+ });
148
+
149
+ test('multiple paints preserve top-first ordering and node opacity stays separate', () => {
150
+ const r = runtime();
151
+ const paints = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 }, opacity: .5 }, { type: 'SOLID', color: { r: 0, g: 0, b: 1 }, opacity: 1 }].map(r.serializePaint);
152
+ const css = r.paintsToCss(paints, 200, 100);
153
+ assert.match(css.value, /^linear-gradient\(rgba\(255, 0, 0, 0.5\)/);
154
+ assert.match(css.value, /linear-gradient\(#0000ff, #0000ff\)$/);
155
+ });
156
+
157
+ test('UI badge uses release version initially and follows the server on reconnect', () => {
158
+ const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
159
+ const html = readFileSync('plugin-runtime/ui.html', 'utf8');
160
+ assert.ok(html.includes(`id="runtime-version">v${pkg.version}</span>`));
161
+ const start = html.indexOf(' function updateRuntimeVersion(');
162
+ const end = html.indexOf(' var runtimeCapabilities', start);
163
+ const badge = {};
164
+ const context = vm.createContext({ document: { getElementById: () => badge } });
165
+ vm.runInContext(html.slice(start, end), context);
166
+ context.updateRuntimeVersion('3.2.7');
167
+ assert.equal(badge.textContent, 'v3.2.7');
168
+ context.updateRuntimeVersion('3.3.0');
169
+ assert.equal(badge.textContent, 'v3.3.0');
170
+ assert.ok(html.includes('updateRuntimeVersion(serverVer)'));
171
+ assert.ok(html.includes('type: "runtime-ready"'));
172
+ });
173
+
174
+ test('build rejects wrong release tags and checked-in bundle matches source', () => {
175
+ const check = spawnSync(process.execPath, ['scripts/build-plugin.js', '--check'], { encoding: 'utf8' });
176
+ assert.equal(check.status, 0, check.stderr);
177
+ const rejected = spawnSync(process.execPath, ['scripts/build-plugin.js'], {
178
+ encoding: 'utf8', env: { ...process.env, GITHUB_REF_TYPE: 'tag', GITHUB_REF_NAME: 'v0.0.0-invalid' },
179
+ });
180
+ assert.notEqual(rejected.status, 0);
181
+ assert.match(rejected.stderr, /does not match build version/);
182
+ });
183
+
184
+ test('setup command prints scriptable choices when no terminal is attached', () => {
185
+ const result = spawnSync(process.execPath, ['bin/figma-rust-mcp.js', '--init'], { encoding: 'utf8' });
186
+ assert.equal(result.status, 0, result.stderr);
187
+ assert.match(result.stdout, /--install-service/);
188
+ assert.match(result.stdout, /--setup-plugin/);
189
+ assert.match(result.stdout, /--service-status/);
190
+ });
191
+
192
+ test('agent setup detects installed clients and preserves existing JSONC and TOML config', () => {
193
+ const home = mkdtempSync(path.join(os.tmpdir(), 'figma-rust-mcp-'));
194
+ try {
195
+ const env = { PATH: '' };
196
+ for (const dir of ['.codex', '.claude', '.antigravity-ide', '.cursor', '.codeium/windsurf', '.copilot', '.config/zed']) {
197
+ mkdirSync(path.join(home, dir), { recursive: true });
198
+ }
199
+ const agents = detectAgents({ home, env, platform: 'linux' });
200
+ assert.deepEqual(agents.map(agent => agent.id), ['codex', 'claude', 'antigravity', 'cursor', 'windsurf', 'vscode', 'zed']);
201
+
202
+ for (const id of ['claude', 'antigravity', 'windsurf', 'vscode']) {
203
+ const agent = agents.find(candidate => candidate.id === id);
204
+ configureAgent(agent);
205
+ const config = parseJsonc(readFileSync(agent.configPath, 'utf8')).mcpServers['figma-rust-mcp'];
206
+ assert.ok(config);
207
+ if (id === 'antigravity') assert.equal(config.serverUrl, 'http://127.0.0.1:38451/sse');
208
+ else assert.equal(config.url, 'http://127.0.0.1:38451/mcp');
209
+ }
210
+
211
+ const cursor = agents.find(agent => agent.id === 'cursor');
212
+ writeFileSync(cursor.configPath, '{\n // keep this note\n "mcpServers": { "other": { "url": "http://example.test" } }\n}\n');
213
+ assert.equal(configureAgent(cursor), true);
214
+ const cursorText = readFileSync(cursor.configPath, 'utf8');
215
+ const cursorConfig = parseJsonc(cursorText);
216
+ assert.match(cursorText, /keep this note/);
217
+ assert.equal(cursorConfig.mcpServers.other.url, 'http://example.test');
218
+ assert.equal(cursorConfig.mcpServers['figma-rust-mcp'].url, 'http://127.0.0.1:38451/mcp');
219
+ assert.equal(configureAgent(cursor), false);
220
+
221
+ const zed = agents.find(agent => agent.id === 'zed');
222
+ writeFileSync(zed.configPath, '{\n "theme": "One Dark",\n}\n');
223
+ configureAgent(zed);
224
+ assert.equal(parseJsonc(readFileSync(zed.configPath, 'utf8')).theme, 'One Dark');
225
+ assert.equal(parseJsonc(readFileSync(zed.configPath, 'utf8')).context_servers['figma-rust-mcp'].url, 'http://127.0.0.1:38451/mcp');
226
+
227
+ const codex = agents.find(agent => agent.id === 'codex');
228
+ writeFileSync(codex.configPath, '[mcp_servers.other]\nurl = "http://other.test"\n');
229
+ configureAgent(codex);
230
+ const codexText = readFileSync(codex.configPath, 'utf8');
231
+ assert.match(codexText, /\[mcp_servers\.other\]/);
232
+ assert.match(codexText, /\[mcp_servers\.figma-rust-mcp\]\nurl = "http:\/\/127\.0\.0\.1:38451\/mcp"/);
233
+ assert.equal(configureAgent(codex), false);
234
+ } finally {
235
+ rmSync(home, { recursive: true, force: true });
236
+ }
237
+ });
238
+
239
+
240
+ test('tree reads preserve same-size screens and all repeated siblings', () => {
241
+ const r = runtime();
242
+ vm.runInContext(readFileSync('plugin-src/read-helpers.js', 'utf8'), r);
243
+ const screens = Array.from({ length: 12 }, (_, i) => ({
244
+ id: `screen:${i}`, name: `Screen ${i}`, type: 'FRAME', width: 402, height: 800,
245
+ layoutMode: 'VERTICAL', itemSpacing: i,
246
+ children: [{ id: `item:${i}`, name: `Item ${i}`, type: 'FRAME', width: 100, height: 40 }],
247
+ }));
248
+ const root = { id: 'root', name: 'Screens', type: 'SECTION', children: screens };
249
+ for (const detail of ['compact', 'full']) {
250
+ const tree = plain(r.extractDesignTree(root, 0, 10, detail, true));
251
+ assert.equal(tree.children.length, 12);
252
+ assert.deepEqual(tree.children.map(node => node.id), screens.map(node => node.id));
253
+ assert.equal(tree.children[11].layout.itemSpacing, 11);
254
+ assert.equal(tree.children[11].children[0].id, 'item:11');
255
+ }
256
+ });
257
+
258
+
259
+ test('startup index defers document-wide components and coalesces duplicate scans', async () => {
260
+ let componentQueries = 0, styleQueries = 0;
261
+ const page = { id: 'page:1', children: [{ id: 'frame:1', name: 'Frame', type: 'FRAME' }] };
262
+ const r = runtime({ currentPage: page, root: { name: 'File', getPluginData: () => '' } });
263
+ r.handlers.get_styles = async () => { styleQueries++; return { schemaVersion: 2 }; };
264
+ r.handlers.get_variables = async () => ({ schemaVersion: 2 });
265
+ r.handlers.get_local_components = async () => { componentQueries++; return { components: [], componentSets: [] }; };
266
+ const [a, b] = await Promise.all([
267
+ r.handlers.index_scan({ deferComponents: true }),
268
+ r.handlers.index_scan({ deferComponents: true }),
269
+ ]);
270
+ assert.equal(componentQueries, 0);
271
+ assert.equal(styleQueries, 1);
272
+ assert.equal(a, b);
273
+ assert.equal(a.pageId, page.id);
274
+ assert.equal(a.componentsDeferred, true);
275
+ assert.equal(a.pageNodes[0].id, 'frame:1');
276
+ const [light, full] = await Promise.all([
277
+ r.handlers.index_scan({ deferComponents: true }), r.handlers.index_scan({}),
278
+ ]);
279
+ assert.equal(light.components, null);
280
+ assert.equal(full.componentsDeferred, false);
281
+ assert.equal(componentQueries, 1);
282
+ });
283
+
284
+ test('page index is shallow; frame reads paginate and instances expand only on request', async () => {
285
+ const messages = [];
286
+ let childReads = 0;
287
+ const page = { id: 'page', type: 'PAGE', children: [] };
288
+ const figma = { currentPage: page, skipInvisibleInstanceChildren: false,
289
+ root: { name: 'File', getPluginData: () => '' }, ui: { postMessage: msg => messages.push(msg) } };
290
+ const text = i => ({ id: 'text:' + i, name: 'Label ' + i, type: 'TEXT', characters: 'Content ' + i,
291
+ width: 15.5, height: 20.25, visible: true,
292
+ get fills() { assert.fail('projected index must not read paints'); },
293
+ get fontSize() { assert.fail('projected index must not read typography'); } });
294
+ const visible = Array.from({ length: 1200 }, (_, i) => text(i));
295
+ const hidden = text('hidden'); hidden.visible = false;
296
+ const frame = { id:'frame', name:'Frame', type:'FRAME', parent:page };
297
+ const instance = { id:'instance', name:'Instance', type:'INSTANCE', parent:frame,
298
+ get children() { childReads++; return figma.skipInvisibleInstanceChildren ? visible : [...visible,hidden]; } };
299
+ for (const node of [...visible,hidden]) node.parent = instance;
300
+ frame.children = [instance]; page.children = [frame];
301
+ figma.getNodeByIdAsync = async id => id === frame.id ? frame : id === instance.id ? instance : null;
302
+ const r = runtime(figma);
303
+ r.handlers.get_styles = async () => ({}); r.handlers.get_variables = async () => ({});
304
+ r.yieldToUI = async () => assert.equal(figma.skipInvisibleInstanceChildren, false);
305
+ const startup = await r.handlers.index_scan({ deferComponents:true });
306
+ assert.equal(startup.scope.id, page.id);
307
+ assert.equal(startup.scope.depth, 0);
308
+ assert.deepEqual(messages.filter(msg => msg.type === 'index-chunk').flatMap(msg => msg.nodes.map(n => n.id)), ['frame']);
309
+ assert.equal(childReads, 0);
310
+ const opaque = await r.handlers.read_nodes({ id:frame.id });
311
+ assert.equal(opaque.nodes.length, 2); assert.equal(opaque.nodes[1].opaque, true); assert.equal(childReads, 0);
312
+ messages.length = 0;
313
+ const result = await r.handlers.index_scan({ id:frame.id, expandInstances:true, deferComponents:true });
314
+ const chunks = messages.filter(msg => msg.type === 'index-chunk');
315
+ assert.deepEqual(chunks.map(msg => msg.nodes.length), [500,500,202]);
316
+ const nodes = chunks.flatMap(msg => plain(msg.nodes));
317
+ assert.equal(childReads, 1); assert.equal(result.nodesTruncated, false);
318
+ assert.equal(nodes.at(-1).content, 'Content 1199'); assert.equal(nodes.at(-1).width, 15.5);
319
+ assert.equal(nodes[1].childIds.length, 1200);
320
+ assert.equal(figma.skipInvisibleInstanceChildren, false);
321
+ const first = await r.handlers.read_nodes({ id:frame.id, expandInstances:true, limit:2 });
322
+ assert.ok(first.nextCursor); assert.equal(first.complete, false);
323
+ const next = await r.handlers.read_nodes({ cursor:first.nextCursor, limit:2 });
324
+ assert.deepEqual(plain(next.nodes.map(n => n.id)), ['text:0','text:1']);
325
+ await assert.rejects(r.handlers.read_nodes({ cursor:first.nextCursor }), /expired/);
326
+ await assert.rejects(r.handlers.read_nodes({ cursor:next.nextCursor, fields:['style'] }), /options cannot change/);
327
+ r.nodeRevision++;
328
+ await assert.rejects(r.handlers.read_nodes({ cursor:next.nextCursor }), /expired/);
329
+ const full = await r.handlers.read_nodes({ id:instance.id, includeHidden:true, expandInstances:true, limit:500 });
330
+ let all = [...full.nodes], cursor = full.nextCursor;
331
+ while(cursor) { const page = await r.handlers.read_nodes({cursor,limit:500}); all.push(...page.nodes); cursor=page.nextCursor; }
332
+ assert.equal(all.length, 1202); assert.equal(all.at(-1).visible, false);
333
+ assert.throws(() => r.withInstanceVisibility(false, () => { throw Error('read failed'); }), /read failed/);
334
+ assert.equal(figma.skipInvisibleInstanceChildren, false);
335
+ });
336
+
337
+ test('index yields by elapsed time and cancels when the active page changes', async () => {
338
+ let elapsed = 0, reads = 0, previousReads = 0, yields = 0;
339
+ const messages = [];
340
+ const page = { id: 'page', children: Array.from({ length: 30 }, (_, i) => ({
341
+ id: `${i}`, type: 'FRAME', get width() { elapsed += 2; reads++; return 10; },
342
+ })) };
343
+ const r = runtime({ skipInvisibleInstanceChildren: false, currentPage: page,
344
+ root: { name: 'File', getPluginData: () => '' }, ui: { postMessage: msg => messages.push(msg) } });
345
+ r.Date = { now: () => elapsed };
346
+ r.handlers.get_styles = async () => ({});
347
+ r.handlers.get_variables = async () => ({});
348
+ r.yieldToUI = async () => {
349
+ yields++;
350
+ assert.ok(reads - previousReads <= 4, 'yield after an 8ms slice');
351
+ previousReads = reads;
352
+ assert.equal(r.figma.skipInvisibleInstanceChildren, false);
353
+ };
354
+ await r.handlers.index_scan({ deferComponents: true });
355
+ assert.equal(reads, 30);
356
+ assert.ok(yields >= 7);
357
+ r.yieldToUI = async () => { r.figma.currentPage = { id: 'other', children: [] }; };
358
+ messages.length = 0;
359
+ const result = await r.handlers.index_scan({ deferComponents: true });
360
+ assert.equal(result.cancelled, true);
361
+ assert.ok(!messages.some(msg => msg.type === 'index-update'));
362
+ });
363
+
364
+ test('component catalogue walks pages cooperatively without bulk loading or root searches', async () => {
365
+ let loaded = 0, yields = 0, reads = 0, lastYieldReads = 0, maxSliceReads = 0;
366
+ const page = { type: 'PAGE', name: 'Components', loadAsync: async () => { loaded++; } };
367
+ page.children = Array.from({ length: 1000 }, (_, i) => ({
368
+ id: `c:${i}`, name: `Component ${i}`, parent: page, width: 10, height: 10,
369
+ get type() { reads++; return i % 2 ? 'COMPONENT_SET' : 'COMPONENT'; },
370
+ }));
371
+ const r = runtime({
372
+ root: { children: [page], findAllWithCriteria: () => assert.fail('root-wide search') },
373
+ loadAllPagesAsync: () => assert.fail('bulk page load'),
374
+ });
375
+ r.yieldToUI = async () => {
376
+ yields++; maxSliceReads = Math.max(maxSliceReads, reads - lastYieldReads); lastYieldReads = reads;
377
+ };
378
+ const result = await r.handlers.get_local_components();
379
+ assert.equal(loaded, 1);
380
+ assert.equal(result.components.length, 500);
381
+ assert.equal(result.componentSets.length, 500);
382
+ assert.ok(yields >= 10, 'large traversal must yield before finishing');
383
+ assert.ok(maxSliceReads <= 200, 'no more than 100 nodes between traversal yields');
384
+ });
385
+
386
+ test('startup subscribes to active-page changes without loading the whole file', async () => {
387
+ const events = new Map(), timers = [], messages = [];
388
+ let pageListener;
389
+ const page = {
390
+ id: 'page:1', children: [], selection: [],
391
+ on: (name, callback) => { assert.equal(name, 'nodechange'); pageListener = callback; },
392
+ off: () => {},
393
+ };
394
+ const r = runtime({
395
+ currentPage: page, root: { name: 'File', getPluginData: () => 'session' },
396
+ clientStorage: { getAsync: async () => null }, ui: { postMessage: msg => messages.push(msg) },
397
+ on: (name, callback) => { events.set(name, callback); },
398
+ loadAllPagesAsync: () => assert.fail('startup bulk page load'),
399
+ });
400
+ r.setTimeout = (callback, delay) => { timers.push({ callback, delay }); return timers.length; };
262
401
  r.clearTimeout = () => {};
263
402
  r.__html__ = '<html>thin loader</html>';
264
403
  let reopenedUi = 0;
265
404
  r.figma.showUI = () => { reopenedUi++; };
266
405
  vm.runInContext(readFileSync('plugin-src/main.js', 'utf8'), r);
267
406
  assert.equal(reopenedUi, 0, 'dynamic runtime must not replace the loader iframe');
268
- assert.ok(pageListener);
269
- assert.ok(events.has('stylechange'));
270
- assert.ok(!events.has('documentchange'));
271
- let scanOptions;
272
- r.handlers.index_scan = async options => { scanOptions = options; return { pageId: page.id }; };
273
- await timers.find(timer => timer.delay === 1800).callback();
274
- await new Promise(resolve => setTimeout(resolve, 0));
275
- assert.equal(scanOptions.deferComponents, true);
276
- pageListener({ nodeChanges: [{ type: 'PROPERTY_CHANGE', node: { id: 'changed:1' } }] });
277
- assert.ok(r.pendingChangedNodeIds.has('changed:1'));
278
- assert.ok(messages.some(msg => msg.type === 'document-change'));
279
- assert.deepEqual(plain(messages.filter(msg => msg.type === 'document-change').at(-1).changedNodeIds), ['changed:1']);
280
- r.findNodeByIdAsync = async id => id === 'changed:1'
281
- ? { id, parent: page, type: 'TEXT', fontSize: 15.5, fontWeight: 'Semi Bold' } : null;
282
- r.extractDesignTree = node => ({ id: node.id, type: node.type, fontSize: node.fontSize, fontWeight: node.fontWeight });
283
- pageListener({ nodeChanges: [{ type: 'DELETE', id: 'deleted:1' }] });
407
+ assert.ok(pageListener);
408
+ assert.ok(events.has('stylechange'));
409
+ assert.ok(!events.has('documentchange'));
410
+ let scanOptions;
411
+ r.handlers.index_scan = async options => { scanOptions = options; return { pageId: page.id }; };
412
+ await timers.find(timer => timer.delay === 1800).callback();
413
+ await new Promise(resolve => setTimeout(resolve, 0));
414
+ assert.equal(scanOptions.deferComponents, true);
415
+ pageListener({ nodeChanges: [{ type: 'PROPERTY_CHANGE', node: { id: 'changed:1' } }] });
416
+ assert.ok(r.pendingChangedNodeIds.has('changed:1'));
417
+ assert.ok(messages.some(msg => msg.type === 'nodes-invalidated'));
418
+ const changed = { id:'changed:1', type:'TEXT', parent:page, width:15.5, characters:'New' };
419
+ r.extractDesignTree = () => assert.fail('patch sync must not extract a tree');
420
+ pageListener({ nodeChanges:[{ type:'PROPERTY_CHANGE', properties:['width','characters'], node:changed }] });
421
+ pageListener({ nodeChanges:[{ type:'DELETE', id:'deleted:1' }] });
284
422
  await timers.filter(timer => timer.delay === 100).at(-1).callback();
285
- const diff = plain(messages.filter(msg => msg.type === 'node-diff').at(-1));
286
- assert.deepEqual(diff.deletedIds, ['deleted:1']);
287
- assert.equal(diff.nodes[0].fontSize, 15.5);
288
- assert.equal(diff.nodes[0].fontWeight, 'Semi Bold');
289
- assert.equal(diff.nodes[0].parentId, page.id);
290
- assert.deepEqual(diff.nodes[0].childIds, []);
291
- const next = { id: 'page:2', on: () => {}, off: () => {} };
292
- r.figma.currentPage = next;
293
- r.handlers.index_scan = async () => ({ pageId: 'page:1' });
294
- const previousUpdates = messages.filter(msg => msg.type === 'index-update').length;
295
- await r.publishIndex(true);
296
- assert.equal(messages.filter(msg => msg.type === 'index-update').length, previousUpdates);
297
- });
298
-
299
-
300
- function taskRuntime() {
301
- const page = { id: 'page', type: 'PAGE' };
302
- const a = { id: 'a', name: 'A', type: 'FRAME', parent: page, children: [] };
303
- const b = { id: 'b', name: 'B', type: 'FRAME', parent: page, children: [] };
304
- const child = { id: 'a:child', type: 'TEXT', parent: a };
305
- a.children.push(child);
306
- const nodes = new Map([page, a, b, child].map(node => [node.id, node]));
307
- const r = runtime();
308
- vm.runInContext(readFileSync('plugin-src/task-scope.js', 'utf8'), r);
309
- r.findNodeByIdAsync = async id => nodes.get(id);
310
- return { r, nodes, a, b, page };
311
- }
312
-
313
- test('frame tasks reject outside/global writes and batch escape before mutations', async () => {
314
- const { r, a, b } = taskRuntime();
315
- await r.handlers.task_start({ taskId: 'task-a', frameId: a.id });
316
- await r.handlers.task_start({ taskId: 'task-b', frameId: b.id });
317
- await assert.rejects(r.handlers.task_start({ taskId: 'task-c', frameId: a.id }), /reserved/);
318
- const create = await r.validateTaskOperation('create', { _taskId: 'task-a', type: 'TEXT', content: 'Hello' });
319
- assert.equal(create.params.parentId, a.id);
320
- const read = await r.validateTaskOperation('get_selection', { _taskId: 'task-a' });
321
- assert.equal(read.operation, 'get_design');
322
- assert.equal(read.params.id, a.id);
323
- assert.equal((await r.validateTaskOperation('screenshot', { _taskId: 'task-a', keepViewport: false })).params.keepViewport, true);
324
- await r.validateTaskOperation('modify', { _taskId: 'task-a', id: 'a:child', name: 'Updated' });
325
- for (const [operation, params] of [
326
- ['modify', { id: b.id }], ['create', { type: 'TEXT', parentId: b.id }],
327
- ['delete', { id: a.id, force: true }], ['clone', { id: a.id }],
328
- ['append', { parentId: b.id, childId: 'a:child' }],
329
- ['set_selection', { nodeIds: [a.id] }], ['setPage', { id: 'page' }],
330
- ['setVariableValue', { id: 'variable' }], ['createPaintStyle', { name: 'Global' }],
331
- ['create', { type: 'COMPONENT' }],
332
- ['batch', { operations: [{ operation: 'modify', params: { id: 'a:child' } }, { operation: 'delete', params: { id: b.id } }] }],
333
- ]) {
334
- await assert.rejects(r.validateTaskOperation(operation, { ...params, _taskId: 'task-a' }));
335
- }
336
- await assert.rejects(r.validateTaskOperation('modify', { id: 'a:child' }), /require taskId/);
337
- await r.handlers.task_end({ taskId: 'task-a' });
338
- await assert.rejects(r.validateTaskOperation('modify', { _taskId: 'task-a', id: 'a:child' }), /Unknown task/);
339
- });
340
-
341
- test('task roots cannot be shared masters or nested layout frames', async () => {
342
- const { r, nodes, a } = taskRuntime();
343
- const nested = { id: 'nested', type: 'FRAME', parent: a, children: [] };
344
- nodes.set(nested.id, nested);
345
- await assert.rejects(r.handlers.task_start({ taskId: 'nested-task', frameId: nested.id }), /independent FRAME/);
346
- a.children.push({ id: 'master', type: 'COMPONENT', parent: a });
347
- await assert.rejects(r.handlers.task_start({ taskId: 'master-task', frameId: a.id }), /shared component/);
348
- });
349
-
350
- test('plugin tab IDs differ for the same document and request replay does not write twice', async () => {
351
- const messages = [];
352
- function boot() {
353
- const r = runtime({
354
- fileKey: 'same-file', root: { name: 'File', getPluginData: () => 'same-file' },
355
- currentPage: { id: 'page', selection: [], on: () => {} },
356
- clientStorage: { getAsync: async () => null }, ui: { postMessage: msg => messages.push(msg) }, on: () => {},
357
- });
358
- r.setTimeout = () => 0; r.clearTimeout = () => {};
359
- r.validateTaskOperation = async (operation, params) => ({ operation, params });
360
- vm.runInContext(readFileSync('plugin-src/main.js', 'utf8'), r);
361
- return r;
362
- }
363
- const first = boot(), second = boot();
364
- assert.notEqual(first.currentSessionId, second.currentSessionId);
365
- assert.equal(first.currentDocumentId, second.currentDocumentId);
366
- let release;
367
- const blocked = new Promise(resolve => { release = resolve; });
368
- const entered = [];
369
- first.handlers.modify = async params => { entered.push(params.id); if (params.id === 'first') await blocked; return params; };
370
- const call1 = first.figma.ui.onmessage({ id: 'request-1', operation: 'modify', params: { id: 'first' } });
371
- const replay = first.figma.ui.onmessage({ id: 'request-1', operation: 'modify', params: { id: 'first' } });
372
- const call2 = first.figma.ui.onmessage({ id: 'request-2', operation: 'modify', params: { id: 'second' } });
373
- await new Promise(resolve => setTimeout(resolve, 0));
374
- assert.deepEqual(entered, ['first']);
375
- release(); await Promise.all([call1, replay, call2]);
376
- assert.deepEqual(entered, ['first', 'second']);
377
- assert.equal(messages.filter(msg => msg.id === 'request-1' && msg.success).length, 2);
378
- assert.ok(messages.filter(msg => msg.id).every(msg => msg.sessionId === first.currentSessionId));
379
- });
380
-
381
- test('old UI socket callbacks cannot clear or dispatch through a replacement socket', () => {
382
- const html = readFileSync('plugin-runtime/ui.html', 'utf8');
383
- const source = html.slice(html.indexOf(' function connectWs()'), html.indexOf(' async function startLongPoll()'));
384
- const sockets = [], dispatched = [];
385
- class Socket {
386
- static OPEN = 1;
387
- constructor(url) { this.url = url; this.readyState = 1; sockets.push(this); }
388
- close() {} send() {}
389
- }
390
- const r = vm.createContext({
391
- ws: null, wsConnected: false, sessionId: null, fileName: 'File', documentId: 'doc', BRIDGE: 'http://localhost:38451',
392
- WebSocket: Socket, setInterval: () => 1, clearInterval: () => {}, setTimeout: () => 1, clearTimeout: () => {},
393
- sendRuntimeCapabilities: () => {}, consecutiveErrors: 0, everConnected: false, retryTimer: null, polling: true,
394
- setStatus: () => {}, log: () => {}, currentPort: 38451, READ_OPS: [],
395
- startLongPoll: () => assert.fail('stale socket started poll'), dispatchToMain: req => dispatched.push(req),
396
- });
397
- vm.runInContext(source, r);
398
- r.connectWs(); assert.equal(sockets.length, 0);
399
- r.sessionId = 'tab-a'; r.connectWs(); const old = sockets[0];
400
- old.onopen(); r.connectWs(); const replacement = sockets[1]; replacement.onopen();
401
- old.onclose(); old.onmessage({ data: JSON.stringify({ id: 'old', operation: 'modify' }) });
402
- assert.equal(r.wsConnected, true);
403
- assert.equal(r.ws, replacement);
404
- assert.equal(dispatched.length, 0);
405
- replacement.onmessage({ data: JSON.stringify({ id: 'new', operation: 'modify' }) });
406
- assert.equal(dispatched[0].id, 'new');
407
- });
408
-
409
- test('tree reads preserve Regular font weight, collect Regular font tokens, and resolve textStyle', async () => {
410
- const figma = {
411
- getLocalPaintStylesAsync: async () => [{ id: 'S:paint-1', name: 'Brand/Primary' }],
412
- getLocalTextStylesAsync: async () => [{ id: 'S:text-1', name: 'Lato/18px/regular' }],
413
- getLocalEffectStylesAsync: async () => [],
414
- };
415
- const context = vm.createContext({ figma, handlers: {}, console, setTimeout, Map, Set, Uint8Array });
416
- for (const file of ['utils', 'svg-path-helpers', 'paint-and-effects', 'token-helpers', 'read-helpers', 'handlers-read-detail', 'handlers-read']) {
417
- vm.runInContext(readFileSync(`plugin-src/${file}.js`, 'utf8'), context, { filename: file });
418
- }
419
-
420
- const walkState = await context.makeWalkStateAsync();
421
- const tokenCollector = { colors: new Set(), fonts: new Set(), sizes: new Set() };
422
- const textNode = {
423
- id: '3139:247224',
424
- name: 'Header',
425
- type: 'TEXT',
426
- characters: 'Shipping address',
427
- fontSize: 18,
428
- fontName: { family: 'Lato', style: 'Regular' },
429
- textStyleId: 'S:text-1',
430
- visible: true,
431
- };
432
- const tree = context.extractDesignTree(textNode, 0, 15, 'full', true, tokenCollector, null, walkState);
433
- assert.equal(tree.fontWeight, 'Regular');
434
- assert.equal(tree.fontFamily, 'Lato');
435
- assert.equal(tree.fontSize, 18);
436
- assert.equal(tree.textStyleId, 'S:text-1');
437
- assert.equal(tree.textStyle, 'Lato/18px/regular');
438
- assert.ok(tokenCollector.fonts.has('Lato/Regular/18px'));
439
- });
423
+ const diff = plain(messages.filter(msg => msg.type === 'node-patch').at(-1));
424
+ assert.equal(diff.baseRevision, 0); assert.equal(diff.revision, 3);
425
+ assert.equal(diff.patches[0].values.width, 15.5); assert.equal(diff.patches[0].values.content, 'New');
426
+ assert.deepEqual(diff.patches[1], {kind:'delete',id:'deleted:1'});
427
+ assert.equal(r.nodeReadCursors.size, 0);
428
+ const next = { id: 'page:2', on: () => {}, off: () => {} };
429
+ r.figma.currentPage = next;
430
+ r.handlers.index_scan = async () => ({ pageId: 'page:1' });
431
+ const previousUpdates = messages.filter(msg => msg.type === 'index-update').length;
432
+ await r.publishIndex(true);
433
+ assert.equal(messages.filter(msg => msg.type === 'index-update').length, previousUpdates);
434
+ });
435
+
436
+
437
+ function taskRuntime() {
438
+ const page = { id: 'page', type: 'PAGE' };
439
+ const a = { id: 'a', name: 'A', type: 'FRAME', parent: page, children: [] };
440
+ const b = { id: 'b', name: 'B', type: 'FRAME', parent: page, children: [] };
441
+ const child = { id: 'a:child', type: 'TEXT', parent: a };
442
+ a.children.push(child);
443
+ const nodes = new Map([page, a, b, child].map(node => [node.id, node]));
444
+ const r = runtime();
445
+ vm.runInContext(readFileSync('plugin-src/task-scope.js', 'utf8'), r);
446
+ r.findNodeByIdAsync = async id => nodes.get(id);
447
+ return { r, nodes, a, b, page };
448
+ }
449
+
450
+ test('frame tasks reject outside/global writes and batch escape before mutations', async () => {
451
+ const { r, a, b } = taskRuntime();
452
+ await r.handlers.task_start({ taskId: 'task-a', frameId: a.id });
453
+ await r.handlers.task_start({ taskId: 'task-b', frameId: b.id });
454
+ await assert.rejects(r.handlers.task_start({ taskId: 'task-c', frameId: a.id }), /reserved/);
455
+ const create = await r.validateTaskOperation('create', { _taskId: 'task-a', type: 'TEXT', content: 'Hello' });
456
+ assert.equal(create.params.parentId, a.id);
457
+ const read = await r.validateTaskOperation('get_selection', { _taskId: 'task-a' });
458
+ assert.equal(read.operation, 'get_design');
459
+ assert.equal(read.params.id, a.id);
460
+ assert.equal((await r.validateTaskOperation('screenshot', { _taskId: 'task-a', keepViewport: false })).params.keepViewport, true);
461
+ await r.validateTaskOperation('modify', { _taskId: 'task-a', id: 'a:child', name: 'Updated' });
462
+ for (const [operation, params] of [
463
+ ['modify', { id: b.id }], ['create', { type: 'TEXT', parentId: b.id }],
464
+ ['delete', { id: a.id, force: true }], ['clone', { id: a.id }],
465
+ ['append', { parentId: b.id, childId: 'a:child' }],
466
+ ['set_selection', { nodeIds: [a.id] }], ['setPage', { id: 'page' }],
467
+ ['setVariableValue', { id: 'variable' }], ['createPaintStyle', { name: 'Global' }],
468
+ ['create', { type: 'COMPONENT' }],
469
+ ['batch', { operations: [{ operation: 'modify', params: { id: 'a:child' } }, { operation: 'delete', params: { id: b.id } }] }],
470
+ ]) {
471
+ await assert.rejects(r.validateTaskOperation(operation, { ...params, _taskId: 'task-a' }));
472
+ }
473
+ await assert.rejects(r.validateTaskOperation('modify', { id: 'a:child' }), /require taskId/);
474
+ await r.handlers.task_end({ taskId: 'task-a' });
475
+ await assert.rejects(r.validateTaskOperation('modify', { _taskId: 'task-a', id: 'a:child' }), /Unknown task/);
476
+ });
477
+
478
+ test('task roots cannot be shared masters or nested layout frames', async () => {
479
+ const { r, nodes, a } = taskRuntime();
480
+ const nested = { id: 'nested', type: 'FRAME', parent: a, children: [] };
481
+ nodes.set(nested.id, nested);
482
+ await assert.rejects(r.handlers.task_start({ taskId: 'nested-task', frameId: nested.id }), /independent FRAME/);
483
+ a.children.push({ id: 'master', type: 'COMPONENT', parent: a });
484
+ await assert.rejects(r.handlers.task_start({ taskId: 'master-task', frameId: a.id }), /shared component/);
485
+ });
486
+
487
+ test('plugin tab IDs differ for the same document and request replay does not write twice', async () => {
488
+ const messages = [];
489
+ function boot() {
490
+ const r = runtime({
491
+ fileKey: 'same-file', root: { name: 'File', getPluginData: () => 'same-file' },
492
+ currentPage: { id: 'page', selection: [], on: () => {} },
493
+ clientStorage: { getAsync: async () => null }, ui: { postMessage: msg => messages.push(msg) }, on: () => {},
494
+ });
495
+ r.setTimeout = () => 0; r.clearTimeout = () => {};
496
+ r.validateTaskOperation = async (operation, params) => ({ operation, params });
497
+ vm.runInContext(readFileSync('plugin-src/main.js', 'utf8'), r);
498
+ return r;
499
+ }
500
+ const first = boot(), second = boot();
501
+ assert.notEqual(first.currentSessionId, second.currentSessionId);
502
+ assert.equal(first.currentDocumentId, second.currentDocumentId);
503
+ let release;
504
+ const blocked = new Promise(resolve => { release = resolve; });
505
+ const entered = [];
506
+ first.handlers.modify = async params => { entered.push(params.id); if (params.id === 'first') await blocked; return params; };
507
+ const call1 = first.figma.ui.onmessage({ id: 'request-1', operation: 'modify', params: { id: 'first' } });
508
+ const replay = first.figma.ui.onmessage({ id: 'request-1', operation: 'modify', params: { id: 'first' } });
509
+ const call2 = first.figma.ui.onmessage({ id: 'request-2', operation: 'modify', params: { id: 'second' } });
510
+ await new Promise(resolve => setTimeout(resolve, 0));
511
+ assert.deepEqual(entered, ['first']);
512
+ release(); await Promise.all([call1, replay, call2]);
513
+ assert.deepEqual(entered, ['first', 'second']);
514
+ assert.equal(messages.filter(msg => msg.id === 'request-1' && msg.success).length, 2);
515
+ assert.ok(messages.filter(msg => msg.id).every(msg => msg.sessionId === first.currentSessionId));
516
+ });
517
+
518
+ test('old UI socket callbacks cannot clear or dispatch through a replacement socket', () => {
519
+ const html = readFileSync('plugin-runtime/ui.html', 'utf8');
520
+ const source = html.slice(html.indexOf(' function connectWs()'), html.indexOf(' async function startLongPoll()'));
521
+ const sockets = [], dispatched = [];
522
+ class Socket {
523
+ static OPEN = 1;
524
+ constructor(url) { this.url = url; this.readyState = 1; sockets.push(this); }
525
+ close() {} send() {}
526
+ }
527
+ const r = vm.createContext({
528
+ ws: null, wsConnected: false, sessionId: null, fileName: 'File', documentId: 'doc', BRIDGE: 'http://localhost:38451',
529
+ WebSocket: Socket, setInterval: () => 1, clearInterval: () => {}, setTimeout: () => 1, clearTimeout: () => {},
530
+ sendRuntimeCapabilities: () => {}, consecutiveErrors: 0, everConnected: false, retryTimer: null, polling: true,
531
+ setStatus: () => {}, log: () => {}, currentPort: 38451, READ_OPS: [],
532
+ startLongPoll: () => assert.fail('stale socket started poll'), dispatchToMain: req => dispatched.push(req),
533
+ });
534
+ vm.runInContext(source, r);
535
+ r.connectWs(); assert.equal(sockets.length, 0);
536
+ r.sessionId = 'tab-a'; r.connectWs(); const old = sockets[0];
537
+ old.onopen(); r.connectWs(); const replacement = sockets[1]; replacement.onopen();
538
+ old.onclose(); old.onmessage({ data: JSON.stringify({ id: 'old', operation: 'modify' }) });
539
+ assert.equal(r.wsConnected, true);
540
+ assert.equal(r.ws, replacement);
541
+ assert.equal(dispatched.length, 0);
542
+ replacement.onmessage({ data: JSON.stringify({ id: 'new', operation: 'modify' }) });
543
+ assert.equal(dispatched[0].id, 'new');
544
+ });
545
+
546
+ test('tree reads preserve Regular font weight, collect Regular font tokens, and resolve textStyle', async () => {
547
+ const figma = {
548
+ getLocalPaintStylesAsync: async () => [{ id: 'S:paint-1', name: 'Brand/Primary' }],
549
+ getLocalTextStylesAsync: async () => [{ id: 'S:text-1', name: 'Lato/18px/regular' }],
550
+ getLocalEffectStylesAsync: async () => [],
551
+ };
552
+ const context = vm.createContext({ figma, handlers: {}, console, setTimeout, Map, Set, Uint8Array });
553
+ for (const file of ['utils', 'svg-path-helpers', 'paint-and-effects', 'token-helpers', 'read-helpers', 'handlers-read-detail', 'handlers-read']) {
554
+ vm.runInContext(readFileSync(`plugin-src/${file}.js`, 'utf8'), context, { filename: file });
555
+ }
556
+
557
+ const walkState = await context.makeWalkStateAsync();
558
+ const tokenCollector = { colors: new Set(), fonts: new Set(), sizes: new Set() };
559
+ const textNode = {
560
+ id: '3139:247224',
561
+ name: 'Header',
562
+ type: 'TEXT',
563
+ characters: 'Shipping address',
564
+ fontSize: 18,
565
+ fontName: { family: 'Lato', style: 'Regular' },
566
+ textStyleId: 'S:text-1',
567
+ visible: true,
568
+ };
569
+ const tree = context.extractDesignTree(textNode, 0, 15, 'full', true, tokenCollector, null, walkState);
570
+ assert.equal(tree.fontWeight, 'Regular');
571
+ assert.equal(tree.fontFamily, 'Lato');
572
+ assert.equal(tree.fontSize, 18);
573
+ assert.equal(tree.textStyleId, 'S:text-1');
574
+ assert.equal(tree.textStyle, 'Lato/18px/regular');
575
+ assert.ok(tokenCollector.fonts.has('Lato/Regular/18px'));
576
+ });