figma-rust-mcp 3.2.7 → 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 +93 -3
- package/bin/figma-mcp.js +7 -4
- package/package.json +6 -4
- package/plugin/code.js +1 -1
- package/scripts/build-plugin.js +22 -4
- package/scripts/test-bridge-sessions.mjs +155 -0
- package/scripts/test-plugin-tokens.mjs +360 -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.
|
|
@@ -107,6 +107,25 @@ npx -y figma-rust-mcp@latest --uninstall-service
|
|
|
107
107
|
|
|
108
108
|
---
|
|
109
109
|
|
|
110
|
+
### Windows background mode
|
|
111
|
+
|
|
112
|
+
Double-clicking `figma-mcp.exe` starts the server in a detached process and closes
|
|
113
|
+
its launcher console. From PowerShell or Command Prompt, use:
|
|
114
|
+
|
|
115
|
+
```powershell
|
|
116
|
+
.\figma-mcp.exe --background
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The server keeps running after the terminal closes. Logs are appended to
|
|
120
|
+
`%LOCALAPPDATA%\figma-mcp\logs\server.log`. Use `--server` for the foreground
|
|
121
|
+
terminal dashboard, or `--stdio` for an MCP client subprocess.
|
|
122
|
+
|
|
123
|
+
`--install-service` through the npm runner configures background startup at login.
|
|
124
|
+
Re-run the installation command to update an existing Windows task. Removing the
|
|
125
|
+
task disables future login startup; stop an already detached server through Task
|
|
126
|
+
Manager (`figma-mcp.exe`). The launcher console may appear briefly when opening
|
|
127
|
+
the executable; the server itself does not retain a console window.
|
|
128
|
+
|
|
110
129
|
### 4. Build from Source (Optional)
|
|
111
130
|
|
|
112
131
|
If you prefer building directly with the [Rust toolchain](https://rustup.rs/) (`cargo >= 1.80`):
|
|
@@ -215,9 +234,43 @@ Exports Figma Variables (Design Tokens), Color Styles, Typography Styles, and El
|
|
|
215
234
|
- **`outputPath`**: Writes directly to disk (e.g. `src/styles/tokens.css` or `tailwind.config.js`).
|
|
216
235
|
- **`collection`** / **`mode`** / **`prefix`**: Optional filters and naming prefixes.
|
|
217
236
|
|
|
237
|
+
Token exports use schema v2 and preserve full paint/effect stacks, alpha, gradient
|
|
238
|
+
transforms, collection modes and raw alias values. CSS, Tailwind, TypeScript and
|
|
239
|
+
JSON share one normalized model. The `w3c` output uses DTCG 2025.10 color,
|
|
240
|
+
dimension, gradient-stop and shadow values, with Figma-specific geometry and
|
|
241
|
+
unsupported values retained in `$extensions`.
|
|
242
|
+
|
|
243
|
+
Linear gradients on a node are projected using its actual dimensions. A diagonal
|
|
244
|
+
gradient style has no target aspect ratio: use `get_css` on its consuming node.
|
|
245
|
+
Radial/angular/diamond paints, unsupported blend modes and progressive blurs
|
|
246
|
+
retain their raw data and report diagnostics recommending SVG instead of emitting
|
|
247
|
+
approximate CSS. Unknown modes, unresolved aliases and sanitized name collisions
|
|
248
|
+
fail explicitly. CSS exports include non-default modes under `data-theme`
|
|
249
|
+
selectors; opacity/font-weight variables stay unitless.
|
|
250
|
+
|
|
251
|
+
The plugin UI badge is stamped from the build version and updated from the
|
|
252
|
+
connected server handshake. Package/Cargo/tag mismatches fail the build pipeline.
|
|
253
|
+
Run `npm run build:plugin` after changing plugin source, and `npm run test:tokens`
|
|
254
|
+
for the token/contract/version regression suite. Existing background servers need
|
|
255
|
+
a restart with the new binary to load the updated runtime.
|
|
256
|
+
|
|
257
|
+
|
|
218
258
|
### 5. `figma_get_selection`
|
|
219
|
-
|
|
220
|
-
- **`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.
|
|
221
274
|
- **`depth`**: Tree depth limit or `"full"`.
|
|
222
275
|
|
|
223
276
|
### 6. `figma_export_asset`
|
|
@@ -291,6 +344,43 @@ Scans your local codebase directories (`src/components`, `components/ui`) to dis
|
|
|
291
344
|
|
|
292
345
|
---
|
|
293
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
|
+
|
|
294
384
|
## 💻 Development & Testing
|
|
295
385
|
|
|
296
386
|
```bash
|
package/bin/figma-mcp.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* caches it locally, and executes it transparently.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { spawn, execSync } from 'node:child_process';
|
|
9
|
+
import { spawn, execSync, execFileSync } from 'node:child_process';
|
|
10
10
|
import fs from 'node:fs';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import os from 'node:os';
|
|
@@ -19,7 +19,7 @@ const __dirname = path.dirname(__filename);
|
|
|
19
19
|
|
|
20
20
|
// Read package.json version
|
|
21
21
|
const pkgPath = path.resolve(__dirname, '../package.json');
|
|
22
|
-
let pkgVersion =
|
|
22
|
+
let pkgVersion = null;
|
|
23
23
|
let repoOwner = 'BuiHung1612';
|
|
24
24
|
let repoName = 'figma-mcp';
|
|
25
25
|
|
|
@@ -36,6 +36,7 @@ try {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
} catch (_) {}
|
|
39
|
+
if (!pkgVersion) throw new Error(`Cannot read package version from ${pkgPath}`);
|
|
39
40
|
|
|
40
41
|
function getPlatformArch() {
|
|
41
42
|
const platform = process.platform;
|
|
@@ -293,8 +294,10 @@ WantedBy=default.target
|
|
|
293
294
|
} else if (platform === 'win32') {
|
|
294
295
|
const taskName = 'FigmaMCPServer';
|
|
295
296
|
// Use schtasks to create a task that runs at login
|
|
296
|
-
|
|
297
|
-
|
|
297
|
+
// The native launcher detaches the server and redirects output to a log file.
|
|
298
|
+
const taskCommand = `"${binPath}" --background`;
|
|
299
|
+
execFileSync('schtasks', ['/Create', '/F', '/TN', taskName,
|
|
300
|
+
'/TR', taskCommand, '/SC', 'ONLOGON'], { stdio: 'inherit', windowsHide: true });
|
|
298
301
|
|
|
299
302
|
// Start it immediately too
|
|
300
303
|
try { execSync(`schtasks /Run /TN "${taskName}"`, { stdio: 'pipe' }); } catch (_) {}
|
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": {
|
|
@@ -16,10 +16,12 @@
|
|
|
16
16
|
"LICENSE"
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
|
-
"build": "cargo build --release",
|
|
19
|
+
"build": "npm run build:plugin && cargo build --release",
|
|
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",
|
|
24
|
+
"test:sessions": "cargo build && node --test scripts/test-bridge-sessions.mjs"
|
|
23
25
|
},
|
|
24
26
|
"engines": {
|
|
25
27
|
"node": ">=18"
|
package/plugin/code.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// ─── FIGMA MCP BRIDGE — DYNAMIC THIN LOADER
|
|
1
|
+
// ─── FIGMA MCP BRIDGE — DYNAMIC THIN LOADER ───────────────────────────
|
|
2
2
|
// This file is a permanent micro-stub. You never need to reinstall or edit this file.
|
|
3
3
|
// The actual runtime is loaded dynamically from the local figma-mcp Rust engine.
|
|
4
4
|
|
package/scripts/build-plugin.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
// Build script — concatenate src
|
|
1
|
+
// Build script — concatenate plugin-src/*.js into plugin-runtime/code.js
|
|
2
2
|
// No module system used: files are plain JS sharing a single scope when concatenated.
|
|
3
3
|
import { readFileSync, writeFileSync } from "fs";
|
|
4
4
|
|
|
5
5
|
const { version } = JSON.parse(readFileSync("package.json", "utf8"));
|
|
6
|
+
const cargoVersion = readFileSync("Cargo.toml", "utf8").match(/^version\s*=\s*"([^"]+)"/m)?.[1];
|
|
7
|
+
if (version !== cargoVersion) throw new Error(`Version mismatch: package.json ${version}, Cargo.toml ${cargoVersion}`);
|
|
8
|
+
if (process.env.GITHUB_REF_TYPE === "tag" && process.env.GITHUB_REF_NAME !== `v${version}`) {
|
|
9
|
+
throw new Error(`Release tag ${process.env.GITHUB_REF_NAME} does not match build version v${version}`);
|
|
10
|
+
}
|
|
6
11
|
|
|
7
12
|
const files = [
|
|
8
13
|
"plugin-src/utils.js",
|
|
9
14
|
"plugin-src/svg-path-helpers.js",
|
|
10
15
|
"plugin-src/paint-and-effects.js",
|
|
16
|
+
"plugin-src/token-helpers.js",
|
|
11
17
|
"plugin-src/read-helpers.js",
|
|
12
18
|
"plugin-src/handlers-write.js",
|
|
13
19
|
"plugin-src/handlers-library.js",
|
|
@@ -15,13 +21,13 @@ const files = [
|
|
|
15
21
|
"plugin-src/handlers-read-detail.js",
|
|
16
22
|
"plugin-src/handlers-tokens.js",
|
|
17
23
|
"plugin-src/handlers-write-ops.js",
|
|
24
|
+
"plugin-src/task-scope.js",
|
|
18
25
|
"plugin-src/main.js",
|
|
19
26
|
];
|
|
20
27
|
|
|
21
|
-
const banner = `// figma-mcp — Figma Plugin main thread
|
|
28
|
+
const banner = `// figma-mcp — Figma Plugin main thread v${version}
|
|
22
29
|
// Handles both WRITE (draw UI) and READ (extract design) operations.
|
|
23
30
|
// AUTO-GENERATED by scripts/build-plugin.js — edit plugin-src/*.js instead
|
|
24
|
-
// Generated: ${new Date().toISOString()}
|
|
25
31
|
`;
|
|
26
32
|
|
|
27
33
|
const parts = files.map(f => {
|
|
@@ -30,5 +36,17 @@ const parts = files.map(f => {
|
|
|
30
36
|
});
|
|
31
37
|
|
|
32
38
|
const output = (banner + parts.join("\n")).replace(/\{\{PLUGIN_VERSION\}\}/g, version);
|
|
33
|
-
|
|
39
|
+
|
|
40
|
+
const uiPath = "plugin-runtime/ui.html";
|
|
41
|
+
const ui = readFileSync(uiPath, "utf8").replace(/(<span class="brand-ver"[^>]*>)[^<]*(<\/span>)/,
|
|
42
|
+
`$1v${version}$2`);
|
|
43
|
+
if (!ui.includes(`id="runtime-version">v${version}</span>`)) throw new Error("Missing runtime version badge");
|
|
44
|
+
if (process.argv.includes('--check')) {
|
|
45
|
+
if (readFileSync("plugin-runtime/code.js", "utf8") !== output || readFileSync(uiPath, "utf8") !== ui) {
|
|
46
|
+
throw new Error("Plugin bundle is stale; run npm run build:plugin");
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
writeFileSync("plugin-runtime/code.js", output);
|
|
50
|
+
writeFileSync(uiPath, ui);
|
|
51
|
+
}
|
|
34
52
|
console.log(`✓ plugin-runtime/code.js written (${output.length} chars, ${output.split("\n").length} lines)`);
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,360 @@
|
|
|
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
|
+
});
|
|
19
|
+
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
|
+
|
|
21
|
+
test('equivalent CSS syntaxes preserve alpha in reads and writes', () => {
|
|
22
|
+
const r = runtime();
|
|
23
|
+
for (const color of ['rgba(255, 0, 0, .5)', 'rgb(100% 0% 0% / 50%)', 'hsl(0 100% 50% / .5)', 'hsla(0, 100%, 50%, .5)']) {
|
|
24
|
+
assert.deepEqual(plain(r.parseColorValue(color)), { r: 1, g: 0, b: 0, a: .5 });
|
|
25
|
+
assert.equal(r.hexToRgbA(color).a, .5);
|
|
26
|
+
assert.equal(r.solidFill(color)[0].opacity, .5);
|
|
27
|
+
}
|
|
28
|
+
assert.equal(r.parseColorValue('#f008').a, 136 / 255);
|
|
29
|
+
assert.equal(r.parseColorValue('transparent').a, 0);
|
|
30
|
+
assert.equal(r.colorToCss({ r: 0, g: 0, b: 0, a: .9999 }), 'rgba(0, 0, 0, 0.9999)');
|
|
31
|
+
assert.throws(() => r.parseColorValue('rgba(garbage)'), /Invalid/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('gradient projection includes translations, aspect ratio, stop and paint alpha', () => {
|
|
35
|
+
const r = runtime();
|
|
36
|
+
const p = r.serializePaint(gradient());
|
|
37
|
+
assert.deepEqual(plain(p.gradientTransform), [[1, 0, 0], [0, 1, 0]]);
|
|
38
|
+
assert.match(r.paintToCss(p, 200, 100), /^linear-gradient\(90deg, rgba\(255, 0, 0, 0.25\) 0%, rgba\(0, 0, 255, 0.5\) 100%\)$/);
|
|
39
|
+
const diagonal = r.paintToCss(r.serializePaint(gradient([[1, 1, 0], [0, 1, 0]])), 200, 100);
|
|
40
|
+
assert.match(diagonal, /153\.434948/);
|
|
41
|
+
assert.match(diagonal, /50%/);
|
|
42
|
+
const shifted = r.paintToCss(r.serializePaint(gradient([[.5, 0, .25], [0, 1, 0]])), 200, 100);
|
|
43
|
+
assert.match(shifted, /-50%/);
|
|
44
|
+
assert.match(shifted, /150%/);
|
|
45
|
+
assert.throws(() => r.paintToCss({ ...p, type: 'GRADIENT_DIAMOND' }, 100, 100), /SVG/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('get_css uses the numeric paint, never hex slicing a serialized rgba', async () => {
|
|
49
|
+
const node = { id: '1:2', name: 'alpha', type: 'RECTANGLE', x: 0, y: 0, width: 200, height: 100,
|
|
50
|
+
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 }, opacity: .25 }], effects: [shadow('DROP_SHADOW'), shadow('INNER_SHADOW')] };
|
|
51
|
+
const r = runtime();
|
|
52
|
+
r.findNodeByIdAsync = async () => node;
|
|
53
|
+
const result = await r.handlers.get_css({ id: node.id });
|
|
54
|
+
assert.match(result.css, /background-color: rgba\(255, 0, 0, 0.25\);/);
|
|
55
|
+
assert.doesNotMatch(result.css, /NaN|0.0625/);
|
|
56
|
+
assert.match(result.css, /inset -1px 3px 6px -2px rgba\(0, 0, 0, 0.125\)/);
|
|
57
|
+
assert.equal(result.detail.effects.length, 2);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('styles retain full paints, effects, units and disabled layers', async () => {
|
|
61
|
+
const r = runtime({
|
|
62
|
+
getLocalPaintStylesAsync: async () => [{ id: 'p', name: 'Gradient', paints: [gradient()] }],
|
|
63
|
+
getLocalTextStylesAsync: async () => [{ id: 't', name: 'Body', fontName: { family: 'Inter', style: 'Regular' }, fontSize: 16, lineHeight: { unit: 'PERCENT', value: 150 }, letterSpacing: { unit: 'PERCENT', value: 2 } }],
|
|
64
|
+
getLocalEffectStylesAsync: async () => [{ id: 's', name: 'Card', effects: [shadow('DROP_SHADOW'), shadow('INNER_SHADOW'), { type: 'LAYER_BLUR', radius: 8, visible: false }] }],
|
|
65
|
+
getLocalGridStylesAsync: async () => [],
|
|
66
|
+
});
|
|
67
|
+
const styles = await r.handlers.get_styles();
|
|
68
|
+
assert.equal(styles.schemaVersion, 2);
|
|
69
|
+
assert.equal(styles.paintStyles[0].paints[0].gradientStops[0].rgba.a, .5);
|
|
70
|
+
assert.equal(styles.effectStyles[0].effects.length, 3);
|
|
71
|
+
assert.equal(styles.textStyles[0].lineHeightUnit, 'PERCENT');
|
|
72
|
+
assert.equal(r.effectsToCss(styles.effectStyles[0].effects).filter, '');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('aliases use mode names across collections and retain false/zero values', async () => {
|
|
76
|
+
const cols = [
|
|
77
|
+
{ id: 'semantic', name: 'Semantic', defaultModeId: 's-light', modes: [{ modeId: 's-light', name: 'Light' }, { modeId: 's-dark', name: 'Dark' }], variableIds: ['alias', 'zero'] },
|
|
78
|
+
{ id: 'primitive', name: 'Primitive', defaultModeId: 'p-light', modes: [{ modeId: 'p-light', name: 'Light' }, { modeId: 'p-dark', name: 'Dark' }], variableIds: ['base'] },
|
|
79
|
+
];
|
|
80
|
+
const variables = [
|
|
81
|
+
{ id: 'alias', name: 'semantic', variableCollectionId: 'semantic', resolvedType: 'BOOLEAN', valuesByMode: { 's-light': { type: 'VARIABLE_ALIAS', id: 'base' }, 's-dark': { type: 'VARIABLE_ALIAS', id: 'base' } } },
|
|
82
|
+
{ id: 'zero', name: 'zero', variableCollectionId: 'semantic', resolvedType: 'FLOAT', valuesByMode: { 's-light': 0, 's-dark': 1 } },
|
|
83
|
+
{ id: 'base', name: 'base', variableCollectionId: 'primitive', resolvedType: 'BOOLEAN', valuesByMode: { 'p-light': false, 'p-dark': true } },
|
|
84
|
+
];
|
|
85
|
+
const r = runtime({ variables: {
|
|
86
|
+
getLocalVariableCollectionsAsync: async () => cols, getLocalVariablesAsync: async () => variables,
|
|
87
|
+
getVariableByIdAsync: async id => variables.find(v => v.id === id),
|
|
88
|
+
getVariableCollectionByIdAsync: async id => cols.find(c => c.id === id),
|
|
89
|
+
} });
|
|
90
|
+
const result = await r.handlers.get_variables();
|
|
91
|
+
const alias = result.collections[0].variables[0];
|
|
92
|
+
assert.equal(alias.values['s-light'].resolvedValue, false);
|
|
93
|
+
assert.equal(alias.values['s-dark'].resolvedValue, true);
|
|
94
|
+
assert.equal(result.resolvedTokens.semantic, false);
|
|
95
|
+
assert.equal(result.resolvedTokens.zero, 0);
|
|
96
|
+
assert.equal(result.collections[0].defaultModeId, 's-light');
|
|
97
|
+
assert.deepEqual(plain(result.diagnostics), []);
|
|
98
|
+
const consumer = { resolvedVariableModes: { semantic: 's-dark', primitive: 'p-light' } };
|
|
99
|
+
variables[0].resolveForConsumer = () => ({ value: false, resolvedType: 'BOOLEAN' });
|
|
100
|
+
assert.equal((await r.resolveVariableValueAsync(variables[0], consumer)).resolvedValue, false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('export_node validates format and works through batch and aliases', async () => {
|
|
104
|
+
const r = runtime();
|
|
105
|
+
r.handlers.export_svg = async () => ({ svg: '<svg/>' });
|
|
106
|
+
r.handlers.export_image = async params => ({ format: params.format });
|
|
107
|
+
assert.equal((await r.resolveOperationHandler('exportNode')({ format: 'svg' })).svg, '<svg/>');
|
|
108
|
+
assert.equal((await r.handlers.export_node({ format: 'jpeg' })).format, 'JPG');
|
|
109
|
+
const batch = await r.handlers.batch({ operations: [{ operation: 'export_node', params: { format: 'SVG' } }, { operation: 'export_node', params: { format: 'bad' } }] });
|
|
110
|
+
assert.equal(batch.succeeded, 1);
|
|
111
|
+
assert.match(batch.results[1].error, /supports PNG/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('multiple paints preserve top-first ordering and node opacity stays separate', () => {
|
|
115
|
+
const r = runtime();
|
|
116
|
+
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);
|
|
117
|
+
const css = r.paintsToCss(paints, 200, 100);
|
|
118
|
+
assert.match(css.value, /^linear-gradient\(rgba\(255, 0, 0, 0.5\)/);
|
|
119
|
+
assert.match(css.value, /linear-gradient\(#0000ff, #0000ff\)$/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('UI badge uses release version initially and follows the server on reconnect', () => {
|
|
123
|
+
const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
|
|
124
|
+
const html = readFileSync('plugin-runtime/ui.html', 'utf8');
|
|
125
|
+
assert.ok(html.includes(`id="runtime-version">v${pkg.version}</span>`));
|
|
126
|
+
const start = html.indexOf(' function updateRuntimeVersion(');
|
|
127
|
+
const end = html.indexOf(' var runtimeCapabilities', start);
|
|
128
|
+
const badge = {};
|
|
129
|
+
const context = vm.createContext({ document: { getElementById: () => badge } });
|
|
130
|
+
vm.runInContext(html.slice(start, end), context);
|
|
131
|
+
context.updateRuntimeVersion('3.2.7');
|
|
132
|
+
assert.equal(badge.textContent, 'v3.2.7');
|
|
133
|
+
context.updateRuntimeVersion('3.3.0');
|
|
134
|
+
assert.equal(badge.textContent, 'v3.3.0');
|
|
135
|
+
assert.ok(html.includes('updateRuntimeVersion(serverVer)'));
|
|
136
|
+
assert.ok(html.includes('type: "runtime-ready"'));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('build rejects wrong release tags and checked-in bundle matches source', () => {
|
|
140
|
+
const check = spawnSync(process.execPath, ['scripts/build-plugin.js', '--check'], { encoding: 'utf8' });
|
|
141
|
+
assert.equal(check.status, 0, check.stderr);
|
|
142
|
+
const rejected = spawnSync(process.execPath, ['scripts/build-plugin.js'], {
|
|
143
|
+
encoding: 'utf8', env: { ...process.env, GITHUB_REF_TYPE: 'tag', GITHUB_REF_NAME: 'v0.0.0-invalid' },
|
|
144
|
+
});
|
|
145
|
+
assert.notEqual(rejected.status, 0);
|
|
146
|
+
assert.match(rejected.stderr, /does not match build version/);
|
|
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
|
+
});
|