@1agh/maude 0.58.3 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/apps/studio/annotations-layer.tsx +49 -15
  2. package/apps/studio/bin/_import-asset.mjs +18 -0
  3. package/apps/studio/bin/_import-figma.mjs +1180 -242
  4. package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
  5. package/apps/studio/bin/_perf-probe.mjs +228 -0
  6. package/apps/studio/bin/_perf-shared.mjs +345 -0
  7. package/apps/studio/bin/_video-playwright.mjs +17 -4
  8. package/apps/studio/bin/import-figma.sh +10 -1
  9. package/apps/studio/bin/perf.sh +228 -0
  10. package/apps/studio/bin/smoke.sh +49 -5
  11. package/apps/studio/canvas-lib.tsx +148 -6
  12. package/apps/studio/client/app.jsx +152 -37
  13. package/apps/studio/client/panels/SyncPanel.jsx +320 -0
  14. package/apps/studio/client/panels/TimelinePanel.jsx +29 -1
  15. package/apps/studio/client/panels/timeline-comp-target.js +101 -0
  16. package/apps/studio/client/styles/3-shell-maude.css +40 -0
  17. package/apps/studio/client/styles/4-components.css +4 -4
  18. package/apps/studio/context.ts +4 -0
  19. package/apps/studio/dist/client.bundle.js +772 -772
  20. package/apps/studio/dist/styles.css +1 -1
  21. package/apps/studio/exporters/video-encode-lib.ts +8 -5
  22. package/apps/studio/exporters/video.ts +10 -0
  23. package/apps/studio/figma/assets.test.ts +92 -0
  24. package/apps/studio/figma/assets.ts +63 -9
  25. package/apps/studio/figma/codegen-client.test.ts +276 -0
  26. package/apps/studio/figma/codegen-client.ts +509 -0
  27. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  28. package/apps/studio/figma/codegen-fonts.ts +195 -0
  29. package/apps/studio/figma/codegen-values.test.ts +179 -0
  30. package/apps/studio/figma/codegen-values.ts +270 -0
  31. package/apps/studio/figma/endpoints.ts +73 -0
  32. package/apps/studio/figma/fig-decode.test.ts +788 -0
  33. package/apps/studio/figma/fig-decode.ts +839 -0
  34. package/apps/studio/figma/fig-differential.test.ts +182 -0
  35. package/apps/studio/figma/fig-kiwi.ts +410 -0
  36. package/apps/studio/figma/fig-translator.test.ts +192 -0
  37. package/apps/studio/figma/fig-vector.test.ts +113 -0
  38. package/apps/studio/figma/fig-vector.ts +145 -0
  39. package/apps/studio/figma/fig-zip.ts +270 -0
  40. package/apps/studio/figma/from-codegen.test.ts +408 -0
  41. package/apps/studio/figma/from-codegen.ts +1103 -0
  42. package/apps/studio/figma/sanitize.test.ts +69 -0
  43. package/apps/studio/figma/sanitize.ts +146 -47
  44. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  45. package/apps/studio/figma/tailwind-map.ts +545 -0
  46. package/apps/studio/figma/to-artboard.ts +41 -1
  47. package/apps/studio/figma/to-render.ts +25 -3
  48. package/apps/studio/figma/types.ts +6 -1
  49. package/apps/studio/http.ts +94 -0
  50. package/apps/studio/sync/asset-push-worker.ts +84 -0
  51. package/apps/studio/sync/asset-push.ts +441 -39
  52. package/apps/studio/sync/asset-sweep.ts +262 -0
  53. package/apps/studio/sync/connection-state.ts +71 -3
  54. package/apps/studio/sync/index.ts +39 -6
  55. package/apps/studio/sync/presentation.ts +21 -0
  56. package/apps/studio/sync/status.ts +18 -0
  57. package/apps/studio/sync/supervisor.ts +20 -0
  58. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  59. package/apps/studio/test/figma-explode.test.ts +438 -0
  60. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  61. package/apps/studio/test/import-figma.test.ts +192 -4
  62. package/apps/studio/test/sync-asset-push-worker.test.ts +183 -0
  63. package/apps/studio/test/sync-asset-push.test.ts +639 -47
  64. package/apps/studio/test/sync-asset-sweep.test.ts +243 -0
  65. package/apps/studio/test/sync-connection-state.test.ts +66 -0
  66. package/apps/studio/test/sync-panel-surface.test.ts +123 -0
  67. package/apps/studio/test/sync-resync-routes.test.ts +125 -0
  68. package/apps/studio/test/sync-status.test.ts +28 -0
  69. package/apps/studio/test/sync-supervisor.test.ts +46 -0
  70. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  71. package/apps/studio/test/video-comp.test.ts +81 -1
  72. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  73. package/apps/studio/use-artboard-drag.tsx +37 -3
  74. package/apps/studio/video-comp.tsx +51 -0
  75. package/apps/studio/whats-new.json +87 -0
  76. package/cli/commands/design.mjs +7 -0
  77. package/cli/commands/kg.mjs +8 -1
  78. package/cli/commands/kg.test.mjs +24 -0
  79. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  80. package/cli/lib/figma-import-controls.test.mjs +70 -0
  81. package/package.json +8 -8
