figma-rust-mcp 4.1.0 → 5.0.2
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 +50 -21
- package/bin/agent-config.js +2 -2
- package/package.json +1 -1
- package/plugin/manifest.json +25 -13
- package/plugin/ui.html +37 -29
- package/scripts/bench-workloads.mjs +85 -0
- package/scripts/bench.mjs +53 -31
- package/scripts/helpers/index-sync-ui.mjs +22 -0
- package/scripts/test-bench.mjs +98 -0
- package/scripts/test-bridge-sessions.mjs +118 -0
- package/scripts/test-plugin-tokens.mjs +246 -8
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ Enables AI agents (Google Antigravity, Claude Code, Cursor, Windsurf, VS Code, Z
|
|
|
18
18
|
- **Batch Asset Extractor (`figma_export_assets` / `figma_export_asset`)**: Extracts SVG icons and raster images directly to project folders (`outputPath`) with auto-generated TypeScript barrel exports (`index.ts`) — zero chat token bloating.
|
|
19
19
|
- **Sandboxed JS Drawing Engine (`figma_write`)**: Powered by **Boa** (pure Rust ECMAScript engine) with built-in asset resolvers and 7 icon packs (*Ionicons, Lucide, Tabler, Bootstrap, Fluent, Phosphor*).
|
|
20
20
|
- **Multi-Tab / Multi-Session Support**: Seamlessly connects to multiple open Figma files simultaneously.
|
|
21
|
-
- **100% Localhost Privacy**: All communication stays strictly on `127.0.0.1:
|
|
21
|
+
- **100% Localhost Privacy**: All communication stays strictly on `127.0.0.1:41730`.
|
|
22
22
|
|
|
23
23
|
---
|
|
24
24
|
|
|
@@ -36,7 +36,7 @@ Enables AI agents (Google Antigravity, Claude Code, Cursor, Windsurf, VS Code, Z
|
|
|
36
36
|
│ • Sandboxed JS Runtime (Boa ECMAScript Engine) │
|
|
37
37
|
│ • Binary MessagePack & Progressive Subtree Chunk Receiver │
|
|
38
38
|
└───────────────▲────────────────────────────────────────▲───────────────┘
|
|
39
|
-
│ (ws://127.0.0.1:
|
|
39
|
+
│ (ws://127.0.0.1:41730/ws) │ (http://127.0.0.1:41730/mcp)
|
|
40
40
|
│ (Dynamic Code Streaming & Hot-Reload) │ (JSON-RPC 2.0 / Streamable HTTP)
|
|
41
41
|
┌─────────┴─────────┐ ┌─────────┴─────────┐
|
|
42
42
|
│ Figma Desktop │ │ AI Assistant(s) │
|
|
@@ -50,9 +50,10 @@ Enables AI agents (Google Antigravity, Claude Code, Cursor, Windsurf, VS Code, Z
|
|
|
50
50
|
|
|
51
51
|
### 🔌 How the Zero-Touch Connection Works:
|
|
52
52
|
1. **Permanent Thin Loader**: You only import the Figma plugin **once** (`~/.figma-rust-mcp/plugin/manifest.json`).
|
|
53
|
-
2. **Dynamic Runtime Streaming**: Upon launch, the Thin Loader contacts `http://127.0.0.1:
|
|
54
|
-
3. **Live WebSocket Hot-Reload**: The plugin connects to `ws://127.0.0.1:
|
|
53
|
+
2. **Dynamic Runtime Streaming**: Upon launch, the Thin Loader contacts `http://127.0.0.1:41730/plugin/code.js` to fetch and execute the latest runtime in memory.
|
|
54
|
+
3. **Live WebSocket Hot-Reload**: The plugin connects to `ws://127.0.0.1:41730/ws`. When you upgrade `figma-rust-mcp` or restart the daemon, the plugin automatically detects the new server version and hot-reloads seamlessly — **no need to re-import or restart the plugin in Figma**.
|
|
55
55
|
4. **Auto-Reconnect & Offline Buffer**: If Figma is opened before the Rust service starts, the plugin displays a waiting screen and connects automatically the instant the service is up.
|
|
56
|
+
5. **Port Range & Auto-Switch**: The bridge uses ports `41730–41739`. On start it reuses a running server of the same version, skips any port held by another app or a different figma-rust-mcp version, and starts on the first free port. The plugin scans the same range, so it follows automatically. MCP clients configured by URL point at `41730`; the server prints a warning with the new port if it had to move.
|
|
56
57
|
|
|
57
58
|
---
|
|
58
59
|
|
|
@@ -148,13 +149,13 @@ Use the `/mcp` endpoint. It accepts `POST` JSON-RPC requests directly:
|
|
|
148
149
|
|
|
149
150
|
```toml
|
|
150
151
|
[mcp_servers.figma-rust-mcp]
|
|
151
|
-
url = "http://127.0.0.1:
|
|
152
|
+
url = "http://127.0.0.1:41730/mcp"
|
|
152
153
|
```
|
|
153
154
|
|
|
154
155
|
For Codex CLI, the equivalent command is:
|
|
155
156
|
|
|
156
157
|
```bash
|
|
157
|
-
codex mcp add figma-rust-mcp --url http://127.0.0.1:
|
|
158
|
+
codex mcp add figma-rust-mcp --url http://127.0.0.1:41730/mcp
|
|
158
159
|
```
|
|
159
160
|
|
|
160
161
|
#### Google Antigravity (SSE Transport)
|
|
@@ -163,7 +164,7 @@ Add to your `~/.gemini/config/mcp_config.json` or project `.agents/mcp_config.js
|
|
|
163
164
|
{
|
|
164
165
|
"mcpServers": {
|
|
165
166
|
"figma-rust-mcp": {
|
|
166
|
-
"serverUrl": "http://127.0.0.1:
|
|
167
|
+
"serverUrl": "http://127.0.0.1:41730/sse"
|
|
167
168
|
}
|
|
168
169
|
}
|
|
169
170
|
}
|
|
@@ -176,7 +177,7 @@ These clients can use the Streamable HTTP `/mcp` endpoint:
|
|
|
176
177
|
{
|
|
177
178
|
"mcpServers": {
|
|
178
179
|
"figma-rust-mcp": {
|
|
179
|
-
"url": "http://127.0.0.1:
|
|
180
|
+
"url": "http://127.0.0.1:41730/mcp"
|
|
180
181
|
}
|
|
181
182
|
}
|
|
182
183
|
}
|
|
@@ -200,7 +201,7 @@ Alternatively, configure a stdio subprocess via NPX:
|
|
|
200
201
|
Check the runtime bundle currently served to Figma Desktop:
|
|
201
202
|
|
|
202
203
|
```bash
|
|
203
|
-
curl http://127.0.0.1:
|
|
204
|
+
curl http://127.0.0.1:41730/plugin/version
|
|
204
205
|
```
|
|
205
206
|
|
|
206
207
|
The response includes `version` and `runtimeHash`. If the hash changes after an
|
|
@@ -421,21 +422,49 @@ recently used entry when its 64-entry capacity is reached.
|
|
|
421
422
|
|
|
422
423
|
## 📏 Benchmarks
|
|
423
424
|
|
|
424
|
-
`npm run bench` builds the release binary
|
|
425
|
+
`npm run bench` builds the release binary and prints a JSON report from a fresh
|
|
426
|
+
server: cold start, process startup, server RAM and MCP dispatch latency. A fresh
|
|
427
|
+
server has no Figma session, so plugin and index measurements are explicitly
|
|
428
|
+
skipped.
|
|
425
429
|
|
|
426
|
-
|
|
430
|
+
To measure real work, connect the Figma plugin and use the existing server:
|
|
431
|
+
|
|
432
|
+
```bash
|
|
433
|
+
# Server report, including its current index and plugin round trip
|
|
434
|
+
node scripts/bench.mjs --url http://127.0.0.1:41730
|
|
435
|
+
|
|
436
|
+
# Read all descendants of a frame, index that frame, and export it as a 1x PNG
|
|
437
|
+
node scripts/bench.mjs --url http://127.0.0.1:41730 \
|
|
438
|
+
--target 'SESSION_ID=FRAME_ID' --samples 3 > benchmark.json
|
|
439
|
+
|
|
440
|
+
# Run the same workloads concurrently on two tabs
|
|
441
|
+
node scripts/bench.mjs --url http://127.0.0.1:41730 \
|
|
442
|
+
--target 'FIRST_SESSION=FIRST_FRAME' --target 'SECOND_SESSION=SECOND_FRAME'
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
Find session IDs at `GET /sessions` and frame IDs in the plugin selection card.
|
|
446
|
+
Use one frame per session and 1–10 samples per workload. Workloads read Figma
|
|
447
|
+
content; they do not modify canvas nodes. Indexing updates the bridge's cache,
|
|
448
|
+
and PNG export may briefly move the viewport before restoring it.
|
|
449
|
+
|
|
450
|
+
| Measurement | What it includes |
|
|
427
451
|
|---|---|
|
|
428
|
-
| Cold start | `spawn` → first successful `GET /health`, from outside the process |
|
|
429
|
-
| Startup | `main()` entry → HTTP listener bound |
|
|
430
|
-
| RAM | Resident set size (macOS/Linux; not reported on Windows) |
|
|
431
452
|
| MCP `tools/list` | 200 in-process dispatches, p50/p95 |
|
|
432
|
-
| Index search | 200 searches over the current
|
|
433
|
-
| Plugin round trip | 10 `get_viewport` calls
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
453
|
+
| Index search | 200 searches over the current index, when available |
|
|
454
|
+
| Plugin round trip | 10 `get_viewport` calls, when connected |
|
|
455
|
+
| Frame read | All paginated `read_nodes` responses, node/page counts and JSON bytes |
|
|
456
|
+
| Frame index | Plugin scan and response time; bridge commit acknowledgement is separate |
|
|
457
|
+
| PNG export | Figma export, transfer and decoded image bytes |
|
|
458
|
+
| Concurrent tabs | Workloads run sequentially per tab and concurrently across tabs |
|
|
459
|
+
| RAM | Server RSS before/after workloads, not peak RAM or Figma's memory |
|
|
460
|
+
|
|
461
|
+
Reports contain each sample plus p50/p95/max. Reads use the normal cache, so the
|
|
462
|
+
samples are not guaranteed cold reads. Truncated reads remain marked incomplete;
|
|
463
|
+
failed workloads retain their error and the CLI exits with status 1. Real Figma
|
|
464
|
+
results depend on your file; simulated integration tests only verify the path.
|
|
465
|
+
|
|
466
|
+
The plugin's existing **Bench** tab retains the basic `POST /benchmark` report.
|
|
467
|
+
Use the CLI for the heavier frame and concurrent-tab workloads.
|
|
439
468
|
|
|
440
469
|
## 💻 Development & Testing
|
|
441
470
|
|
package/bin/agent-config.js
CHANGED
|
@@ -4,7 +4,7 @@ import os from 'node:os';
|
|
|
4
4
|
import { applyEdits, modify, parse } from 'jsonc-parser';
|
|
5
5
|
|
|
6
6
|
const SERVER_NAME = 'figma-rust-mcp';
|
|
7
|
-
const HTTP_URL = 'http://127.0.0.1:
|
|
7
|
+
const HTTP_URL = 'http://127.0.0.1:41730/mcp';
|
|
8
8
|
|
|
9
9
|
function existsAny(paths) {
|
|
10
10
|
return paths.some(candidate => candidate && fs.existsSync(candidate));
|
|
@@ -78,7 +78,7 @@ function upsertCodex(filePath) {
|
|
|
78
78
|
|
|
79
79
|
export function configureAgent(agent) {
|
|
80
80
|
const entry = agent.id === 'antigravity'
|
|
81
|
-
? { serverUrl: 'http://127.0.0.1:
|
|
81
|
+
? { serverUrl: 'http://127.0.0.1:41730/sse' }
|
|
82
82
|
: agent.id === 'claude' || agent.id === 'vscode'
|
|
83
83
|
? { type: 'http', url: HTTP_URL }
|
|
84
84
|
: { url: HTTP_URL };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "figma-rust-mcp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.2",
|
|
4
4
|
"description": "Claude Code to Figma — 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-rust-mcp",
|
|
6
6
|
"type": "module",
|
package/plugin/manifest.json
CHANGED
|
@@ -5,21 +5,33 @@
|
|
|
5
5
|
"main": "code.js",
|
|
6
6
|
"ui": "ui.html",
|
|
7
7
|
"documentAccess": "dynamic-page",
|
|
8
|
-
"editorType": [
|
|
8
|
+
"editorType": [
|
|
9
|
+
"figma",
|
|
10
|
+
"dev"
|
|
11
|
+
],
|
|
9
12
|
"networkAccess": {
|
|
10
13
|
"allowedDomains": [
|
|
11
|
-
"http://localhost:
|
|
12
|
-
"http://localhost:
|
|
13
|
-
"http://localhost:
|
|
14
|
-
"http://localhost:
|
|
15
|
-
"http://localhost:
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
14
|
+
"http://localhost:41730",
|
|
15
|
+
"http://localhost:41731",
|
|
16
|
+
"http://localhost:41732",
|
|
17
|
+
"http://localhost:41733",
|
|
18
|
+
"http://localhost:41734",
|
|
19
|
+
"http://localhost:41735",
|
|
20
|
+
"http://localhost:41736",
|
|
21
|
+
"http://localhost:41737",
|
|
22
|
+
"http://localhost:41738",
|
|
23
|
+
"http://localhost:41739",
|
|
24
|
+
"ws://localhost:41730",
|
|
25
|
+
"ws://localhost:41731",
|
|
26
|
+
"ws://localhost:41732",
|
|
27
|
+
"ws://localhost:41733",
|
|
28
|
+
"ws://localhost:41734",
|
|
29
|
+
"ws://localhost:41735",
|
|
30
|
+
"ws://localhost:41736",
|
|
31
|
+
"ws://localhost:41737",
|
|
32
|
+
"ws://localhost:41738",
|
|
33
|
+
"ws://localhost:41739"
|
|
21
34
|
],
|
|
22
|
-
"reasoning": "Thin loader connecting dynamically to the local figma-rust-mcp Rust engine on ports
|
|
35
|
+
"reasoning": "Thin loader connecting dynamically to the local figma-rust-mcp Rust engine on ports 41730-41739."
|
|
23
36
|
}
|
|
24
37
|
}
|
|
25
|
-
|
package/plugin/ui.html
CHANGED
|
@@ -6,18 +6,18 @@
|
|
|
6
6
|
<title>Figma Rust MCP Loader</title>
|
|
7
7
|
<style>
|
|
8
8
|
:root {
|
|
9
|
-
--bg: #
|
|
10
|
-
--surface-base: #
|
|
11
|
-
--surface-card: #
|
|
12
|
-
--border-subtle:
|
|
13
|
-
--border-ghost:
|
|
14
|
-
--text-primary: #
|
|
15
|
-
--text-secondary: #
|
|
16
|
-
--text-muted: #
|
|
17
|
-
--ghost-lavender: #
|
|
18
|
-
--ghost-mint: #
|
|
19
|
-
--font-ui: -apple-system, BlinkMacSystemFont, 'Inter',
|
|
20
|
-
--font-mono: '
|
|
9
|
+
--bg: #fdf1dc;
|
|
10
|
+
--surface-base: #fffaf0;
|
|
11
|
+
--surface-card: #fffdf7;
|
|
12
|
+
--border-subtle: #1c1a17;
|
|
13
|
+
--border-ghost: #1c1a17;
|
|
14
|
+
--text-primary: #1c1a17;
|
|
15
|
+
--text-secondary: #4a453d;
|
|
16
|
+
--text-muted: #7d7567;
|
|
17
|
+
--ghost-lavender: #f4532c;
|
|
18
|
+
--ghost-mint: #2f8f3a;
|
|
19
|
+
--font-ui: ui-rounded, 'SF Pro Rounded', -apple-system, BlinkMacSystemFont, 'Inter', sans-serif;
|
|
20
|
+
--font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, monospace;
|
|
21
21
|
}
|
|
22
22
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
23
23
|
html, body { width: 100%; height: 100%; overflow: hidden; background: var(--bg); color: var(--text-primary); font-family: var(--font-ui); font-size: 11px; }
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
height: 14px;
|
|
59
59
|
border-radius: 50%;
|
|
60
60
|
background: var(--ghost-lavender);
|
|
61
|
-
|
|
61
|
+
border: 2px solid #1c1a17;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
@keyframes pulse {
|
|
@@ -85,7 +85,8 @@
|
|
|
85
85
|
gap: 6px;
|
|
86
86
|
padding: 4px 10px;
|
|
87
87
|
background: var(--surface-card);
|
|
88
|
-
border:
|
|
88
|
+
border: 2px solid var(--border-subtle);
|
|
89
|
+
box-shadow: 2px 2px 0 #1c1a17;
|
|
89
90
|
border-radius: 9999px;
|
|
90
91
|
font-family: var(--font-mono);
|
|
91
92
|
font-size: 10px;
|
|
@@ -94,7 +95,8 @@
|
|
|
94
95
|
}
|
|
95
96
|
.hint-box {
|
|
96
97
|
background: var(--surface-base);
|
|
97
|
-
border:
|
|
98
|
+
border: 2px solid var(--border-subtle);
|
|
99
|
+
box-shadow: 2px 2px 0 #1c1a17;
|
|
98
100
|
border-radius: 8px;
|
|
99
101
|
padding: 10px 12px;
|
|
100
102
|
max-width: 280px;
|
|
@@ -108,7 +110,7 @@
|
|
|
108
110
|
color: var(--ghost-lavender);
|
|
109
111
|
display: block;
|
|
110
112
|
margin-top: 4px;
|
|
111
|
-
background: rgba(
|
|
113
|
+
background: rgba(28,26,23,0.06);
|
|
112
114
|
padding: 3px 6px;
|
|
113
115
|
border-radius: 4px;
|
|
114
116
|
}
|
|
@@ -123,9 +125,9 @@
|
|
|
123
125
|
<div class="radar-core"></div>
|
|
124
126
|
</div>
|
|
125
127
|
<div class="title">Connecting to figma-rust-mcp</div>
|
|
126
|
-
<div id="status" class="status-text">Scanning local ports (
|
|
128
|
+
<div id="status" class="status-text">Scanning local ports (41730-41739)...</div>
|
|
127
129
|
<div class="badge">
|
|
128
|
-
<span>●</span> <span id="port-label">localhost:
|
|
130
|
+
<span>●</span> <span id="port-label">localhost:41730</span>
|
|
129
131
|
</div>
|
|
130
132
|
<div class="hint-box">
|
|
131
133
|
Ensure the local Rust server is running:
|
|
@@ -134,7 +136,8 @@
|
|
|
134
136
|
</div>
|
|
135
137
|
|
|
136
138
|
<script>
|
|
137
|
-
|
|
139
|
+
// Must match DEFAULT_PORT / PORT_RANGE in src/bridge/server.rs and manifest.json.
|
|
140
|
+
const PORTS = Array.from({ length: 10 }, (_, i) => 41730 + i);
|
|
138
141
|
let attempts = 0;
|
|
139
142
|
|
|
140
143
|
async function checkPort(port) {
|
|
@@ -148,8 +151,9 @@
|
|
|
148
151
|
clearTimeout(timeoutId);
|
|
149
152
|
if (res.ok) {
|
|
150
153
|
const data = await res.json();
|
|
151
|
-
return { ok: true, data };
|
|
154
|
+
if (data && data.name === "figma-rust-mcp") return { ok: true, data };
|
|
152
155
|
}
|
|
156
|
+
return { ok: false, answered: true };
|
|
153
157
|
} catch (_) {
|
|
154
158
|
clearTimeout(timeoutId);
|
|
155
159
|
}
|
|
@@ -174,7 +178,8 @@
|
|
|
174
178
|
if (!uiRes.ok) throw new Error("Failed to load plugin UI: " + uiRes.status);
|
|
175
179
|
const uiHtml = await uiRes.text();
|
|
176
180
|
|
|
177
|
-
// 3. Inject full UI cleanly
|
|
181
|
+
// 3. Inject full UI cleanly; the runtime reads the port from here (globals survive document.open).
|
|
182
|
+
window.__FIGMA_MCP_PORT = port;
|
|
178
183
|
document.open();
|
|
179
184
|
document.write(uiHtml);
|
|
180
185
|
document.close();
|
|
@@ -190,17 +195,20 @@
|
|
|
190
195
|
const statusEl = document.getElementById("status");
|
|
191
196
|
const portEl = document.getElementById("port-label");
|
|
192
197
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
198
|
+
if (portEl) portEl.textContent = `localhost:${PORTS[0]}-${PORTS[PORTS.length - 1]}`;
|
|
199
|
+
const results = await Promise.all(PORTS.map(checkPort));
|
|
200
|
+
const hit = results.findIndex(r => r.ok);
|
|
201
|
+
if (hit >= 0) {
|
|
202
|
+
if (portEl) portEl.textContent = `localhost:${PORTS[hit]}`;
|
|
203
|
+
await bootstrapFromPort(PORTS[hit]);
|
|
204
|
+
return;
|
|
200
205
|
}
|
|
201
206
|
|
|
202
207
|
if (statusEl) {
|
|
203
|
-
|
|
208
|
+
const busy = PORTS.filter((_, i) => results[i].answered);
|
|
209
|
+
statusEl.textContent = busy.length
|
|
210
|
+
? `Port ${busy.join(", ")} answered but is not a compatible figma-rust-mcp. Run: npx -y figma-rust-mcp@latest --install-service (attempt #${attempts})`
|
|
211
|
+
: `Waiting for server (attempt #${attempts})...`;
|
|
204
212
|
}
|
|
205
213
|
setTimeout(findAndConnect, 1000);
|
|
206
214
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Read-only live workloads. The server and Figma plugin must already be running.
|
|
2
|
+
export async function requestJson(base, route, body) {
|
|
3
|
+
const response = await fetch(new URL(route, base), {
|
|
4
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
5
|
+
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
|
|
6
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
7
|
+
signal: AbortSignal.timeout(120000),
|
|
8
|
+
});
|
|
9
|
+
if (!response.ok) throw new Error(`${route}: HTTP ${response.status}`);
|
|
10
|
+
return response.json();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function summarize(samples) {
|
|
14
|
+
const times = samples.map(sample => sample.durationMs).sort((a, b) => a - b);
|
|
15
|
+
const pick = q => times[Math.round((times.length - 1) * q)];
|
|
16
|
+
return { n: times.length, p50Ms: pick(.5), p95Ms: pick(.95), maxMs: times.at(-1), samples };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function benchmarkTargets(base, targets, samples = 3) {
|
|
20
|
+
if (!Number.isInteger(samples) || samples < 1 || samples > 10) throw new Error('samples must be from 1 to 10');
|
|
21
|
+
if (!targets.length || targets.some(target => !target.sessionId || !target.nodeId)) throw new Error('Each target needs sessionId and nodeId');
|
|
22
|
+
if (new Set(targets.map(target => target.sessionId)).size !== targets.length) throw new Error('Use one target per session for concurrent tab measurements');
|
|
23
|
+
const memoryBefore = (await requestJson(base, '/health')).stats?.memoryMb ?? null;
|
|
24
|
+
const started = performance.now();
|
|
25
|
+
const results = await Promise.all(targets.map(async ({ sessionId, nodeId }) => {
|
|
26
|
+
const workloads = {};
|
|
27
|
+
const exec = async (operation, params) => {
|
|
28
|
+
const result = await requestJson(base, `/exec?sessionId=${encodeURIComponent(sessionId)}`, { operation, params });
|
|
29
|
+
if (!result.success) throw new Error(result.error || `${operation} failed`);
|
|
30
|
+
return result.data;
|
|
31
|
+
};
|
|
32
|
+
const runs = {
|
|
33
|
+
readFrame: async () => {
|
|
34
|
+
let cursor, nodes = 0, pages = 0, responseBytes = 0, complete;
|
|
35
|
+
const cursors = new Set();
|
|
36
|
+
do {
|
|
37
|
+
const data = await exec('read_nodes', cursor ? { cursor, limit: 500 } :
|
|
38
|
+
{ id: nodeId, depth: 'full', expandInstances: true, fields: ['geometry', 'content', 'style', 'text'], limit: 500 });
|
|
39
|
+
if (!Array.isArray(data.nodes)) throw new Error('read_nodes returned no node list');
|
|
40
|
+
nodes += data.nodes.length; pages++;
|
|
41
|
+
responseBytes += Buffer.byteLength(JSON.stringify(data));
|
|
42
|
+
complete = data.complete === true;
|
|
43
|
+
cursor = data.nextCursor;
|
|
44
|
+
if (cursor && cursors.has(cursor)) throw new Error('read_nodes repeated a cursor');
|
|
45
|
+
if (cursor) cursors.add(cursor);
|
|
46
|
+
if (cursor && pages >= 100) throw new Error('read_nodes exceeded 100 pages');
|
|
47
|
+
} while (cursor);
|
|
48
|
+
return { nodes, pages, responseBytes, complete };
|
|
49
|
+
},
|
|
50
|
+
indexFrame: async () => {
|
|
51
|
+
const data = await exec('index_scan', { id: nodeId, depth: 'full', expandInstances: true, deferComponents: true });
|
|
52
|
+
if (data.cancelled) throw new Error('Index cancelled by a document or page change');
|
|
53
|
+
return { complete: data.complete === true, nodesTruncated: data.nodesTruncated === true };
|
|
54
|
+
},
|
|
55
|
+
exportPng: async () => {
|
|
56
|
+
const data = await exec('export_image', { id: nodeId, format: 'PNG', scale: 1 });
|
|
57
|
+
if (typeof data.base64 !== 'string') throw new Error('export_image returned no image');
|
|
58
|
+
return { imageBytes: Buffer.from(data.base64, 'base64').length, width: data.width, height: data.height };
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
for (const [name, run] of Object.entries(runs)) {
|
|
62
|
+
const timings = [];
|
|
63
|
+
try {
|
|
64
|
+
for (let i = 0; i < samples; i++) {
|
|
65
|
+
const start = performance.now();
|
|
66
|
+
const data = await run();
|
|
67
|
+
timings.push({ durationMs: performance.now() - start, ...data });
|
|
68
|
+
}
|
|
69
|
+
workloads[name] = summarize(timings);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
workloads[name] = { error: error.message, completedSamples: timings.length, samples: timings };
|
|
72
|
+
break; // Do not keep loading a tab after a timeout or disconnect.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { sessionId, nodeId, workloads };
|
|
76
|
+
}));
|
|
77
|
+
const memoryAfter = (await requestJson(base, '/health')).stats?.memoryMb ?? null;
|
|
78
|
+
return {
|
|
79
|
+
mode: 'live', concurrentTabs: targets.length, samplesPerWorkload: samples,
|
|
80
|
+
wallTimeMs: performance.now() - started,
|
|
81
|
+
serverRssBeforeMb: memoryBefore, serverRssAfterMb: memoryAfter,
|
|
82
|
+
note: 'RSS is server memory at the boundaries, not peak memory or Figma memory. Reads may use the normal cache. Index timing includes plugin scan/response, not bridge commit acknowledgement.',
|
|
83
|
+
targets: results,
|
|
84
|
+
};
|
|
85
|
+
}
|
package/scripts/bench.mjs
CHANGED
|
@@ -1,37 +1,59 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
import { statSync } from
|
|
1
|
+
// Cold: node scripts/bench.mjs [binary]
|
|
2
|
+
// Live: node scripts/bench.mjs --url http://127.0.0.1:41730 --target SESSION=NODE [--target SESSION=NODE] [--samples 3]
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { createServer } from 'node:net';
|
|
5
|
+
import { statSync } from 'node:fs';
|
|
6
|
+
import { benchmarkTargets, requestJson } from './bench-workloads.mjs';
|
|
6
7
|
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
const args = process.argv.slice(2), targets = [];
|
|
9
|
+
let base, samples = 3, bin = `target/release/figma-rust-mcp${process.platform === 'win32' ? '.exe' : ''}`;
|
|
10
|
+
for (let i = 0; i < args.length; i++) {
|
|
11
|
+
const arg = args[i];
|
|
12
|
+
if (arg === '--url') base = args[++i];
|
|
13
|
+
else if (arg === '--samples') samples = Number(args[++i]);
|
|
14
|
+
else if (arg === '--target') {
|
|
15
|
+
const target = args[++i] || '', separator = target.indexOf('=');
|
|
16
|
+
if (separator < 1 || separator === target.length - 1) throw new Error('--target must be SESSION=NODE');
|
|
17
|
+
targets.push({ sessionId: target.slice(0, separator), nodeId: target.slice(separator + 1) });
|
|
18
|
+
} else if (!arg.startsWith('-') && i === 0) bin = arg;
|
|
19
|
+
else throw new Error(`Unknown argument: ${arg}`);
|
|
20
|
+
}
|
|
21
|
+
if (args.includes('--url') && !base) throw new Error('--url needs a server URL');
|
|
22
|
+
if (!Number.isInteger(samples) || samples < 1 || samples > 10) throw new Error('--samples must be from 1 to 10');
|
|
23
|
+
if (targets.length && !base) throw new Error('--target requires --url for an existing Figma connection');
|
|
24
|
+
if (base && !['http:', 'https:'].includes(new URL(base).protocol)) throw new Error('--url must use HTTP or HTTPS');
|
|
25
|
+
let child, coldStartMs;
|
|
14
26
|
try {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
27
|
+
if (!base) {
|
|
28
|
+
const reservation = createServer();
|
|
29
|
+
await new Promise(resolve => reservation.listen(0, '127.0.0.1', resolve));
|
|
30
|
+
const port = reservation.address().port;
|
|
31
|
+
await new Promise(resolve => reservation.close(resolve));
|
|
32
|
+
base = `http://127.0.0.1:${port}`;
|
|
33
|
+
const start = performance.now();
|
|
34
|
+
let spawnError;
|
|
35
|
+
child = spawn(bin, ['--server', '--port', String(port)], { stdio: 'ignore' });
|
|
36
|
+
child.on('error', error => { spawnError = error; });
|
|
37
|
+
while (coldStartMs === undefined) {
|
|
38
|
+
if (spawnError) throw spawnError;
|
|
39
|
+
if (child.exitCode !== null) throw new Error(`Server exited: ${child.exitCode}`);
|
|
40
|
+
if (performance.now() - start > 10000) throw new Error('Server did not answer /health within 10s');
|
|
41
|
+
try {
|
|
42
|
+
const response = await fetch(`${base}/health`, { signal: AbortSignal.timeout(500) });
|
|
43
|
+
if (response.ok) coldStartMs = performance.now() - start;
|
|
44
|
+
} catch {}
|
|
45
|
+
if (coldStartMs === undefined) await new Promise(resolve => setTimeout(resolve, 2));
|
|
21
46
|
}
|
|
22
47
|
}
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"plugin round trip": r.pluginRoundTrip ? lat(r.pluginRoundTrip) : "skipped (fresh server has no Figma session)",
|
|
33
|
-
};
|
|
34
|
-
for (const [k, v] of Object.entries(rows)) console.log(`${k.padEnd(30)} ${v}`);
|
|
48
|
+
const report = await requestJson(base, '/benchmark', {});
|
|
49
|
+
report.mode = child ? 'cold-start' : 'live';
|
|
50
|
+
if (child) {
|
|
51
|
+
report.coldStartMs = coldStartMs;
|
|
52
|
+
report.binarySizeMb = statSync(bin).size / 1024 / 1024;
|
|
53
|
+
}
|
|
54
|
+
if (targets.length) report.workloads = await benchmarkTargets(base, targets, samples);
|
|
55
|
+
console.log(JSON.stringify(report, null, 2));
|
|
56
|
+
if (report.workloads?.targets.some(target => Object.values(target.workloads).some(workload => workload.error || workload.samples?.some(sample => sample.complete === false)))) process.exitCode = 1;
|
|
35
57
|
} finally {
|
|
36
|
-
child
|
|
58
|
+
child?.kill();
|
|
37
59
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import vm from 'node:vm';
|
|
3
|
+
|
|
4
|
+
export function indexSyncUi(socket = { readyState: 1, send() {} }) {
|
|
5
|
+
const html = readFileSync('plugin-runtime/ui.html', 'utf8');
|
|
6
|
+
const element = () => {
|
|
7
|
+
const classes = new Set();
|
|
8
|
+
return { disabled: false, title: 'Sync index', attributes: {},
|
|
9
|
+
setAttribute(name, value) { this.attributes[name] = value; },
|
|
10
|
+
classList: { add: name => classes.add(name), remove: name => classes.delete(name),
|
|
11
|
+
contains: name => classes.has(name), toggle: (name, active) => active ? classes.add(name) : classes.delete(name) } };
|
|
12
|
+
};
|
|
13
|
+
const timers = new Map();
|
|
14
|
+
let timerId = 0;
|
|
15
|
+
const ui = vm.createContext({ reindexBtn: element(), indexProgressWrap: element(), indexProgressText: element(),
|
|
16
|
+
ws: socket, WebSocket: { OPEN: 1 }, log() {},
|
|
17
|
+
setTimeout(callback, delay) { timers.set(++timerId, { callback, delay }); return timerId; },
|
|
18
|
+
clearTimeout(id) { timers.delete(id); } });
|
|
19
|
+
vm.runInContext(html.slice(html.indexOf(' var activeIndexScan ='), html.indexOf(' function focusSelection()')), ui);
|
|
20
|
+
ui.fireTimers = delay => { for (const [id, timer] of timers) if (timer.delay === delay) { timers.delete(id); timer.callback(); } };
|
|
21
|
+
return ui;
|
|
22
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { benchmarkTargets, summarize } from './bench-workloads.mjs';
|
|
6
|
+
|
|
7
|
+
async function fixture(t, exec) {
|
|
8
|
+
const calls = [];
|
|
9
|
+
const server = createServer(async (request, response) => {
|
|
10
|
+
try {
|
|
11
|
+
const url = new URL(request.url, 'http://localhost');
|
|
12
|
+
response.setHeader('Content-Type', 'application/json');
|
|
13
|
+
if (url.pathname === '/health') return response.end(JSON.stringify({ stats: { memoryMb: 12 } }));
|
|
14
|
+
if (url.pathname === '/benchmark') return response.end(JSON.stringify({ version: 'test', pluginRoundTrip: null }));
|
|
15
|
+
let body = '';
|
|
16
|
+
for await (const chunk of request) body += chunk;
|
|
17
|
+
const call = { sessionId: url.searchParams.get('sessionId'), ...JSON.parse(body) };
|
|
18
|
+
calls.push(call);
|
|
19
|
+
response.end(JSON.stringify(await exec(call)));
|
|
20
|
+
} catch (error) {
|
|
21
|
+
response.statusCode = 500; response.end(JSON.stringify({ error: error.message }));
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
25
|
+
t.after(() => { server.closeAllConnections(); server.close(); });
|
|
26
|
+
return { base: `http://127.0.0.1:${server.address().port}`, calls };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const result = data => ({ success: true, data });
|
|
30
|
+
function success(call) {
|
|
31
|
+
if (call.operation === 'read_nodes') return result({ nodes: [{ id: call.params.cursor ? 'child' : call.params.id }],
|
|
32
|
+
nextCursor: call.params.cursor ? null : `${call.sessionId}:next`, complete: !!call.params.cursor });
|
|
33
|
+
if (call.operation === 'index_scan') return result({ complete: true, nodesTruncated: false });
|
|
34
|
+
if (call.operation === 'export_image') return result({ base64: Buffer.from('png').toString('base64'), width: 100, height: 80 });
|
|
35
|
+
throw new Error(`Unexpected operation: ${call.operation}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function cli(args) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const child = spawn(process.execPath, ['scripts/bench.mjs', ...args]);
|
|
41
|
+
let stdout = '', stderr = '';
|
|
42
|
+
child.stdout.on('data', chunk => { stdout += chunk; });
|
|
43
|
+
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
44
|
+
child.on('error', reject);
|
|
45
|
+
child.on('exit', code => resolve({ code, stdout, stderr }));
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test('live benchmark paginates reads, measures all workloads and runs tabs concurrently', { timeout: 5000 }, async t => {
|
|
50
|
+
let active = 0, peak = 0;
|
|
51
|
+
const { base, calls } = await fixture(t, async call => {
|
|
52
|
+
active++; peak = Math.max(peak, active);
|
|
53
|
+
await new Promise(resolve => setTimeout(resolve, 5));
|
|
54
|
+
active--;
|
|
55
|
+
return success(call);
|
|
56
|
+
});
|
|
57
|
+
const report = await benchmarkTargets(base, [{ sessionId: 'one', nodeId: 'frame:1' }, { sessionId: 'two', nodeId: 'frame:2' }], 2);
|
|
58
|
+
assert.equal(report.concurrentTabs, 2);
|
|
59
|
+
assert.ok(peak >= 2);
|
|
60
|
+
assert.equal(report.serverRssBeforeMb, 12);
|
|
61
|
+
for (const target of report.targets) {
|
|
62
|
+
assert.equal(target.workloads.readFrame.n, 2);
|
|
63
|
+
assert.equal(target.workloads.readFrame.samples[0].nodes, 2);
|
|
64
|
+
assert.equal(target.workloads.readFrame.samples[0].pages, 2);
|
|
65
|
+
assert.equal(target.workloads.readFrame.samples[0].complete, true);
|
|
66
|
+
assert.ok(target.workloads.readFrame.samples[0].responseBytes > 0);
|
|
67
|
+
assert.equal(target.workloads.indexFrame.samples[0].complete, true);
|
|
68
|
+
assert.equal(target.workloads.exportPng.samples[0].imageBytes, 3);
|
|
69
|
+
}
|
|
70
|
+
assert.equal(calls.length, 16);
|
|
71
|
+
assert.ok(calls.filter(call => call.params.cursor).every(call => call.params.cursor.startsWith(call.sessionId)));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('live benchmark exposes bridge failures and stops loading the failed tab', async t => {
|
|
75
|
+
const { base, calls } = await fixture(t, () => ({ success: false, error: 'Plugin disconnected' }));
|
|
76
|
+
const report = await benchmarkTargets(base, [{ sessionId: 'one', nodeId: 'frame' }]);
|
|
77
|
+
assert.equal(report.targets[0].workloads.readFrame.error, 'Plugin disconnected');
|
|
78
|
+
assert.equal(calls.length, 1);
|
|
79
|
+
await assert.rejects(benchmarkTargets(base, [{ sessionId: 'one', nodeId: 'a' }, { sessionId: 'one', nodeId: 'b' }]), /one target per session/);
|
|
80
|
+
await assert.rejects(benchmarkTargets(base, [{ sessionId: 'one', nodeId: 'a' }], 0), /samples/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('benchmark rejects repeated cursors and CLI preserves failure status in JSON', async t => {
|
|
84
|
+
const { base } = await fixture(t, () => result({ nodes: [], nextCursor: 'same', complete: false }));
|
|
85
|
+
const output = await cli(['--url', base, '--target', 'one=frame', '--samples', '1']);
|
|
86
|
+
assert.equal(output.code, 1, output.stderr);
|
|
87
|
+
const report = JSON.parse(output.stdout);
|
|
88
|
+
assert.match(report.workloads.targets[0].workloads.readFrame.error, /repeated a cursor/);
|
|
89
|
+
const invalid = await cli(['--target', 'invalid']);
|
|
90
|
+
assert.equal(invalid.code, 1);
|
|
91
|
+
assert.match(invalid.stderr, /SESSION=NODE/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('benchmark reports sorted percentile samples', () => {
|
|
95
|
+
const report = summarize([5, 1, 3, 2, 4].map(durationMs => ({ durationMs })));
|
|
96
|
+
assert.equal(report.p50Ms, 3);
|
|
97
|
+
assert.equal(report.p95Ms, 5);
|
|
98
|
+
});
|
|
@@ -158,3 +158,121 @@ test('multiple tabs, reconnect ownership and independent frame tasks across MCP
|
|
|
158
158
|
const restarted = decode(ok(await rpc('figma_task', { action: 'start', sessionId: 'tab-b', frameId: 'frame-c' })));
|
|
159
159
|
ok(await rpc('figma_task', { action: 'end', taskId: restarted.taskId }));
|
|
160
160
|
});
|
|
161
|
+
|
|
162
|
+
test('index sync travels from plugin through UI to bridge and returns a matching confirmation', { timeout: 20000 }, async t => {
|
|
163
|
+
const { indexSyncUi } = await import('./helpers/index-sync-ui.mjs');
|
|
164
|
+
const reservation = createServer();
|
|
165
|
+
await new Promise(resolve => reservation.listen(0, '127.0.0.1', resolve));
|
|
166
|
+
const port = reservation.address().port;
|
|
167
|
+
await new Promise(resolve => reservation.close(resolve));
|
|
168
|
+
const server = spawn(process.env.FIGMA_TEST_BINARY || 'target/debug/figma-rust-mcp',
|
|
169
|
+
['--server', '--port', String(port)], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
170
|
+
let stderr = ''; server.stderr.on('data', bytes => { stderr = (stderr + bytes).slice(-4000); });
|
|
171
|
+
const sockets = [];
|
|
172
|
+
t.after(() => { sockets.forEach(socket => socket.close()); server.kill(); });
|
|
173
|
+
const base = `http://127.0.0.1:${port}`;
|
|
174
|
+
await until(async () => { try { return (await fetch(`${base}/health`)).ok; } catch { assert.equal(server.exitCode, null, stderr); return false; } });
|
|
175
|
+
const ui = indexSyncUi(null);
|
|
176
|
+
const page = { id: 'page', children: [{ id: 'frame', name: 'Frame', type: 'FRAME', width: 100, height: 100 }] };
|
|
177
|
+
const runtime = vm.createContext({ figma: { currentPage: page, root: { name: 'Sync test' },
|
|
178
|
+
ui: { postMessage: msg => ui.handleIndexMessage(msg) } }, handlers: {}, console, setTimeout, Map, Set, Uint8Array });
|
|
179
|
+
for (const file of ['utils', 'token-helpers', 'read-helpers', 'handlers-read-detail', 'handlers-read', 'handlers-tokens', 'handlers-write-ops', 'node-reader']) {
|
|
180
|
+
vm.runInContext(readFileSync(`plugin-src/${file}.js`, 'utf8'), runtime);
|
|
181
|
+
}
|
|
182
|
+
runtime.handlers.get_styles = async () => ({});
|
|
183
|
+
runtime.handlers.get_variables = async () => ({});
|
|
184
|
+
const html = readFileSync('plugin-runtime/ui.html', 'utf8');
|
|
185
|
+
Object.assign(ui, { WebSocket, BRIDGE: base, sessionId: 'sync-test', fileName: 'Sync test', documentId: 'sync-doc',
|
|
186
|
+
uiDisposed: false, wsConnected: false, everConnected: false, retryTimer: null, polling: false, consecutiveErrors: 0,
|
|
187
|
+
currentPort: port, window: {}, READ_OPS: [], setInterval: () => 1, clearInterval() {},
|
|
188
|
+
sendRuntimeCapabilities() {}, setStatus() {}, updateRuntimeVersion() {}, startLongPoll() {},
|
|
189
|
+
dispatchToMain() {}, showSetupGuide() {}, parent: { postMessage: () => runtime.handlers.index_scan({ deferComponents: true }) } });
|
|
190
|
+
vm.runInContext(html.slice(html.indexOf(' function connectWs()'), html.indexOf(' async function startLongPoll()')), ui);
|
|
191
|
+
const connect = async () => { ui.connectWs(); sockets.push(ui.ws); await until(() => ui.wsConnected); };
|
|
192
|
+
await connect();
|
|
193
|
+
const confirmed = ui.receiveIndexAck;
|
|
194
|
+
const acks = [];
|
|
195
|
+
ui.receiveIndexAck = ack => acks.push(ack);
|
|
196
|
+
await runtime.handlers.index_scan({ deferComponents: true });
|
|
197
|
+
await until(() => acks.length === 1);
|
|
198
|
+
assert.equal(acks[0].success, true);
|
|
199
|
+
assert.equal(ui.reindexBtn.disabled, true, 'local scan completion must wait for bridge ack');
|
|
200
|
+
confirmed({ ...acks[0], scanId: 'older-scan' });
|
|
201
|
+
assert.equal(ui.reindexBtn.disabled, true);
|
|
202
|
+
confirmed(acks.shift());
|
|
203
|
+
assert.equal(ui.reindexBtn.disabled, false);
|
|
204
|
+
assert.match(ui.reindexBtn.title, /Last synced/);
|
|
205
|
+
const lastSuccess = ui.reindexBtn.title;
|
|
206
|
+
ui.receiveIndexAck = ack => { acks.push(ack); confirmed(ack); };
|
|
207
|
+
const unmatched = { type: 'index-start', pageId: 'page', scanId: 'unmatched', revision: 0, scope: { id: 'page' } };
|
|
208
|
+
// The UI begins a scan but the bridge never receives its start/chunks.
|
|
209
|
+
ui.handleIndexMessage({ ...unmatched, type: 'index-progress', stage: 'starting' });
|
|
210
|
+
ui.handleIndexMessage({ ...unmatched, type: 'index-update', data: {} });
|
|
211
|
+
await until(() => acks.length === 1);
|
|
212
|
+
assert.equal(acks[0].success, false);
|
|
213
|
+
assert.equal(ui.reindexBtn.disabled, false);
|
|
214
|
+
assert.match(ui.indexProgressText.textContent, /no longer active/);
|
|
215
|
+
assert.equal(ui.reindexBtn.title, lastSuccess);
|
|
216
|
+
|
|
217
|
+
ui.handleIndexMessage({ ...unmatched, scanId: 'disconnect' });
|
|
218
|
+
ui.ws.close();
|
|
219
|
+
await until(() => !ui.wsConnected);
|
|
220
|
+
assert.equal(ui.reindexBtn.disabled, false);
|
|
221
|
+
assert.match(ui.indexProgressText.textContent, /disconnected/);
|
|
222
|
+
await connect();
|
|
223
|
+
acks.length = 0;
|
|
224
|
+
await ui.triggerManualReindex();
|
|
225
|
+
await until(() => acks.length === 1);
|
|
226
|
+
assert.equal(acks[0].success, true);
|
|
227
|
+
assert.equal(ui.reindexBtn.disabled, false);
|
|
228
|
+
|
|
229
|
+
runtime.handlers.get_styles = async () => { throw new Error('Styles unavailable'); };
|
|
230
|
+
await assert.rejects(runtime.handlers.index_scan({ deferComponents: true }), /Styles unavailable/);
|
|
231
|
+
assert.equal(ui.reindexBtn.disabled, false);
|
|
232
|
+
assert.match(ui.indexProgressText.textContent, /Styles unavailable/);
|
|
233
|
+
runtime.handlers.get_styles = async () => { runtime.figma.currentPage = { id: 'new-page', children: [] }; return {}; };
|
|
234
|
+
const cancelled = await runtime.handlers.index_scan({ deferComponents: true });
|
|
235
|
+
assert.equal(cancelled.cancelled, true);
|
|
236
|
+
assert.equal(ui.reindexBtn.disabled, false);
|
|
237
|
+
assert.match(ui.indexProgressText.textContent, /Document changed/);
|
|
238
|
+
runtime.handlers.get_styles = async () => ({});
|
|
239
|
+
acks.length = 0;
|
|
240
|
+
await runtime.handlers.index_scan({ deferComponents: true });
|
|
241
|
+
await until(() => acks.length === 1);
|
|
242
|
+
assert.equal(acks[0].pageId, 'new-page');
|
|
243
|
+
assert.equal(acks[0].success, true);
|
|
244
|
+
assert.equal(ui.reindexBtn.disabled, false);
|
|
245
|
+
|
|
246
|
+
const { benchmarkTargets } = await import('./bench-workloads.mjs');
|
|
247
|
+
page.type = 'PAGE';
|
|
248
|
+
const frame = page.children[0];
|
|
249
|
+
frame.parent = page;
|
|
250
|
+
frame.children = Array.from({ length: 600 }, (_, i) => ({ id: `bench:${i}`, type: 'TEXT',
|
|
251
|
+
name: `Child ${i}`, characters: `Text ${i}`, parent: frame, width: 10, height: 10 }));
|
|
252
|
+
let exported = 0;
|
|
253
|
+
frame.exportAsync = async () => { exported++; return new Uint8Array([1, 2, 3]); };
|
|
254
|
+
runtime.figma.currentPage = page;
|
|
255
|
+
runtime.figma.getNodeByIdAsync = async id => id === frame.id ? frame : null;
|
|
256
|
+
const operations = [];
|
|
257
|
+
ui.READ_OPS = ['read_nodes', 'index_scan', 'export_image'];
|
|
258
|
+
ui.dispatchToMain = async req => {
|
|
259
|
+
if (!req.operation) return;
|
|
260
|
+
operations.push(req.operation);
|
|
261
|
+
try {
|
|
262
|
+
const data = await runtime.handlers[req.operation](req.params);
|
|
263
|
+
ui.ws.send(JSON.stringify({ id: req.id, sessionId: ui.sessionId, success: true, data }));
|
|
264
|
+
} catch (error) {
|
|
265
|
+
ui.ws.send(JSON.stringify({ id: req.id, sessionId: ui.sessionId, success: false, error: error.message }));
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
const measurements = await benchmarkTargets(base, [{ sessionId: ui.sessionId, nodeId: frame.id }], 1);
|
|
269
|
+
const workloads = measurements.targets[0].workloads;
|
|
270
|
+
assert.equal(workloads.readFrame.samples[0].nodes, 601);
|
|
271
|
+
assert.equal(workloads.readFrame.samples[0].pages, 2);
|
|
272
|
+
assert.equal(workloads.readFrame.samples[0].complete, true);
|
|
273
|
+
assert.equal(workloads.indexFrame.samples[0].complete, true);
|
|
274
|
+
assert.equal(workloads.exportPng.samples[0].imageBytes, 3);
|
|
275
|
+
assert.equal(exported, 1);
|
|
276
|
+
assert.deepEqual(operations, ['read_nodes', 'read_nodes', 'index_scan', 'export_image']);
|
|
277
|
+
|
|
278
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
|
+
import { indexSyncUi } from './helpers/index-sync-ui.mjs';
|
|
2
3
|
import assert from 'node:assert/strict';
|
|
3
4
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
5
|
import os from 'node:os';
|
|
@@ -53,6 +54,20 @@ test('compact tree and scan preserve mixed typography instead of using the first
|
|
|
53
54
|
assert.equal(r.resolveTextStyle(node).fontWeight, undefined);
|
|
54
55
|
});
|
|
55
56
|
|
|
57
|
+
test('scan_design expands instances by default and flags opaque ones as incomplete', async () => {
|
|
58
|
+
const text = { id: '1:3', name: 'Label', type: 'TEXT', characters: 'Hi', fontSize: 12, fontName: { family: 'Inter', style: 'Regular' }, visible: true };
|
|
59
|
+
const inst = { id: '1:2', name: 'Button', type: 'INSTANCE', visible: true, children: [text], getMainComponentAsync: async () => null };
|
|
60
|
+
const frame = { id: '1:1', name: 'Frame', type: 'FRAME', visible: true, children: [inst] };
|
|
61
|
+
const r = runtime({ root: { id: '0:0' }, currentPage: { id: '0:1' }, getNodeByIdAsync: async () => frame });
|
|
62
|
+
const full = plain(await r.handlers.scan_design({ id: '1:1' }));
|
|
63
|
+
assert.equal(full.totals.textNodes, 1);
|
|
64
|
+
assert.equal(full.complete, true);
|
|
65
|
+
const opaque = plain(await r.handlers.scan_design({ id: '1:1', expandInstances: false }));
|
|
66
|
+
assert.equal(opaque.totals.textNodes, 0);
|
|
67
|
+
assert.equal(opaque.totals.opaqueInstances, 1);
|
|
68
|
+
assert.equal(opaque.complete, false);
|
|
69
|
+
});
|
|
70
|
+
|
|
56
71
|
test('equivalent CSS syntaxes preserve alpha in reads and writes', () => {
|
|
57
72
|
const r = runtime();
|
|
58
73
|
for (const color of ['rgba(255, 0, 0, .5)', 'rgb(100% 0% 0% / 50%)', 'hsl(0 100% 50% / .5)', 'hsla(0, 100%, 50%, .5)']) {
|
|
@@ -171,6 +186,76 @@ test('UI badge uses release version initially and follows the server on reconnec
|
|
|
171
186
|
assert.ok(html.includes('type: "runtime-ready"'));
|
|
172
187
|
});
|
|
173
188
|
|
|
189
|
+
test('sync waits for a matching bridge confirmation and ignores final and silent progress', async () => {
|
|
190
|
+
const ui = indexSyncUi(), messages = [];
|
|
191
|
+
const page = { id: 'page', children: [] };
|
|
192
|
+
const r = runtime({ currentPage: page, root: { name: 'File' },
|
|
193
|
+
ui: { postMessage: message => { messages.push(message); ui.handleIndexMessage(message); } } });
|
|
194
|
+
r.handlers.get_styles = async () => ({});
|
|
195
|
+
r.handlers.get_variables = async () => ({});
|
|
196
|
+
for (let i = 0; i < 2; i++) {
|
|
197
|
+
await r.handlers.index_scan({ deferComponents: true });
|
|
198
|
+
assert.equal(messages.at(-1).stage, 'done');
|
|
199
|
+
assert.equal(ui.reindexBtn.disabled, true);
|
|
200
|
+
assert.match(ui.indexProgressText.textContent, /confirmation/);
|
|
201
|
+
ui.receiveIndexAck({ scanId: 'old', pageId: 'page', success: true });
|
|
202
|
+
assert.equal(ui.reindexBtn.disabled, true);
|
|
203
|
+
const update = messages.findLast(msg => msg.type === 'index-update');
|
|
204
|
+
ui.receiveIndexAck({ ...update, success: true });
|
|
205
|
+
assert.equal(ui.reindexBtn.disabled, false);
|
|
206
|
+
assert.equal(ui.reindexBtn.classList.contains('indexing'), false);
|
|
207
|
+
assert.match(ui.reindexBtn.title, /Last synced/);
|
|
208
|
+
ui.handleIndexMessage({ ...update, type: 'index-progress', stage: 'done' });
|
|
209
|
+
ui.handleIndexMessage({ ...update, type: 'index-progress', stage: 'starting', silent: true });
|
|
210
|
+
assert.equal(ui.reindexBtn.disabled, false);
|
|
211
|
+
ui.fireTimers(1200);
|
|
212
|
+
assert.equal(ui.indexProgressWrap.classList.contains('active'), false);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('sync reports read failures, page cancellation, rejection, missing ack and disconnect', async () => {
|
|
217
|
+
const ui = indexSyncUi();
|
|
218
|
+
const page = { id: 'page', children: [] };
|
|
219
|
+
const r = runtime({ currentPage: page, root: { name: 'File' },
|
|
220
|
+
ui: { postMessage: message => ui.handleIndexMessage(message) } });
|
|
221
|
+
r.handlers.read_nodes = async () => { throw new Error('Cannot read layers'); };
|
|
222
|
+
await assert.rejects(r.handlers.index_scan({}), /Cannot read layers/);
|
|
223
|
+
assert.equal(ui.reindexBtn.disabled, false);
|
|
224
|
+
assert.match(ui.indexProgressText.textContent, /Cannot read layers/);
|
|
225
|
+
r.handlers.read_nodes = async () => {
|
|
226
|
+
r.figma.currentPage = { id: 'next' };
|
|
227
|
+
return { revision: 0, scope: { id: 'page' }, nodes: [] };
|
|
228
|
+
};
|
|
229
|
+
await r.handlers.index_scan({});
|
|
230
|
+
assert.equal(ui.reindexBtn.disabled, false);
|
|
231
|
+
assert.match(ui.indexProgressText.textContent, /Document changed/);
|
|
232
|
+
const start = { type: 'index-start', scanId: 'retry', pageId: 'page' };
|
|
233
|
+
ui.handleIndexMessage(start);
|
|
234
|
+
ui.receiveIndexAck({ ...start, success: false, error: 'Rejected stale revision' });
|
|
235
|
+
assert.match(ui.indexProgressText.textContent, /Rejected stale revision/);
|
|
236
|
+
ui.handleIndexMessage(start);
|
|
237
|
+
ui.handleIndexMessage({ ...start, type: 'index-update' });
|
|
238
|
+
ui.fireTimers(30000);
|
|
239
|
+
assert.equal(ui.reindexBtn.disabled, false);
|
|
240
|
+
assert.match(ui.indexProgressText.textContent, /No bridge confirmation/);
|
|
241
|
+
ui.ws.readyState = 3;
|
|
242
|
+
ui.handleIndexMessage(start);
|
|
243
|
+
assert.equal(ui.reindexBtn.disabled, false);
|
|
244
|
+
assert.match(ui.indexProgressText.textContent, /disconnected/);
|
|
245
|
+
assert.equal(ui.reindexBtn.title, 'Sync index');
|
|
246
|
+
ui.ws.readyState = 1;
|
|
247
|
+
ui.parent = { postMessage() {} };
|
|
248
|
+
ui.triggerManualReindex();
|
|
249
|
+
ui.fireTimers(30000);
|
|
250
|
+
assert.equal(ui.reindexBtn.disabled, false);
|
|
251
|
+
assert.match(ui.indexProgressText.textContent, /Canvas did not start/);
|
|
252
|
+
ui.handleIndexMessage(start);
|
|
253
|
+
ui.receiveIndexAck({ ...start, success: true });
|
|
254
|
+
ui.handleIndexMessage({ ...start, scanId: 'new' });
|
|
255
|
+
ui.fireTimers(1200);
|
|
256
|
+
assert.equal(ui.indexProgressWrap.classList.contains('active'), true);
|
|
257
|
+
});
|
|
258
|
+
|
|
174
259
|
test('build rejects wrong release tags and checked-in bundle matches source', () => {
|
|
175
260
|
const check = spawnSync(process.execPath, ['scripts/build-plugin.js', '--check'], { encoding: 'utf8' });
|
|
176
261
|
assert.equal(check.status, 0, check.stderr);
|
|
@@ -204,8 +289,8 @@ test('agent setup detects installed clients and preserves existing JSONC and TOM
|
|
|
204
289
|
configureAgent(agent);
|
|
205
290
|
const config = parseJsonc(readFileSync(agent.configPath, 'utf8')).mcpServers['figma-rust-mcp'];
|
|
206
291
|
assert.ok(config);
|
|
207
|
-
if (id === 'antigravity') assert.equal(config.serverUrl, 'http://127.0.0.1:
|
|
208
|
-
else assert.equal(config.url, 'http://127.0.0.1:
|
|
292
|
+
if (id === 'antigravity') assert.equal(config.serverUrl, 'http://127.0.0.1:41730/sse');
|
|
293
|
+
else assert.equal(config.url, 'http://127.0.0.1:41730/mcp');
|
|
209
294
|
}
|
|
210
295
|
|
|
211
296
|
const cursor = agents.find(agent => agent.id === 'cursor');
|
|
@@ -215,21 +300,21 @@ test('agent setup detects installed clients and preserves existing JSONC and TOM
|
|
|
215
300
|
const cursorConfig = parseJsonc(cursorText);
|
|
216
301
|
assert.match(cursorText, /keep this note/);
|
|
217
302
|
assert.equal(cursorConfig.mcpServers.other.url, 'http://example.test');
|
|
218
|
-
assert.equal(cursorConfig.mcpServers['figma-rust-mcp'].url, 'http://127.0.0.1:
|
|
303
|
+
assert.equal(cursorConfig.mcpServers['figma-rust-mcp'].url, 'http://127.0.0.1:41730/mcp');
|
|
219
304
|
assert.equal(configureAgent(cursor), false);
|
|
220
305
|
|
|
221
306
|
const zed = agents.find(agent => agent.id === 'zed');
|
|
222
307
|
writeFileSync(zed.configPath, '{\n "theme": "One Dark",\n}\n');
|
|
223
308
|
configureAgent(zed);
|
|
224
309
|
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:
|
|
310
|
+
assert.equal(parseJsonc(readFileSync(zed.configPath, 'utf8')).context_servers['figma-rust-mcp'].url, 'http://127.0.0.1:41730/mcp');
|
|
226
311
|
|
|
227
312
|
const codex = agents.find(agent => agent.id === 'codex');
|
|
228
313
|
writeFileSync(codex.configPath, '[mcp_servers.other]\nurl = "http://other.test"\n');
|
|
229
314
|
configureAgent(codex);
|
|
230
315
|
const codexText = readFileSync(codex.configPath, 'utf8');
|
|
231
316
|
assert.match(codexText, /\[mcp_servers\.other\]/);
|
|
232
|
-
assert.match(codexText, /\[mcp_servers\.figma-rust-mcp\]\nurl = "http:\/\/127\.0\.0\.1:
|
|
317
|
+
assert.match(codexText, /\[mcp_servers\.figma-rust-mcp\]\nurl = "http:\/\/127\.0\.0\.1:41730\/mcp"/);
|
|
233
318
|
assert.equal(configureAgent(codex), false);
|
|
234
319
|
} finally {
|
|
235
320
|
rmSync(home, { recursive: true, force: true });
|
|
@@ -525,11 +610,11 @@ test('old UI socket callbacks cannot clear or dispatch through a replacement soc
|
|
|
525
610
|
close() {} send() {}
|
|
526
611
|
}
|
|
527
612
|
const r = vm.createContext({
|
|
528
|
-
ws: null, wsConnected: false, sessionId: null, fileName: 'File', documentId: 'doc', BRIDGE: 'http://localhost:
|
|
613
|
+
uiDisposed: false, ws: null, wsConnected: false, sessionId: null, fileName: 'File', documentId: 'doc', BRIDGE: 'http://localhost:41730',
|
|
529
614
|
WebSocket: Socket, setInterval: () => 1, clearInterval: () => {}, setTimeout: () => 1, clearTimeout: () => {},
|
|
530
615
|
sendRuntimeCapabilities: () => {}, consecutiveErrors: 0, everConnected: false, retryTimer: null, polling: true,
|
|
531
|
-
setStatus: () => {}, log: () => {}, currentPort:
|
|
532
|
-
startLongPoll: () => assert.fail('stale socket started poll'), dispatchToMain: req => dispatched.push(req),
|
|
616
|
+
setStatus: () => {}, log: () => {}, currentPort: 41730, READ_OPS: [],
|
|
617
|
+
activeIndexScan: null, startLongPoll: () => assert.fail('stale socket started poll'), dispatchToMain: req => dispatched.push(req),
|
|
533
618
|
});
|
|
534
619
|
vm.runInContext(source, r);
|
|
535
620
|
r.connectWs(); assert.equal(sockets.length, 0);
|
|
@@ -574,3 +659,156 @@ test('tree reads preserve Regular font weight, collect Regular font tokens, and
|
|
|
574
659
|
assert.equal(tree.textStyle, 'Lato/18px/regular');
|
|
575
660
|
assert.ok(tokenCollector.fonts.has('Lato/Regular/18px'));
|
|
576
661
|
});
|
|
662
|
+
|
|
663
|
+
test('hot reload removes old Figma listeners and timers before installing the next runtime', async () => {
|
|
664
|
+
const events = new Map(), pageEvents = new Set(), timers = new Map(), messages = [];
|
|
665
|
+
let timerId = 0;
|
|
666
|
+
const page = { id: 'page', children: [], selection: [], on: (_, fn) => pageEvents.add(fn), off: (_, fn) => pageEvents.delete(fn) };
|
|
667
|
+
let styleName = 'Before';
|
|
668
|
+
const figma = { getLocalTextStylesAsync: async () => [{ id: 'text-style', name: styleName }], currentPage: page, root: { name: 'File', getPluginData: () => 'document' },
|
|
669
|
+
clientStorage: { getAsync: async () => null }, ui: { postMessage: msg => messages.push(msg) },
|
|
670
|
+
on: (event, fn) => { if (!events.has(event)) events.set(event, new Set()); events.get(event).add(fn); },
|
|
671
|
+
off: (event, fn) => events.get(event)?.delete(fn) };
|
|
672
|
+
const main = readFileSync('plugin-src/main.js', 'utf8');
|
|
673
|
+
const runtimes = [];
|
|
674
|
+
for (let i = 0; i < 3; i++) {
|
|
675
|
+
const r = runtime(figma);
|
|
676
|
+
r.setTimeout = (fn, delay) => { timers.set(++timerId, { fn, delay }); return timerId; };
|
|
677
|
+
r.clearTimeout = id => timers.delete(id);
|
|
678
|
+
r.__html__ = '';
|
|
679
|
+
vm.runInContext(main, r);
|
|
680
|
+
runtimes.push(r);
|
|
681
|
+
for (const listeners of events.values()) assert.equal(listeners.size, 1);
|
|
682
|
+
assert.equal(pageEvents.size, 1);
|
|
683
|
+
assert.equal(timers.size, 1, 'only the current startup timer survives');
|
|
684
|
+
}
|
|
685
|
+
assert.equal(runtimes[0].runtimeDisposed, true);
|
|
686
|
+
messages.length = 0;
|
|
687
|
+
for (const fn of events.get('selectionchange')) fn();
|
|
688
|
+
assert.equal(messages.filter(msg => msg.type === 'selection-change').length, 1);
|
|
689
|
+
const live = runtimes.at(-1);
|
|
690
|
+
assert.equal((await live.getStyleNameMapAsync())['text-style'], 'Before');
|
|
691
|
+
styleName = 'After';
|
|
692
|
+
for (const fn of events.get('stylechange')) fn({});
|
|
693
|
+
assert.equal((await live.getStyleNameMapAsync())['text-style'], 'After');
|
|
694
|
+
let releaseStyles;
|
|
695
|
+
live.handlers.get_styles = () => new Promise(resolve => { releaseStyles = resolve; });
|
|
696
|
+
live.handlers.get_variables = async () => ({});
|
|
697
|
+
const scan = live.handlers.index_scan({ deferComponents: true });
|
|
698
|
+
while (!releaseStyles) await new Promise(resolve => setTimeout(resolve, 0));
|
|
699
|
+
figma.ui.onmessage.dispose();
|
|
700
|
+
messages.length = 0;
|
|
701
|
+
releaseStyles({});
|
|
702
|
+
assert.equal((await scan).cancelled, true);
|
|
703
|
+
assert.ok(!messages.some(msg => msg.type === 'index-update'));
|
|
704
|
+
assert.equal(timers.size, 0);
|
|
705
|
+
assert.equal(pageEvents.size, 0);
|
|
706
|
+
for (const listeners of events.values()) assert.equal(listeners.size, 0);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
test('style names share in-flight reads and refresh after invalidation or a failed getter', async () => {
|
|
710
|
+
const calls = {};
|
|
711
|
+
let name = 'Before', failPaint = false;
|
|
712
|
+
const figma = Object.fromEntries(['Paint', 'Text', 'Effect', 'Grid'].map(kind => [`getLocal${kind}StylesAsync`, async () => {
|
|
713
|
+
calls[kind] = (calls[kind] || 0) + 1;
|
|
714
|
+
if (kind === 'Paint' && failPaint) throw new Error('temporary failure');
|
|
715
|
+
return [{ id: kind, name }];
|
|
716
|
+
}]));
|
|
717
|
+
const r = runtime(figma);
|
|
718
|
+
const [first, second] = await Promise.all([r.makeWalkStateAsync({}), r.makeWalkStateAsync({})]);
|
|
719
|
+
assert.equal(first.styleMap.Text, 'Before');
|
|
720
|
+
assert.equal(second.styleMap.Paint, 'Before');
|
|
721
|
+
assert.deepEqual(Object.values(calls), [1, 1, 1, 1]);
|
|
722
|
+
name = 'After'; r.invalidateStyleNameMap();
|
|
723
|
+
assert.equal((await r.makeWalkStateAsync({})).styleMap.Text, 'After');
|
|
724
|
+
assert.deepEqual(Object.values(calls), [2, 2, 2, 2]);
|
|
725
|
+
failPaint = true; r.invalidateStyleNameMap();
|
|
726
|
+
assert.equal((await r.makeWalkStateAsync({})).styleMap.Paint, undefined);
|
|
727
|
+
failPaint = false;
|
|
728
|
+
assert.equal((await r.makeWalkStateAsync({})).styleMap.Paint, 'After');
|
|
729
|
+
assert.equal(calls.Paint, 4);
|
|
730
|
+
let release;
|
|
731
|
+
r.figma.getLocalPaintStylesAsync = () => new Promise(resolve => { release = resolve; });
|
|
732
|
+
r.invalidateStyleNameMap();
|
|
733
|
+
const old = r.getStyleNameMapAsync();
|
|
734
|
+
r.invalidateStyleNameMap();
|
|
735
|
+
r.figma.getLocalPaintStylesAsync = async () => [{ id: 'Paint', name: 'Newest' }];
|
|
736
|
+
assert.equal((await r.getStyleNameMapAsync()).Paint, 'Newest');
|
|
737
|
+
release([{ id: 'Paint', name: 'Obsolete' }]);
|
|
738
|
+
await old;
|
|
739
|
+
assert.equal((await r.getStyleNameMapAsync()).Paint, 'Newest');
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
for (const lineEnding of ['\n', '\r\n']) test(`UI reload clears previous timers, listeners and socket while keeping inline controls available (${lineEnding === '\n' ? 'LF' : 'CRLF'})`, () => {
|
|
743
|
+
const html = readFileSync('plugin-runtime/ui.html', 'utf8');
|
|
744
|
+
const source = html.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/\r?\n/g, lineEnding);
|
|
745
|
+
const timers = new Set();
|
|
746
|
+
let timerId = 0, socketCloses = 0, cancelledRaf = 0;
|
|
747
|
+
const elements = new Map();
|
|
748
|
+
const target = () => {
|
|
749
|
+
const listeners = new Map();
|
|
750
|
+
return {
|
|
751
|
+
listeners, style: {}, dataset: {}, classList: { add() {}, remove() {}, contains: () => false, toggle() {} },
|
|
752
|
+
addEventListener(name, fn, options) {
|
|
753
|
+
if (options?.signal?.aborted) return;
|
|
754
|
+
if (!listeners.has(name)) listeners.set(name, new Set());
|
|
755
|
+
listeners.get(name).add(fn);
|
|
756
|
+
options?.signal?.addEventListener('abort', () => listeners.get(name).delete(fn), { once: true });
|
|
757
|
+
},
|
|
758
|
+
removeEventListener(name, fn) { listeners.get(name)?.delete(fn); },
|
|
759
|
+
setPointerCapture() {}, releasePointerCapture() {},
|
|
760
|
+
setAttribute() {}, querySelectorAll: () => [], querySelector: () => null,
|
|
761
|
+
};
|
|
762
|
+
};
|
|
763
|
+
const window = { ...target(), innerWidth: 360, innerHeight: 600,
|
|
764
|
+
setTimeout() { timers.add(++timerId); return timerId; }, clearTimeout: id => timers.delete(id),
|
|
765
|
+
setInterval() { timers.add(++timerId); return timerId; }, clearInterval: id => timers.delete(id),
|
|
766
|
+
};
|
|
767
|
+
const document = { ...target(), getElementById: id => {
|
|
768
|
+
if (!elements.has(id)) elements.set(id, target());
|
|
769
|
+
return elements.get(id);
|
|
770
|
+
}, querySelectorAll: () => [] };
|
|
771
|
+
class Socket {
|
|
772
|
+
static OPEN = 1;
|
|
773
|
+
constructor() { this.readyState = 1; }
|
|
774
|
+
close() { socketCloses++; }
|
|
775
|
+
send() {}
|
|
776
|
+
}
|
|
777
|
+
const r = vm.createContext({ window, document, WebSocket: Socket, AbortController, AbortSignal,
|
|
778
|
+
console, navigator: {}, fetch: () => new Promise(() => {}), parent: { postMessage() {} },
|
|
779
|
+
requestAnimationFrame: () => 42, cancelAnimationFrame: () => cancelledRaf++ });
|
|
780
|
+
for (let i = 0; i < 3; i++) {
|
|
781
|
+
const entryPoint = /^( {4})initConnection\(\);(?=\r?$)/m;
|
|
782
|
+
assert.match(source, entryPoint, 'startup entry point must be replaced by the socket fixture');
|
|
783
|
+
vm.runInContext(source.replace(entryPoint, "$1sessionId = 's'; connectWs();"), r);
|
|
784
|
+
assert.equal(timers.size, 1, 'only current stats interval remains');
|
|
785
|
+
assert.equal(window.listeners.get('focus').size, 1);
|
|
786
|
+
assert.equal(document.listeners.get('keydown').size, 1);
|
|
787
|
+
assert.equal(document.listeners.get('visibilitychange').size, 1);
|
|
788
|
+
for (const name of [...html.matchAll(/onclick="([A-Za-z][A-Za-z0-9_]*)\(/g)].map(match => match[1]).filter(name => name !== 'document')) {
|
|
789
|
+
assert.equal(typeof window[name], 'function', `${name} must remain available to inline controls`);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
// Cleanup also covers a resize in progress.
|
|
793
|
+
const grip = elements.get('resize-grip');
|
|
794
|
+
for (const fn of grip.listeners.get('pointerdown')) fn({ preventDefault() {}, pointerId: 1, clientX: 0, clientY: 0 });
|
|
795
|
+
for (const fn of window.listeners.get('pointermove')) fn({ clientX: 10, clientY: 10 });
|
|
796
|
+
window.__disposeFigmaMcpUi();
|
|
797
|
+
assert.equal(timers.size, 0);
|
|
798
|
+
assert.equal(cancelledRaf, 1);
|
|
799
|
+
for (const listeners of window.listeners.values()) assert.equal(listeners.size, 0);
|
|
800
|
+
for (const listeners of document.listeners.values()) assert.equal(listeners.size, 0);
|
|
801
|
+
window.__disposeFigmaMcpUi();
|
|
802
|
+
assert.equal(cancelledRaf, 1, 'cleanup is idempotent');
|
|
803
|
+
assert.equal(socketCloses, 3);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
test('creating a paint style invalidates the style names before the next design read', async () => {
|
|
808
|
+
const styles = [];
|
|
809
|
+
const r = runtime({ getLocalPaintStylesAsync: async () => styles,
|
|
810
|
+
createPaintStyle: () => { const style = { id: 'new-style' }; styles.push(style); return style; } });
|
|
811
|
+
assert.equal((await r.getStyleNameMapAsync())['new-style'], undefined);
|
|
812
|
+
await r.handlers.createPaintStyle({ name: 'Brand/New', color: '#ff0000' });
|
|
813
|
+
assert.equal((await r.getStyleNameMapAsync())['new-style'], 'Brand/New');
|
|
814
|
+
});
|