@1agh/maude 0.58.3 → 0.59.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.
- package/apps/studio/annotations-layer.tsx +49 -15
- package/apps/studio/bin/_import-asset.mjs +18 -0
- package/apps/studio/bin/_import-figma.mjs +868 -214
- package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
- package/apps/studio/bin/_perf-probe.mjs +228 -0
- package/apps/studio/bin/_perf-shared.mjs +345 -0
- package/apps/studio/bin/_video-playwright.mjs +17 -4
- package/apps/studio/bin/import-figma.sh +10 -1
- package/apps/studio/bin/perf.sh +228 -0
- package/apps/studio/bin/smoke.sh +49 -5
- package/apps/studio/canvas-lib.tsx +148 -6
- package/apps/studio/client/app.jsx +152 -37
- package/apps/studio/client/panels/SyncPanel.jsx +229 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +29 -1
- package/apps/studio/client/panels/timeline-comp-target.js +101 -0
- package/apps/studio/client/styles/3-shell-maude.css +30 -0
- package/apps/studio/client/styles/4-components.css +4 -4
- package/apps/studio/dist/client.bundle.js +772 -772
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/video-encode-lib.ts +8 -5
- package/apps/studio/exporters/video.ts +10 -0
- package/apps/studio/figma/assets.test.ts +92 -0
- package/apps/studio/figma/assets.ts +63 -9
- package/apps/studio/figma/codegen-client.test.ts +276 -0
- package/apps/studio/figma/codegen-client.ts +509 -0
- package/apps/studio/figma/codegen-fonts.test.ts +103 -0
- package/apps/studio/figma/codegen-fonts.ts +195 -0
- package/apps/studio/figma/codegen-values.test.ts +179 -0
- package/apps/studio/figma/codegen-values.ts +270 -0
- package/apps/studio/figma/endpoints.ts +73 -0
- package/apps/studio/figma/fig-decode.test.ts +702 -0
- package/apps/studio/figma/fig-decode.ts +617 -0
- package/apps/studio/figma/fig-kiwi.ts +410 -0
- package/apps/studio/figma/fig-zip.ts +270 -0
- package/apps/studio/figma/from-codegen.test.ts +408 -0
- package/apps/studio/figma/from-codegen.ts +1103 -0
- package/apps/studio/figma/sanitize.test.ts +69 -0
- package/apps/studio/figma/sanitize.ts +139 -47
- package/apps/studio/figma/tailwind-map.test.ts +142 -0
- package/apps/studio/figma/tailwind-map.ts +545 -0
- package/apps/studio/figma/to-render.ts +25 -3
- package/apps/studio/figma/types.ts +6 -1
- package/apps/studio/http.ts +47 -0
- package/apps/studio/sync/asset-push.ts +346 -38
- package/apps/studio/sync/connection-state.ts +71 -3
- package/apps/studio/sync/index.ts +10 -1
- package/apps/studio/sync/status.ts +18 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
- package/apps/studio/test/figma-explode.test.ts +438 -0
- package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
- package/apps/studio/test/import-figma.test.ts +192 -4
- package/apps/studio/test/sync-asset-push.test.ts +490 -47
- package/apps/studio/test/sync-connection-state.test.ts +66 -0
- package/apps/studio/test/sync-panel-surface.test.ts +90 -0
- package/apps/studio/test/sync-status.test.ts +28 -0
- package/apps/studio/test/timeline-comp-target.test.ts +139 -0
- package/apps/studio/test/video-comp.test.ts +81 -1
- package/apps/studio/test/video-encode-lib.test.ts +63 -0
- package/apps/studio/use-artboard-drag.tsx +37 -3
- package/apps/studio/video-comp.tsx +51 -0
- package/apps/studio/whats-new.json +71 -0
- package/cli/commands/design.mjs +7 -0
- package/cli/commands/kg.mjs +8 -1
- package/cli/commands/kg.test.mjs +24 -0
- package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
- package/cli/lib/figma-import-controls.test.mjs +70 -0
- 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
|
+
});
|