@@ -0,0 +1,332 @@
1
+ #!/usr/bin/env node
2
+ // _perf-probe-safari.mjs — WebKit lane for `maude design perf` (`perf.sh --engine safari`).
3
+ //
4
+ // Why this exists: the Chromium lane (`_perf-probe.mjs`) measured a 128-artboard
5
+ // canvas at a warm 60fps while the user was reporting unusable pan/zoom. The
6
+ // pain lives on WebKit — the Tauri desktop shell is WKWebView — and Blink
7
+ // simply does not reproduce it. Optimising against the engine that is already
8
+ // fast is how a performance project ships a refactor that changes nothing.
9
+ //
10
+ // Safari is not WKWebView, but it is the same engine family (JavaScriptCore +
11
+ // WebKit compositing + WebKit's `will-change` / re-raster semantics), which is
12
+ // what the hypotheses under test are about. Treat it as the closest measurable
13
+ // proxy, not as the desktop shell itself.
14
+ //
15
+ // Drives Safari through safaridriver's W3C WebDriver server over plain HTTP —
16
+ // no client library, keeping the dev-server's zero-dependency posture. Requires
17
+ // a one-time `safaridriver --enable` (admin auth), done by the operator.
18
+ //
19
+ // Usage:
20
+ // node _perf-probe-safari.mjs --url <canvas-shell-url> [--label <name>]
21
+ // [--history <path>] [--variant <tag>] [--inject-css <css>]
22
+ // [--pan N] [--zoom N] [--repeat N] [--timeout S] [--driver-port N] [--json]
23
+
24
+ import { spawn } from 'node:child_process';
25
+ import { appendFileSync, mkdirSync } from 'node:fs';
26
+ import { dirname } from 'node:path';
27
+
28
+ import {
29
+ buildRow,
30
+ harnessSource,
31
+ medianOf,
32
+ parseAndValidateResult,
33
+ readHistory,
34
+ renderReport,
35
+ } from './_perf-shared.mjs';
36
+
37
+ function parseArgs(argv) {
38
+ const out = {
39
+ url: '',
40
+ label: '',
41
+ history: '',
42
+ variant: '',
43
+ injectCss: '',
44
+ pan: 60,
45
+ zoom: 40,
46
+ repeat: 3,
47
+ timeout: 60,
48
+ driverPort: 4488,
49
+ fitAll: false,
50
+ setFlags: [],
51
+ studio: '',
52
+ parentCss: '',
53
+ json: false,
54
+ };
55
+ for (let i = 0; i < argv.length; i++) {
56
+ const a = argv[i];
57
+ if (a === '--url') out.url = argv[++i];
58
+ else if (a === '--label') out.label = argv[++i];
59
+ else if (a === '--history') out.history = argv[++i];
60
+ else if (a === '--variant') out.variant = argv[++i];
61
+ else if (a === '--inject-css') out.injectCss = argv[++i];
62
+ else if (a === '--pan') out.pan = Number(argv[++i]);
63
+ else if (a === '--zoom') out.zoom = Number(argv[++i]);
64
+ else if (a === '--repeat') out.repeat = Number(argv[++i]);
65
+ else if (a === '--timeout') out.timeout = Number(argv[++i]);
66
+ else if (a === '--driver-port') out.driverPort = Number(argv[++i]);
67
+ else if (a === '--fit-all') out.fitAll = true;
68
+ else if (a === '--set-flag') out.setFlags.push(argv[++i]);
69
+ else if (a === '--studio') out.studio = argv[++i];
70
+ else if (a === '--parent-css') out.parentCss = argv[++i];
71
+ else if (a === '--json') out.json = true;
72
+ else {
73
+ console.error(`_perf-probe-safari.mjs: unknown arg '${a}'`);
74
+ process.exit(2);
75
+ }
76
+ }
77
+ if (!out.url) {
78
+ console.error('_perf-probe-safari.mjs: --url is required');
79
+ process.exit(2);
80
+ }
81
+ return out;
82
+ }
83
+
84
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
85
+
86
+ async function wd(port, method, path, body) {
87
+ const res = await fetch(`http://localhost:${port}${path}`, {
88
+ method,
89
+ headers: { 'Content-Type': 'application/json' },
90
+ body: body === undefined ? undefined : JSON.stringify(body),
91
+ });
92
+ const json = await res.json().catch(() => ({}));
93
+ if (json?.value?.error) {
94
+ throw new Error(`${json.value.error}: ${json.value.message}`);
95
+ }
96
+ return json.value;
97
+ }
98
+
99
+ /**
100
+ * Is a real W3C WebDriver answering on this port?
101
+ *
102
+ * "Something responded" is not the same question. A same-UID process squatting
103
+ * the port would receive the harness, the URLs under test and any --parent-css,
104
+ * and could answer with whatever it likes — and this probe trusts driver
105
+ * responses. So check that the body has the W3C `value.ready` shape before
106
+ * adopting a listener we did not start.
107
+ */
108
+ async function statusLooksLikeWebDriver(port) {
109
+ try {
110
+ const res = await fetch(`http://localhost:${port}/status`, {
111
+ signal: AbortSignal.timeout(2000),
112
+ });
113
+ const json = await res.json();
114
+ return !!json && typeof json === 'object' && typeof json.value?.ready === 'boolean';
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Start safaridriver unless a real one is already listening. Returns the child
122
+ * we spawned (so the caller can kill it) or null when we adopted an existing
123
+ * driver — leaving a browser-automation server running after every benchmark
124
+ * would be its own small hazard.
125
+ */
126
+ async function ensureDriver(port) {
127
+ if (await statusLooksLikeWebDriver(port)) return null;
128
+ const child = spawn('safaridriver', ['-p', String(port)], { stdio: 'ignore' });
129
+ for (let i = 0; i < 15; i++) {
130
+ await sleep(500);
131
+ if (await statusLooksLikeWebDriver(port)) return child;
132
+ }
133
+ child.kill();
134
+ throw new Error(
135
+ `nothing that looks like a W3C driver answered on :${port}. ` +
136
+ 'Run `safaridriver --enable` once (needs admin auth), and check nothing else holds the port.'
137
+ );
138
+ }
139
+
140
+ async function main() {
141
+ const opts = parseArgs(process.argv.slice(2));
142
+ if (!Number.isInteger(opts.driverPort) || opts.driverPort < 1024 || opts.driverPort > 65535) {
143
+ console.error('_perf-probe-safari.mjs: --driver-port must be an integer in [1024, 65535]');
144
+ process.exit(2);
145
+ }
146
+ const driverChild = await ensureDriver(opts.driverPort);
147
+
148
+ let sessionId = null;
149
+ try {
150
+ const created = await wd(opts.driverPort, 'POST', '/session', {
151
+ capabilities: { alwaysMatch: { browserName: 'safari' } },
152
+ });
153
+ sessionId = created.sessionId;
154
+ } catch (err) {
155
+ console.error(
156
+ `_perf-probe-safari.mjs: could not create a Safari session — ${err.message}\n` +
157
+ 'If it says "already paired", quit Safari (or `pkill -x safaridriver`) and retry.'
158
+ );
159
+ // This exit bypasses the `finally` below, so the driver has to be cleaned up
160
+ // here: session-refused ("already paired") is exactly the path a retry loop
161
+ // hits, and each attempt would otherwise leave another driver behind.
162
+ if (driverChild) driverChild.kill();
163
+ process.exit(1);
164
+ }
165
+
166
+ const base = `/session/${sessionId}`;
167
+ const exec = (script, args = []) =>
168
+ wd(opts.driverPort, 'POST', `${base}/execute/sync`, { script, args });
169
+
170
+ try {
171
+ // A real, visible window — WebKit throttles rAF in occluded/background
172
+ // windows, and a throttled measurement would read as catastrophic jank
173
+ // that no user is experiencing.
174
+ await wd(opts.driverPort, 'POST', `${base}/window/rect`, {
175
+ x: 0,
176
+ y: 0,
177
+ width: 1600,
178
+ height: 1000,
179
+ });
180
+ await wd(opts.driverPort, 'POST', `${base}/url`, { url: opts.url });
181
+
182
+ // Studio mode measures the canvas AS THE USER RUNS IT: inside the studio
183
+ // shell's iframe, not as a bare `_canvas-shell.html` page. That difference
184
+ // is not cosmetic — the canvas postMessages `active-artboard` to the parent
185
+ // on every viewport publish, and the parent answers with a setState in the
186
+ // 15k-line studio component (app.jsx, the 'active-artboard' branch). A
187
+ // shell-only probe never pays that cost, so it can report a comfortable
188
+ // 60fps for a canvas the user finds unusable.
189
+ if (opts.studio) {
190
+ const rowSel = `[data-testid="canvas-row-${opts.studio}"]`;
191
+ let clicked = false;
192
+ for (let i = 0; i < 20; i++) {
193
+ const ok = await exec(
194
+ `const el = document.querySelector(${JSON.stringify(rowSel)}); if (el) { el.click(); return true; } return false;`
195
+ );
196
+ if (ok) {
197
+ clicked = true;
198
+ break;
199
+ }
200
+ await sleep(1000);
201
+ }
202
+ if (!clicked) {
203
+ console.error(
204
+ `_perf-probe-safari.mjs: no ${rowSel} in the studio file tree — check the slug`
205
+ );
206
+ process.exit(1);
207
+ }
208
+ // Wait for the frame, then switch WebDriver's context into it. The canvas
209
+ // runs on its own origin (DDR-054), so script cannot reach it from the
210
+ // parent — WebDriver frame switching is the only way in.
211
+ let frameFound = false;
212
+ for (let i = 0; i < 30; i++) {
213
+ await sleep(1000);
214
+ const n = await exec(
215
+ 'return document.querySelectorAll(\'[data-testid="canvas-frame"]\').length;'
216
+ );
217
+ if (Number(n) > 0) {
218
+ frameFound = true;
219
+ break;
220
+ }
221
+ }
222
+ if (!frameFound) {
223
+ console.error('_perf-probe-safari.mjs: canvas-frame iframe never appeared');
224
+ process.exit(1);
225
+ }
226
+ // Parent-side CSS lever. The canvas lives on its own origin, so the
227
+ // in-frame --inject-css cannot reach the studio chrome that wraps it —
228
+ // and the studio wrapper is exactly what this mode exists to interrogate.
229
+ if (opts.parentCss) {
230
+ await exec(
231
+ `const st = document.createElement('style'); st.id = 'maude-perf-parent'; st.textContent = arguments[0]; document.head.appendChild(st);`,
232
+ [opts.parentCss]
233
+ );
234
+ await sleep(500);
235
+ }
236
+
237
+ const frameEl = await wd(opts.driverPort, 'POST', `${base}/element`, {
238
+ using: 'css selector',
239
+ value: '[data-testid="canvas-frame"]',
240
+ });
241
+ await wd(opts.driverPort, 'POST', `${base}/frame`, { id: frameEl });
242
+ }
243
+
244
+ let ready = false;
245
+ for (let i = 0; i < opts.timeout; i++) {
246
+ await sleep(1000);
247
+ const n = await exec("return document.querySelectorAll('[data-dc-screen]').length;");
248
+ if (Number(n) > 0) {
249
+ ready = true;
250
+ break;
251
+ }
252
+ }
253
+ if (!ready) {
254
+ console.error(
255
+ `_perf-probe-safari.mjs: no [data-dc-screen] after ${opts.timeout}s — wrong URL, or the canvas failed to mount`
256
+ );
257
+ process.exit(1);
258
+ }
259
+
260
+ const passes = [];
261
+ const total = Math.max(1, opts.repeat);
262
+ for (let i = 0; i < total; i++) {
263
+ const started = await exec(
264
+ `return ${harnessSource({ pan: opts.pan, zoom: opts.zoom, injectCss: opts.injectCss, fitAll: opts.fitAll, setFlags: opts.setFlags })};`
265
+ );
266
+ if (started === 'NO_HOST') {
267
+ console.error('_perf-probe-safari.mjs: no .dc-canvas host on the page');
268
+ process.exit(1);
269
+ }
270
+ let r = null;
271
+ for (let t = 0; t < opts.timeout; t++) {
272
+ await sleep(1000);
273
+ const raw = await exec('return JSON.stringify(window.__maudePerfResult);');
274
+ if (raw && raw !== 'null') {
275
+ r = parseAndValidateResult(raw);
276
+ if (!r) {
277
+ console.error(
278
+ '_perf-probe-safari.mjs: the page returned a malformed result — refusing to print or ' +
279
+ 'record it. window.__maudePerfResult lives in the untrusted canvas origin (DDR-054).'
280
+ );
281
+ process.exit(1);
282
+ }
283
+ break;
284
+ }
285
+ }
286
+ if (!r) {
287
+ console.error(`_perf-probe-safari.mjs: gesture never completed within ${opts.timeout}s`);
288
+ process.exit(1);
289
+ }
290
+ if (!r.panApplied || !r.zoomApplied) {
291
+ console.error(
292
+ `_perf-probe-safari.mjs: gesture did not reach the canvas on pass ${i + 1} ` +
293
+ `(pan: ${r.panApplied}, zoom: ${r.zoomApplied}) — refusing to record a meaningless row.`
294
+ );
295
+ process.exit(1);
296
+ }
297
+ if (i > 0 || total === 1) passes.push(r);
298
+ process.stderr.write(
299
+ `→ pass ${i + 1}/${total}: gesture p95 ${r.gesture.p95}ms` +
300
+ `${i === 0 && total > 1 ? ' (warm-up, discarded)' : ''}\n`
301
+ );
302
+ }
303
+
304
+ const result = medianOf(passes);
305
+ const label = (opts.label || opts.url) + (opts.variant ? ` [${opts.variant}]` : '');
306
+ const engineTag = 'webkit-safari';
307
+ const prev = readHistory(opts.history, label, engineTag);
308
+ const row = buildRow({ result, label, engineTag, opts });
309
+
310
+ if (opts.history) {
311
+ mkdirSync(dirname(opts.history), { recursive: true });
312
+ appendFileSync(opts.history, `${JSON.stringify(row)}\n`, 'utf8');
313
+ }
314
+ if (opts.json) {
315
+ console.log(JSON.stringify({ current: row, previous: prev }, null, 2));
316
+ } else {
317
+ console.log(renderReport({ row, prev, opts, label, engineTag }));
318
+ }
319
+ } finally {
320
+ if (sessionId) {
321
+ await wd(opts.driverPort, 'DELETE', base).catch(() => {});
322
+ }
323
+ // Only kill a driver this run started; an adopted one belongs to whoever
324
+ // started it.
325
+ if (driverChild) driverChild.kill();
326
+ }
327
+ }
328
+
329
+ main().catch((err) => {
330
+ console.error(`_perf-probe-safari.mjs: ${err.message}`);
331
+ process.exit(1);
332
+ });
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env node
2
+ // _perf-probe.mjs — internal shim behind `perf.sh` (`maude design perf`).
3
+ //
4
+ // Drives a scripted pan + zoom gesture against a live canvas and reports what
5
+ // the frame pipeline actually did: frame-time percentiles, long-task count, and
6
+ // the React render count during the gesture. That last number is the point.
7
+ // The plan this implements (.ai/plans/feature-canvas-render-performance.md)
8
+ // asserts a "zero React renders during gesture" invariant, and an invariant
9
+ // with no meter is a wish — so the meter ships in the same wave as the fix, and
10
+ // the BASELINE is captured before any fix lands.
11
+ //
12
+ // Numbers are only meaningful relative to another run on the same machine and
13
+ // engine (headless GPU behaviour varies wildly), so every run appends to a
14
+ // history file and the report prints the delta against the previous run of the
15
+ // same (canvas, engine) pair. There is deliberately no absolute pass/fail
16
+ // threshold and no CI gate — a red build on a noisy GPU teaches people to
17
+ // ignore the meter.
18
+ //
19
+ // Usage:
20
+ // node _perf-probe.mjs --url <canvas-shell-url> [--label <name>]
21
+ // [--history <path>] [--engine-tag <tag>]
22
+ // [--timeout <secs>] [--pan <n>] [--zoom <n>] [--json]
23
+ //
24
+ // Stdout: human report (or raw JSON with --json).
25
+ // Exit: 0 measured / 1 dependency or capture failure / 2 bad args.
26
+
27
+ import { execFileSync } from 'node:child_process';
28
+ import { appendFileSync, mkdirSync } from 'node:fs';
29
+ import { dirname } from 'node:path';
30
+
31
+ import {
32
+ buildRow,
33
+ harnessSource,
34
+ medianOf,
35
+ parseAndValidateResult,
36
+ readHistory,
37
+ renderReport,
38
+ } from './_perf-shared.mjs';
39
+
40
+ const AB = process.env.MAUDE_AGENT_BROWSER || 'agent-browser';
41
+
42
+ // agent-browser is a SHARED singleton by default: a concurrent session running
43
+ // its own smoke sweep will navigate the same tab out from under a measurement
44
+ // mid-gesture, and the probe then times somebody else's page. Observed exactly
45
+ // that during this feature's own baseline run. A named session pins us to our
46
+ // own browser instance, so the benchmark measures the canvas it was pointed at.
47
+ const AB_SESSION = process.env.MAUDE_PERF_SESSION || 'maude-perf';
48
+
49
+ function parseArgs(argv) {
50
+ const out = {
51
+ url: '',
52
+ label: '',
53
+ history: '',
54
+ engineTag: '',
55
+ timeout: 30,
56
+ pan: 60,
57
+ zoom: 40,
58
+ json: false,
59
+ // A/B lever: inject a CSS override before measuring, so a rendering
60
+ // hypothesis can be tested against the SAME build instead of rebuilding
61
+ // per variant (a rebuild changes more than the variable under test).
62
+ // `--variant` is only a label — it keys the history row so A and B don't
63
+ // overwrite each other's baseline.
64
+ injectCss: '',
65
+ variant: '',
66
+ fitAll: false,
67
+ setFlags: [],
68
+ // Repetitions, median-reported, FIRST ONE DISCARDED. Measured necessity,
69
+ // not caution: two identical control runs of this probe came out 60% apart
70
+ // (p95 82.4ms vs 32.7ms) because the first paid for canvas compile, image
71
+ // decode and initial layout. A benchmark whose noise exceeds the effect it
72
+ // measures is worse than none — it produces confident wrong conclusions.
73
+ repeat: 3,
74
+ };
75
+ for (let i = 0; i < argv.length; i++) {
76
+ const a = argv[i];
77
+ if (a === '--url') out.url = argv[++i];
78
+ else if (a === '--label') out.label = argv[++i];
79
+ else if (a === '--history') out.history = argv[++i];
80
+ else if (a === '--engine-tag') out.engineTag = argv[++i];
81
+ else if (a === '--timeout') out.timeout = Number(argv[++i]);
82
+ else if (a === '--pan') out.pan = Number(argv[++i]);
83
+ else if (a === '--zoom') out.zoom = Number(argv[++i]);
84
+ else if (a === '--json') out.json = true;
85
+ else if (a === '--inject-css') out.injectCss = argv[++i];
86
+ else if (a === '--variant') out.variant = argv[++i];
87
+ else if (a === '--repeat') out.repeat = Number(argv[++i]);
88
+ else if (a === '--fit-all') out.fitAll = true;
89
+ else if (a === '--set-flag') out.setFlags.push(argv[++i]);
90
+ else {
91
+ console.error(`_perf-probe.mjs: unknown arg '${a}'`);
92
+ process.exit(2);
93
+ }
94
+ }
95
+ if (!out.url) {
96
+ console.error('_perf-probe.mjs: --url is required');
97
+ process.exit(2);
98
+ }
99
+ return out;
100
+ }
101
+
102
+ function ab(args) {
103
+ return execFileSync(AB, ['--session', AB_SESSION, ...args], {
104
+ encoding: 'utf8',
105
+ maxBuffer: 32 * 1024 * 1024,
106
+ });
107
+ }
108
+
109
+ /** agent-browser prints eval results JSON-encoded; unwrap one level. */
110
+ function abEval(expr) {
111
+ const raw = ab(['eval', expr]).trim();
112
+ if (!raw) return null;
113
+ try {
114
+ return JSON.parse(raw);
115
+ } catch {
116
+ return raw;
117
+ }
118
+ }
119
+
120
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
121
+
122
+ async function main() {
123
+ const opts = parseArgs(process.argv.slice(2));
124
+
125
+ try {
126
+ ab(['open', opts.url]);
127
+ } catch (err) {
128
+ console.error(`_perf-probe.mjs: agent-browser could not open ${opts.url}\n${err.message}`);
129
+ process.exit(1);
130
+ }
131
+
132
+ // Wait for artboards to exist — measuring an empty shell would report a
133
+ // gorgeous 0.1 ms p95 and mean nothing.
134
+ let ready = false;
135
+ for (let i = 0; i < opts.timeout; i++) {
136
+ await sleep(1000);
137
+ const n = abEval(
138
+ "(() => { const d = document.querySelector('iframe') && document.querySelector('iframe').contentDocument; const r = (d||document).querySelectorAll('[data-dc-screen]'); return r.length; })()"
139
+ );
140
+ if (Number(n) > 0) {
141
+ ready = true;
142
+ break;
143
+ }
144
+ }
145
+ if (!ready) {
146
+ console.error(
147
+ `_perf-probe.mjs: no [data-dc-screen] after ${opts.timeout}s — wrong URL, or the canvas failed to mount`
148
+ );
149
+ process.exit(1);
150
+ }
151
+
152
+ /** One gesture pass: install the harness, drive it, wait for its result. */
153
+ async function runOnce() {
154
+ const started = abEval(
155
+ harnessSource({
156
+ pan: opts.pan,
157
+ zoom: opts.zoom,
158
+ injectCss: opts.injectCss,
159
+ fitAll: opts.fitAll,
160
+ setFlags: opts.setFlags,
161
+ })
162
+ );
163
+ if (started === 'NO_HOST') {
164
+ console.error('_perf-probe.mjs: no .dc-canvas host found on the page');
165
+ process.exit(1);
166
+ }
167
+ for (let i = 0; i < opts.timeout; i++) {
168
+ await sleep(1000);
169
+ const r = abEval('JSON.stringify(window.__maudePerfResult)');
170
+ if (r && r !== 'null') {
171
+ const parsed = parseAndValidateResult(r);
172
+ if (!parsed) {
173
+ console.error(
174
+ '_perf-probe.mjs: the page returned a malformed result — refusing to print or record it. ' +
175
+ 'window.__maudePerfResult is inside the untrusted canvas origin (DDR-054).'
176
+ );
177
+ process.exit(1);
178
+ }
179
+ return parsed;
180
+ }
181
+ }
182
+ console.error(`_perf-probe.mjs: gesture never completed within ${opts.timeout}s`);
183
+ process.exit(1);
184
+ }
185
+
186
+ // Run `repeat` passes and DISCARD the first. The first pass pays for canvas
187
+ // compile, image decode and initial layout — costs that are real but are not
188
+ // what "is panning smooth" asks about, and that swamped the signal by 60% in
189
+ // this probe's own control runs.
190
+ const passes = [];
191
+ const total = Math.max(1, opts.repeat);
192
+ for (let i = 0; i < total; i++) {
193
+ const r = await runOnce();
194
+ if (!r.panApplied || !r.zoomApplied) {
195
+ console.error(
196
+ `_perf-probe.mjs: gesture did not reach the canvas on pass ${i + 1} ` +
197
+ `(pan: ${r.panApplied}, zoom: ${r.zoomApplied}) — refusing to record a meaningless row.`
198
+ );
199
+ process.exit(1);
200
+ }
201
+ if (i > 0 || total === 1) passes.push(r);
202
+ process.stderr.write(
203
+ `→ pass ${i + 1}/${total}: gesture p95 ${r.gesture.p95}ms${i === 0 && total > 1 ? ' (warm-up, discarded)' : ''}\n`
204
+ );
205
+ }
206
+
207
+ const result = medianOf(passes);
208
+ const label = (opts.label || opts.url) + (opts.variant ? ` [${opts.variant}]` : '');
209
+ const engineTag = opts.engineTag || 'unknown';
210
+ const prev = readHistory(opts.history, label, engineTag);
211
+ const row = buildRow({ result, label, engineTag, opts });
212
+
213
+ if (opts.history) {
214
+ mkdirSync(dirname(opts.history), { recursive: true });
215
+ appendFileSync(opts.history, `${JSON.stringify(row)}\n`, 'utf8');
216
+ }
217
+
218
+ if (opts.json) {
219
+ console.log(JSON.stringify({ current: row, previous: prev }, null, 2));
220
+ return;
221
+ }
222
+ console.log(renderReport({ row, prev, opts, label, engineTag }));
223
+ }
224
+
225
+ main().catch((err) => {
226
+ console.error(`_perf-probe.mjs: ${err.message}`);
227
+ process.exit(1);
228
+ });