figma-rust-mcp 3.2.8 → 3.2.9
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.
- package/README.md +53 -3
- package/package.json +4 -3
- package/scripts/build-plugin.js +1 -0
- package/scripts/test-bridge-sessions.mjs +155 -0
- package/scripts/test-plugin-tokens.mjs +213 -0
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Enables AI agents (Google Antigravity, Claude Code, Cursor, Windsurf, VS Code, Z
|
|
|
8
8
|
|
|
9
9
|
## ⚡ Highlights
|
|
10
10
|
|
|
11
|
-
- **
|
|
11
|
+
- **Lossless Rust Tree Compression**: Compact/minimal tree responses share identical style bundles when the complete JSON becomes smaller. Node IDs, hierarchy, text, geometry and state differences remain intact. Full detail bypasses Rust compression; no heuristic merging of screens or repeated items.
|
|
12
12
|
- **Pure Rust Native Performance**: Starts in `< 1ms`, uses `~3MB RAM`, zero GC pauses.
|
|
13
13
|
- **In-Memory Deep Indexing (`figma_index`)**: Queries layers, components, styles, and tokens in `< 1ms` without slow canvas roundtrips.
|
|
14
14
|
- **Binary IPC & Chunk Streaming**: Powered by **MessagePack** (`rmp-serde`) and progressive subtree chunking for instant transfers of massive design files.
|
|
@@ -256,8 +256,21 @@ a restart with the new binary to load the updated runtime.
|
|
|
256
256
|
|
|
257
257
|
|
|
258
258
|
### 5. `figma_get_selection`
|
|
259
|
-
|
|
260
|
-
- **`detail`**: `"compact"` (
|
|
259
|
+
Inspection of currently selected layers/frames, with shared-style compression when it reduces response size.
|
|
260
|
+
- **`detail`**: `"compact"` (default), `"minimal"`, `"full"`.
|
|
261
|
+
|
|
262
|
+
Compact/minimal `get_selection` and `get_design` responses may contain a root
|
|
263
|
+
`_compression` object with `version: 1` and a `styles` dictionary. For each node
|
|
264
|
+
with `styleRef`, merge `_compression.styles[node.styleRef]` into that node and
|
|
265
|
+
remove `styleRef`; remove root `_compression` to reconstruct the plugin payload
|
|
266
|
+
exactly. Style references are dictionary lookups, not inherited CSS. Payloads
|
|
267
|
+
with reserved-field collisions or no net byte savings stay inline. `detail: "full"` bypasses this Rust transformation and queries the plugin rather than a
|
|
268
|
+
compact selection cache. Plugin detail, visibility, precision, depth and node
|
|
269
|
+
budgets still determine the source payload; compression cannot restore fields
|
|
270
|
+
omitted there. Code generation continues to consume inline styles.
|
|
271
|
+
|
|
272
|
+
No token-reduction or AI-latency percentage is claimed without a tokenizer and
|
|
273
|
+
end-to-end benchmark on real designs.
|
|
261
274
|
- **`depth`**: Tree depth limit or `"full"`.
|
|
262
275
|
|
|
263
276
|
### 6. `figma_export_asset`
|
|
@@ -331,6 +344,43 @@ Scans your local codebase directories (`src/components`, `components/ui`) to dis
|
|
|
331
344
|
|
|
332
345
|
---
|
|
333
346
|
|
|
347
|
+
### Multiple tabs and frame tasks
|
|
348
|
+
|
|
349
|
+
Each plugin runtime has its own `sessionId`; tabs of the same file share a
|
|
350
|
+
`documentId`. Use `figma_status` to discover tabs. With multiple connected tabs,
|
|
351
|
+
pass an exact `sessionId`; the server rejects ambiguous routing.
|
|
352
|
+
|
|
353
|
+
For multiple agents editing one file, call `figma_task` with `action: "start"`,
|
|
354
|
+
`sessionId` and `frameId`, then pass the returned `taskId` on subsequent tools.
|
|
355
|
+
Each task reserves a separate FRAME directly under a PAGE or SECTION, without
|
|
356
|
+
shared component definitions. Writes stay inside that frame; global styles,
|
|
357
|
+
variables, page and selection changes are blocked. A frame cannot be reserved
|
|
358
|
+
twice, including across tabs of the same document. Release it with `figma_task`
|
|
359
|
+
using `action: "end"` and `taskId`; restart tasks after a plugin reload.
|
|
360
|
+
|
|
361
|
+
Tools on one tab run sequentially, including every awaited operation in a
|
|
362
|
+
`figma_write` call. Different tabs run independently. `npm run test:sessions`
|
|
363
|
+
(Node >=22) checks real WebSocket/HTTP transport with a simulated plugin canvas;
|
|
364
|
+
it does not replace validation in Figma Desktop.
|
|
365
|
+
|
|
366
|
+
### Startup indexing
|
|
367
|
+
|
|
368
|
+
Opening the bridge indexes top-level nodes on the active page and local tokens.
|
|
369
|
+
It does not preload every page or scan the document-wide component catalogue.
|
|
370
|
+
`figma_index` status exposes `components_indexed: false` until a full reindex;
|
|
371
|
+
this means deferred, not an empty catalogue. Live component queries populate
|
|
372
|
+
the Rust cache for subsequent reads while the index remains valid. Component reads/searches and rules
|
|
373
|
+
that require the complete catalogue fall back to a live query.
|
|
374
|
+
|
|
375
|
+
The plugin listens to active-page `nodechange` and global `stylechange` events,
|
|
376
|
+
and rebuilds the lightweight index when the active page changes. It does not
|
|
377
|
+
subscribe to document-wide node changes by preloading the whole file. Explicit
|
|
378
|
+
reindexing still includes all local components; discovery loads pages one at a
|
|
379
|
+
time and yields after at most 100 nodes or an 8ms traversal slice. Figma's native
|
|
380
|
+
page load itself cannot be interrupted by the plugin. Concurrent identical
|
|
381
|
+
index requests share one scan, and results from a previously active page are
|
|
382
|
+
not published as the current index.
|
|
383
|
+
|
|
334
384
|
## 💻 Development & Testing
|
|
335
385
|
|
|
336
386
|
```bash
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "figma-rust-mcp",
|
|
3
|
-
"version": "3.2.
|
|
4
|
-
"description": "Claude Code to Figma
|
|
3
|
+
"version": "3.2.9",
|
|
4
|
+
"description": "Claude Code to Figma \u2014 Bidirectional MCP bridge. AI draws UI on Figma canvas, reads designs back. Works with Claude, Cursor, VS Code, Windsurf, Antigravity. No API key needed.",
|
|
5
5
|
"mcpName": "io.github.BuiHung1612/figma-mcp",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"build:plugin": "node scripts/build-plugin.js",
|
|
21
21
|
"test": "./scripts/test-rust-mcp.sh",
|
|
22
22
|
"test:tokens": "node --test scripts/test-plugin-tokens.mjs && cargo test",
|
|
23
|
-
"dev": "cargo run"
|
|
23
|
+
"dev": "cargo run",
|
|
24
|
+
"test:sessions": "cargo build && node --test scripts/test-bridge-sessions.mjs"
|
|
24
25
|
},
|
|
25
26
|
"engines": {
|
|
26
27
|
"node": ">=18"
|
package/scripts/build-plugin.js
CHANGED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { createServer } from 'node:net';
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import vm from 'node:vm';
|
|
8
|
+
|
|
9
|
+
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
10
|
+
const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return { promise, resolve }; };
|
|
11
|
+
async function until(predicate) {
|
|
12
|
+
const deadline = Date.now() + 5000;
|
|
13
|
+
while (!await predicate()) { if (Date.now() > deadline) throw new Error('Timed out'); await delay(10); }
|
|
14
|
+
}
|
|
15
|
+
const decode = result => JSON.parse(result.content[0].text);
|
|
16
|
+
const ok = result => { assert.notEqual(result.isError, true, result.content?.[0]?.text); return result; };
|
|
17
|
+
|
|
18
|
+
// Uses only Node built-ins (Node >=22 supplies WebSocket) and the actual Rust
|
|
19
|
+
// daemon. The canvas is simulated; this verifies transport/agent isolation.
|
|
20
|
+
test('multiple tabs, reconnect ownership and independent frame tasks across MCP clients', { timeout: 20000 }, async t => {
|
|
21
|
+
assert.equal(typeof WebSocket, 'function', 'This integration check requires Node >=22');
|
|
22
|
+
const reservation = createServer();
|
|
23
|
+
await new Promise(resolve => reservation.listen(0, '127.0.0.1', resolve));
|
|
24
|
+
const port = reservation.address().port;
|
|
25
|
+
await new Promise(resolve => reservation.close(resolve));
|
|
26
|
+
const binary = process.env.FIGMA_TEST_BINARY || 'target/debug/figma-mcp';
|
|
27
|
+
const server = spawn(binary, ['--server', '--port', String(port)], { stdio: ['ignore', 'ignore', 'pipe'], env: { ...process.env, FIGMA_MCP_PORT: String(port) } });
|
|
28
|
+
let stderr = ''; server.stderr.on('data', bytes => { stderr = (stderr + bytes).slice(-4000); });
|
|
29
|
+
const processes = [server], sockets = [], latest = new Map(), actions = [];
|
|
30
|
+
t.after(() => { for (const socket of sockets) socket.close(); for (const process of processes) process.kill(); });
|
|
31
|
+
const base = `http://127.0.0.1:${port}`;
|
|
32
|
+
await until(async () => { try { return (await fetch(`${base}/health`)).ok; } catch { assert.equal(server.exitCode, null, stderr); return false; } });
|
|
33
|
+
let rpcId = 0;
|
|
34
|
+
async function rpc(name, args) {
|
|
35
|
+
const response = await fetch(`${base}/mcp`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
36
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: ++rpcId, method: 'tools/call', params: { name, arguments: args } }) });
|
|
37
|
+
return (await response.json()).result;
|
|
38
|
+
}
|
|
39
|
+
function agent() {
|
|
40
|
+
const process = spawn(binary, ['--stdio', '--port', String(port)], { env: { ...globalThis.process.env, FIGMA_MCP_PORT: String(port) } });
|
|
41
|
+
processes.push(process); process.stderr.resume();
|
|
42
|
+
let id = 0;
|
|
43
|
+
const pending = new Map();
|
|
44
|
+
createInterface({ input: process.stdout }).on('line', line => {
|
|
45
|
+
const response = JSON.parse(line); const resolve = pending.get(response.id);
|
|
46
|
+
if (resolve) { pending.delete(response.id); resolve(response.result); }
|
|
47
|
+
});
|
|
48
|
+
return (name, args) => new Promise(resolve => {
|
|
49
|
+
pending.set(++id, resolve);
|
|
50
|
+
process.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } }) + '\n');
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const paused = deferred(), resume = deferred();
|
|
54
|
+
async function plugin(sid, existing) {
|
|
55
|
+
const state = existing || {};
|
|
56
|
+
if (!state.runtime) {
|
|
57
|
+
const page = { id: 'page', type: 'PAGE' };
|
|
58
|
+
const nodes = new Map();
|
|
59
|
+
for (const suffix of ['a', 'b', 'c']) {
|
|
60
|
+
const root = { id: `frame-${suffix}`, name: suffix, type: 'FRAME', parent: page, children: [] };
|
|
61
|
+
const child = { id: `child-${suffix}`, type: 'TEXT', parent: root }; root.children.push(child);
|
|
62
|
+
nodes.set(root.id, root); nodes.set(child.id, child);
|
|
63
|
+
}
|
|
64
|
+
state.runtime = vm.createContext({ handlers: {}, findNodeByIdAsync: async id => nodes.get(id), yieldToUI: async () => {} });
|
|
65
|
+
vm.runInContext(readFileSync('plugin-src/task-scope.js', 'utf8'), state.runtime);
|
|
66
|
+
state.runtime.handlers.get_design = async params => ({ id: params.id, type: 'FRAME', name: params.id, tab: sid });
|
|
67
|
+
state.runtime.handlers.status = async () => ({ tab: sid });
|
|
68
|
+
state.runtime.handlers.modify = async params => {
|
|
69
|
+
actions.push(params.note);
|
|
70
|
+
if (params.note === 'paused') { paused.resolve(); await resume.promise; }
|
|
71
|
+
return { id: params.id, note: params.note };
|
|
72
|
+
};
|
|
73
|
+
state.tail = Promise.resolve();
|
|
74
|
+
}
|
|
75
|
+
const socket = new WebSocket(`ws://127.0.0.1:${port}/ws?sessionId=${sid}&fileName=SameFile&documentId=same-document`);
|
|
76
|
+
sockets.push(socket); state.socket = socket; latest.set(sid, state);
|
|
77
|
+
socket.addEventListener('message', event => {
|
|
78
|
+
const message = JSON.parse(event.data);
|
|
79
|
+
if (message.type === 'server-hello') {
|
|
80
|
+
for (const id of state.runtime.frameTasks.keys()) if (!message.activeTaskIds.includes(id)) state.runtime.frameTasks.delete(id);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!message.operation) return;
|
|
84
|
+
if (state.ignoreNext) { state.ignoreNext = false; state.ignored.resolve(message); return; }
|
|
85
|
+
socket.send(JSON.stringify({ type: 'ack', id: message.id }));
|
|
86
|
+
state.tail = state.tail.then(async () => {
|
|
87
|
+
try {
|
|
88
|
+
const checked = await state.runtime.validateTaskOperation(message.operation, message.params);
|
|
89
|
+
const data = await state.runtime.handlers[checked.operation](checked.params);
|
|
90
|
+
latest.get(sid).socket.send(JSON.stringify({ id: message.id, sessionId: sid, success: true, data }));
|
|
91
|
+
} catch (error) { latest.get(sid).socket.send(JSON.stringify({ id: message.id, sessionId: sid, success: false, error: error.message })); }
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
await new Promise((resolve, reject) => { socket.addEventListener('open', resolve, { once: true }); socket.addEventListener('error', reject, { once: true }); });
|
|
95
|
+
socket.send(JSON.stringify({ type: 'runtime-capabilities', runtimeVersion: 'test', protocolVersion: 2, documentId: 'same-document', operations: Object.keys(state.runtime.handlers) }));
|
|
96
|
+
return state;
|
|
97
|
+
}
|
|
98
|
+
const tabA = await plugin('tab-a'), tabB = await plugin('tab-b');
|
|
99
|
+
const first = agent(), second = agent();
|
|
100
|
+
const ambiguous = await rpc('figma_read', { operation: 'get_design', nodeId: 'frame-a' });
|
|
101
|
+
assert.equal(ambiguous.isError, true); assert.match(ambiguous.content[0].text, /Ambiguous/);
|
|
102
|
+
const missing = await rpc('figma_read', { operation: 'get_design', sessionId: 'missing' });
|
|
103
|
+
assert.equal(missing.isError, true);
|
|
104
|
+
const taskA = decode(ok(await first('figma_task', { action: 'start', sessionId: 'tab-a', frameId: 'frame-a' })));
|
|
105
|
+
const collision = await second('figma_task', { action: 'start', sessionId: 'tab-b', frameId: 'frame-a' });
|
|
106
|
+
assert.equal(collision.isError, true); assert.match(collision.content[0].text, /reserved/);
|
|
107
|
+
const taskB = decode(ok(await second('figma_task', { action: 'start', sessionId: 'tab-a', frameId: 'frame-b' })));
|
|
108
|
+
const taskC = decode(ok(await rpc('figma_task', { action: 'start', sessionId: 'tab-b', frameId: 'frame-c' })));
|
|
109
|
+
assert.equal(decode(ok(await first('figma_get_selection', { taskId: taskA.taskId }))).id, 'frame-a');
|
|
110
|
+
assert.equal(decode(ok(await second('figma_get_selection', { taskId: taskB.taskId }))).id, 'frame-b');
|
|
111
|
+
const writeA = first('figma_write', { taskId: taskA.taskId, code: 'await figma.modify({id:"child-a",note:"paused"}); await figma.modify({id:"child-a",note:"second"});' });
|
|
112
|
+
await paused.promise;
|
|
113
|
+
const writeB = second('figma_write', { taskId: taskB.taskId, code: 'await figma.modify({id:"child-b",note:"other-task"});' });
|
|
114
|
+
ok(await rpc('figma_write', { taskId: taskC.taskId, code: 'await figma.modify({id:"child-c",note:"other-tab"});' }));
|
|
115
|
+
assert.deepEqual(actions, ['paused', 'other-tab']);
|
|
116
|
+
resume.resolve(); ok(await writeA); ok(await writeB);
|
|
117
|
+
assert.deepEqual(actions, ['paused', 'other-tab', 'second', 'other-task']);
|
|
118
|
+
const returned = ok(await first('figma_write', { taskId: taskA.taskId,
|
|
119
|
+
code: 'await figma.modify({id:"child-a",note:"settled"}); return "done";' }));
|
|
120
|
+
assert.equal(returned.content[0].text, 'Result: "done"');
|
|
121
|
+
const rejected = await first('figma_write', { taskId: taskA.taskId,
|
|
122
|
+
code: 'await figma.modify({id:"child-a",note:"before-error"}); throw new Error("after-await");' });
|
|
123
|
+
assert.equal(rejected.isError, true); assert.match(rejected.content[0].text, /after-await/);
|
|
124
|
+
const outside = await first('figma_write', { taskId: taskA.taskId, code: 'await figma.modify({id:"child-b",note:"escape"});' });
|
|
125
|
+
assert.equal(outside.isError, true); assert.ok(!actions.includes('escape'));
|
|
126
|
+
const rawWrite = await fetch(`${base}/exec?sessionId=tab-b`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ operation: 'modify', params: { id: 'child-a' } }) });
|
|
127
|
+
assert.equal((await rawWrite.json()).success, false);
|
|
128
|
+
|
|
129
|
+
// A replacement socket recovers an op that the old socket never ACKed.
|
|
130
|
+
tabA.ignoreNext = true; tabA.ignored = deferred(); const oldSocket = tabA.socket;
|
|
131
|
+
const recovering = rpc('figma_read', { operation: 'get_design', nodeId: 'frame-a', sessionId: 'tab-a' });
|
|
132
|
+
const ignored = await tabA.ignored.promise;
|
|
133
|
+
await plugin('tab-a', tabA); oldSocket.close();
|
|
134
|
+
assert.equal(decode(ok(await recovering)).tab, 'tab-a');
|
|
135
|
+
await delay(30);
|
|
136
|
+
assert.equal(decode(ok(await rpc('figma_read', { operation: 'get_design', nodeId: 'frame-b', sessionId: 'tab-a' }))).id, 'frame-b');
|
|
137
|
+
// Cross-tab response spoofing cannot consume another tab's pending request.
|
|
138
|
+
tabA.ignoreNext = true; tabA.ignored = deferred();
|
|
139
|
+
const routed = rpc('figma_read', { operation: 'get_design', nodeId: 'frame-a', sessionId: 'tab-a' });
|
|
140
|
+
const waiting = await tabA.ignored.promise;
|
|
141
|
+
tabB.socket.send(JSON.stringify({ id: waiting.id, success: true, data: { hijacked: true } }));
|
|
142
|
+
const wrongHttp = await fetch(`${base}/response`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: waiting.id, sessionId: 'tab-b', success: true, data: { hijacked: true } }) });
|
|
143
|
+
assert.equal((await wrongHttp.json()).ok, false);
|
|
144
|
+
tabA.socket.send(JSON.stringify({ id: waiting.id, sessionId: 'tab-a', success: true, data: { id: 'frame-a', tab: 'tab-a' } }));
|
|
145
|
+
assert.equal(decode(ok(await routed)).tab, 'tab-a');
|
|
146
|
+
assert.ok(ignored.id);
|
|
147
|
+
ok(await first('figma_task', { action: 'end', taskId: taskA.taskId }));
|
|
148
|
+
ok(await second('figma_task', { action: 'end', taskId: taskB.taskId }));
|
|
149
|
+
tabB.socket.close();
|
|
150
|
+
await until(async () => !(await (await fetch(`${base}/sessions`)).json()).sessions.find(session => session.id === 'tab-b').connected);
|
|
151
|
+
assert.equal(decode(ok(await rpc('figma_task', { action: 'end', taskId: taskC.taskId }))).pluginDisconnected, true);
|
|
152
|
+
await plugin('tab-b', tabB);
|
|
153
|
+
const restarted = decode(ok(await rpc('figma_task', { action: 'start', sessionId: 'tab-b', frameId: 'frame-c' })));
|
|
154
|
+
ok(await rpc('figma_task', { action: 'end', taskId: restarted.taskId }));
|
|
155
|
+
});
|
|
@@ -145,3 +145,216 @@ test('build rejects wrong release tags and checked-in bundle matches source', ()
|
|
|
145
145
|
assert.notEqual(rejected.status, 0);
|
|
146
146
|
assert.match(rejected.stderr, /does not match build version/);
|
|
147
147
|
});
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
test('tree reads preserve same-size screens and all repeated siblings', () => {
|
|
151
|
+
const r = runtime();
|
|
152
|
+
vm.runInContext(readFileSync('plugin-src/read-helpers.js', 'utf8'), r);
|
|
153
|
+
const screens = Array.from({ length: 12 }, (_, i) => ({
|
|
154
|
+
id: `screen:${i}`, name: `Screen ${i}`, type: 'FRAME', width: 402, height: 800,
|
|
155
|
+
layoutMode: 'VERTICAL', itemSpacing: i,
|
|
156
|
+
children: [{ id: `item:${i}`, name: `Item ${i}`, type: 'FRAME', width: 100, height: 40 }],
|
|
157
|
+
}));
|
|
158
|
+
const root = { id: 'root', name: 'Screens', type: 'SECTION', children: screens };
|
|
159
|
+
for (const detail of ['compact', 'full']) {
|
|
160
|
+
const tree = plain(r.extractDesignTree(root, 0, 10, detail, true));
|
|
161
|
+
assert.equal(tree.children.length, 12);
|
|
162
|
+
assert.deepEqual(tree.children.map(node => node.id), screens.map(node => node.id));
|
|
163
|
+
assert.equal(tree.children[11].layout.itemSpacing, 11);
|
|
164
|
+
assert.equal(tree.children[11].children[0].id, 'item:11');
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
test('startup index defers document-wide components and coalesces duplicate scans', async () => {
|
|
170
|
+
let componentQueries = 0, styleQueries = 0;
|
|
171
|
+
const page = { id: 'page:1', children: [{ id: 'frame:1', name: 'Frame', type: 'FRAME' }] };
|
|
172
|
+
const r = runtime({ currentPage: page, root: { name: 'File', getPluginData: () => '' } });
|
|
173
|
+
r.handlers.get_styles = async () => { styleQueries++; return { schemaVersion: 2 }; };
|
|
174
|
+
r.handlers.get_variables = async () => ({ schemaVersion: 2 });
|
|
175
|
+
r.handlers.get_local_components = async () => { componentQueries++; return { components: [], componentSets: [] }; };
|
|
176
|
+
const [a, b] = await Promise.all([
|
|
177
|
+
r.handlers.index_scan({ deferComponents: true }),
|
|
178
|
+
r.handlers.index_scan({ deferComponents: true }),
|
|
179
|
+
]);
|
|
180
|
+
assert.equal(componentQueries, 0);
|
|
181
|
+
assert.equal(styleQueries, 1);
|
|
182
|
+
assert.equal(a, b);
|
|
183
|
+
assert.equal(a.pageId, page.id);
|
|
184
|
+
assert.equal(a.componentsDeferred, true);
|
|
185
|
+
assert.equal(a.pageNodes[0].id, 'frame:1');
|
|
186
|
+
const [light, full] = await Promise.all([
|
|
187
|
+
r.handlers.index_scan({ deferComponents: true }), r.handlers.index_scan({}),
|
|
188
|
+
]);
|
|
189
|
+
assert.equal(light.components, null);
|
|
190
|
+
assert.equal(full.componentsDeferred, false);
|
|
191
|
+
assert.equal(componentQueries, 1);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('component catalogue walks pages cooperatively without bulk loading or root searches', async () => {
|
|
195
|
+
let loaded = 0, yields = 0, reads = 0, lastYieldReads = 0, maxSliceReads = 0;
|
|
196
|
+
const page = { type: 'PAGE', name: 'Components', loadAsync: async () => { loaded++; } };
|
|
197
|
+
page.children = Array.from({ length: 1000 }, (_, i) => ({
|
|
198
|
+
id: `c:${i}`, name: `Component ${i}`, parent: page, width: 10, height: 10,
|
|
199
|
+
get type() { reads++; return i % 2 ? 'COMPONENT_SET' : 'COMPONENT'; },
|
|
200
|
+
}));
|
|
201
|
+
const r = runtime({
|
|
202
|
+
root: { children: [page], findAllWithCriteria: () => assert.fail('root-wide search') },
|
|
203
|
+
loadAllPagesAsync: () => assert.fail('bulk page load'),
|
|
204
|
+
});
|
|
205
|
+
r.yieldToUI = async () => {
|
|
206
|
+
yields++; maxSliceReads = Math.max(maxSliceReads, reads - lastYieldReads); lastYieldReads = reads;
|
|
207
|
+
};
|
|
208
|
+
const result = await r.handlers.get_local_components();
|
|
209
|
+
assert.equal(loaded, 1);
|
|
210
|
+
assert.equal(result.components.length, 500);
|
|
211
|
+
assert.equal(result.componentSets.length, 500);
|
|
212
|
+
assert.ok(yields >= 10, 'large traversal must yield before finishing');
|
|
213
|
+
assert.ok(maxSliceReads <= 200, 'no more than 100 nodes between traversal yields');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('startup subscribes to active-page changes without loading the whole file', async () => {
|
|
217
|
+
const events = new Map(), timers = [], messages = [];
|
|
218
|
+
let pageListener;
|
|
219
|
+
const page = {
|
|
220
|
+
id: 'page:1', children: [], selection: [],
|
|
221
|
+
on: (name, callback) => { assert.equal(name, 'nodechange'); pageListener = callback; },
|
|
222
|
+
off: () => {},
|
|
223
|
+
};
|
|
224
|
+
const r = runtime({
|
|
225
|
+
currentPage: page, root: { name: 'File', getPluginData: () => 'session' },
|
|
226
|
+
clientStorage: { getAsync: async () => null }, ui: { postMessage: msg => messages.push(msg) },
|
|
227
|
+
on: (name, callback) => { events.set(name, callback); },
|
|
228
|
+
loadAllPagesAsync: () => assert.fail('startup bulk page load'),
|
|
229
|
+
});
|
|
230
|
+
r.setTimeout = (callback, delay) => { timers.push({ callback, delay }); return timers.length; };
|
|
231
|
+
r.clearTimeout = () => {};
|
|
232
|
+
vm.runInContext(readFileSync('plugin-src/main.js', 'utf8'), r);
|
|
233
|
+
assert.ok(pageListener);
|
|
234
|
+
assert.ok(events.has('stylechange'));
|
|
235
|
+
assert.ok(!events.has('documentchange'));
|
|
236
|
+
let scanOptions;
|
|
237
|
+
r.handlers.index_scan = async options => { scanOptions = options; return { pageId: page.id }; };
|
|
238
|
+
await timers.find(timer => timer.delay === 1800).callback();
|
|
239
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
240
|
+
assert.equal(scanOptions.deferComponents, true);
|
|
241
|
+
pageListener({ nodeChanges: [{ type: 'PROPERTY_CHANGE', node: { id: 'changed:1' } }] });
|
|
242
|
+
assert.ok(r.pendingChangedNodeIds.has('changed:1'));
|
|
243
|
+
assert.ok(messages.some(msg => msg.type === 'document-change'));
|
|
244
|
+
const next = { id: 'page:2', on: () => {}, off: () => {} };
|
|
245
|
+
r.figma.currentPage = next;
|
|
246
|
+
r.handlers.index_scan = async () => ({ pageId: 'page:1' });
|
|
247
|
+
const previousUpdates = messages.filter(msg => msg.type === 'index-update').length;
|
|
248
|
+
await r.publishIndex(true);
|
|
249
|
+
assert.equal(messages.filter(msg => msg.type === 'index-update').length, previousUpdates);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
function taskRuntime() {
|
|
254
|
+
const page = { id: 'page', type: 'PAGE' };
|
|
255
|
+
const a = { id: 'a', name: 'A', type: 'FRAME', parent: page, children: [] };
|
|
256
|
+
const b = { id: 'b', name: 'B', type: 'FRAME', parent: page, children: [] };
|
|
257
|
+
const child = { id: 'a:child', type: 'TEXT', parent: a };
|
|
258
|
+
a.children.push(child);
|
|
259
|
+
const nodes = new Map([page, a, b, child].map(node => [node.id, node]));
|
|
260
|
+
const r = runtime();
|
|
261
|
+
vm.runInContext(readFileSync('plugin-src/task-scope.js', 'utf8'), r);
|
|
262
|
+
r.findNodeByIdAsync = async id => nodes.get(id);
|
|
263
|
+
return { r, nodes, a, b, page };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
test('frame tasks reject outside/global writes and batch escape before mutations', async () => {
|
|
267
|
+
const { r, a, b } = taskRuntime();
|
|
268
|
+
await r.handlers.task_start({ taskId: 'task-a', frameId: a.id });
|
|
269
|
+
await r.handlers.task_start({ taskId: 'task-b', frameId: b.id });
|
|
270
|
+
await assert.rejects(r.handlers.task_start({ taskId: 'task-c', frameId: a.id }), /reserved/);
|
|
271
|
+
const create = await r.validateTaskOperation('create', { _taskId: 'task-a', type: 'TEXT', content: 'Hello' });
|
|
272
|
+
assert.equal(create.params.parentId, a.id);
|
|
273
|
+
const read = await r.validateTaskOperation('get_selection', { _taskId: 'task-a' });
|
|
274
|
+
assert.equal(read.operation, 'get_design');
|
|
275
|
+
assert.equal(read.params.id, a.id);
|
|
276
|
+
assert.equal((await r.validateTaskOperation('screenshot', { _taskId: 'task-a', keepViewport: false })).params.keepViewport, true);
|
|
277
|
+
await r.validateTaskOperation('modify', { _taskId: 'task-a', id: 'a:child', name: 'Updated' });
|
|
278
|
+
for (const [operation, params] of [
|
|
279
|
+
['modify', { id: b.id }], ['create', { type: 'TEXT', parentId: b.id }],
|
|
280
|
+
['delete', { id: a.id, force: true }], ['clone', { id: a.id }],
|
|
281
|
+
['append', { parentId: b.id, childId: 'a:child' }],
|
|
282
|
+
['set_selection', { nodeIds: [a.id] }], ['setPage', { id: 'page' }],
|
|
283
|
+
['setVariableValue', { id: 'variable' }], ['createPaintStyle', { name: 'Global' }],
|
|
284
|
+
['create', { type: 'COMPONENT' }],
|
|
285
|
+
['batch', { operations: [{ operation: 'modify', params: { id: 'a:child' } }, { operation: 'delete', params: { id: b.id } }] }],
|
|
286
|
+
]) {
|
|
287
|
+
await assert.rejects(r.validateTaskOperation(operation, { ...params, _taskId: 'task-a' }));
|
|
288
|
+
}
|
|
289
|
+
await assert.rejects(r.validateTaskOperation('modify', { id: 'a:child' }), /require taskId/);
|
|
290
|
+
await r.handlers.task_end({ taskId: 'task-a' });
|
|
291
|
+
await assert.rejects(r.validateTaskOperation('modify', { _taskId: 'task-a', id: 'a:child' }), /Unknown task/);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('task roots cannot be shared masters or nested layout frames', async () => {
|
|
295
|
+
const { r, nodes, a } = taskRuntime();
|
|
296
|
+
const nested = { id: 'nested', type: 'FRAME', parent: a, children: [] };
|
|
297
|
+
nodes.set(nested.id, nested);
|
|
298
|
+
await assert.rejects(r.handlers.task_start({ taskId: 'nested-task', frameId: nested.id }), /independent FRAME/);
|
|
299
|
+
a.children.push({ id: 'master', type: 'COMPONENT', parent: a });
|
|
300
|
+
await assert.rejects(r.handlers.task_start({ taskId: 'master-task', frameId: a.id }), /shared component/);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('plugin tab IDs differ for the same document and request replay does not write twice', async () => {
|
|
304
|
+
const messages = [];
|
|
305
|
+
function boot() {
|
|
306
|
+
const r = runtime({
|
|
307
|
+
fileKey: 'same-file', root: { name: 'File', getPluginData: () => 'same-file' },
|
|
308
|
+
currentPage: { id: 'page', selection: [], on: () => {} },
|
|
309
|
+
clientStorage: { getAsync: async () => null }, ui: { postMessage: msg => messages.push(msg) }, on: () => {},
|
|
310
|
+
});
|
|
311
|
+
r.setTimeout = () => 0; r.clearTimeout = () => {};
|
|
312
|
+
r.validateTaskOperation = async (operation, params) => ({ operation, params });
|
|
313
|
+
vm.runInContext(readFileSync('plugin-src/main.js', 'utf8'), r);
|
|
314
|
+
return r;
|
|
315
|
+
}
|
|
316
|
+
const first = boot(), second = boot();
|
|
317
|
+
assert.notEqual(first.currentSessionId, second.currentSessionId);
|
|
318
|
+
assert.equal(first.currentDocumentId, second.currentDocumentId);
|
|
319
|
+
let release;
|
|
320
|
+
const blocked = new Promise(resolve => { release = resolve; });
|
|
321
|
+
const entered = [];
|
|
322
|
+
first.handlers.modify = async params => { entered.push(params.id); if (params.id === 'first') await blocked; return params; };
|
|
323
|
+
const call1 = first.figma.ui.onmessage({ id: 'request-1', operation: 'modify', params: { id: 'first' } });
|
|
324
|
+
const replay = first.figma.ui.onmessage({ id: 'request-1', operation: 'modify', params: { id: 'first' } });
|
|
325
|
+
const call2 = first.figma.ui.onmessage({ id: 'request-2', operation: 'modify', params: { id: 'second' } });
|
|
326
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
327
|
+
assert.deepEqual(entered, ['first']);
|
|
328
|
+
release(); await Promise.all([call1, replay, call2]);
|
|
329
|
+
assert.deepEqual(entered, ['first', 'second']);
|
|
330
|
+
assert.equal(messages.filter(msg => msg.id === 'request-1' && msg.success).length, 2);
|
|
331
|
+
assert.ok(messages.filter(msg => msg.id).every(msg => msg.sessionId === first.currentSessionId));
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test('old UI socket callbacks cannot clear or dispatch through a replacement socket', () => {
|
|
335
|
+
const html = readFileSync('plugin-runtime/ui.html', 'utf8');
|
|
336
|
+
const source = html.slice(html.indexOf(' function connectWs()'), html.indexOf(' async function startLongPoll()'));
|
|
337
|
+
const sockets = [], dispatched = [];
|
|
338
|
+
class Socket {
|
|
339
|
+
static OPEN = 1;
|
|
340
|
+
constructor(url) { this.url = url; this.readyState = 1; sockets.push(this); }
|
|
341
|
+
close() {} send() {}
|
|
342
|
+
}
|
|
343
|
+
const r = vm.createContext({
|
|
344
|
+
ws: null, wsConnected: false, sessionId: null, fileName: 'File', documentId: 'doc', BRIDGE: 'http://localhost:38451',
|
|
345
|
+
WebSocket: Socket, setInterval: () => 1, clearInterval: () => {}, setTimeout: () => 1, clearTimeout: () => {},
|
|
346
|
+
sendRuntimeCapabilities: () => {}, consecutiveErrors: 0, everConnected: false, retryTimer: null, polling: true,
|
|
347
|
+
setStatus: () => {}, log: () => {}, currentPort: 38451, READ_OPS: [],
|
|
348
|
+
startLongPoll: () => assert.fail('stale socket started poll'), dispatchToMain: req => dispatched.push(req),
|
|
349
|
+
});
|
|
350
|
+
vm.runInContext(source, r);
|
|
351
|
+
r.connectWs(); assert.equal(sockets.length, 0);
|
|
352
|
+
r.sessionId = 'tab-a'; r.connectWs(); const old = sockets[0];
|
|
353
|
+
old.onopen(); r.connectWs(); const replacement = sockets[1]; replacement.onopen();
|
|
354
|
+
old.onclose(); old.onmessage({ data: JSON.stringify({ id: 'old', operation: 'modify' }) });
|
|
355
|
+
assert.equal(r.wsConnected, true);
|
|
356
|
+
assert.equal(r.ws, replacement);
|
|
357
|
+
assert.equal(dispatched.length, 0);
|
|
358
|
+
replacement.onmessage({ data: JSON.stringify({ id: 'new', operation: 'modify' }) });
|
|
359
|
+
assert.equal(dispatched[0].id, 'new');
|
|
360
|
+
});
|