@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,345 @@
|
|
|
1
|
+
// _perf-shared.mjs — the measurement contract shared by every `maude design perf`
|
|
2
|
+
// engine lane (Chromium via agent-browser, WebKit via safaridriver).
|
|
3
|
+
//
|
|
4
|
+
// It lives in one file on purpose. Two lanes with two copies of the gesture
|
|
5
|
+
// script would drift, and the moment they drift the engines stop being
|
|
6
|
+
// comparable — which is the only thing this benchmark is for.
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
|
|
10
|
+
// ── The in-page harness ──────────────────────────────────────────────────────
|
|
11
|
+
//
|
|
12
|
+
// Synthesizes the same wheel events the viewport controller listens for
|
|
13
|
+
// (`canvas-lib.tsx` onWheel: plain wheel = 2D pan, ctrlKey wheel = pinch-zoom —
|
|
14
|
+
// a macOS trackpad pinch arrives as ctrlKey:true, so this is the real gesture
|
|
15
|
+
// path, not a side door).
|
|
16
|
+
//
|
|
17
|
+
// Runs async and parks its result on `window.__maudePerfResult`, which the
|
|
18
|
+
// caller polls: both driver protocols return one synchronous value per eval and
|
|
19
|
+
// cannot await.
|
|
20
|
+
export function harnessSource({ pan, zoom, injectCss, fitAll, setFlags }) {
|
|
21
|
+
const flagBlock = (setFlags || []).map((f) => `\n win[${JSON.stringify(f)}] = true;`).join('');
|
|
22
|
+
const cssBlock = injectCss
|
|
23
|
+
? `
|
|
24
|
+
{
|
|
25
|
+
const st = doc.createElement('style');
|
|
26
|
+
st.id = 'maude-perf-variant';
|
|
27
|
+
st.textContent = ${JSON.stringify(injectCss)};
|
|
28
|
+
doc.head.appendChild(st);
|
|
29
|
+
}`
|
|
30
|
+
: '';
|
|
31
|
+
return `(() => {
|
|
32
|
+
const host = document.querySelector('.dc-canvas') ||
|
|
33
|
+
(document.querySelector('iframe') &&
|
|
34
|
+
document.querySelector('iframe').contentDocument &&
|
|
35
|
+
document.querySelector('iframe').contentDocument.querySelector('.dc-canvas'));
|
|
36
|
+
if (!host) return 'NO_HOST';
|
|
37
|
+
const doc = host.ownerDocument;
|
|
38
|
+
const win = doc.defaultView;
|
|
39
|
+
win.__maudePerfResult = null;${flagBlock}${cssBlock}
|
|
40
|
+
|
|
41
|
+
// Render counter, installed by canvas-lib when this object exists. Absent
|
|
42
|
+
// instrumentation reports null rather than zero — "not measured" and
|
|
43
|
+
// "measured zero" must never look alike in a baseline.
|
|
44
|
+
win.__dcPerf = { artboardRenders: 0, annotationRenders: 0 };
|
|
45
|
+
|
|
46
|
+
const frames = [];
|
|
47
|
+
let longtasks = 0;
|
|
48
|
+
let po = null;
|
|
49
|
+
try {
|
|
50
|
+
po = new win.PerformanceObserver((list) => { longtasks += list.getEntries().length; });
|
|
51
|
+
po.observe({ entryTypes: ['longtask'] });
|
|
52
|
+
} catch { /* longtask unsupported (WebKit) — count stays 0, frames still tell the story */ }
|
|
53
|
+
|
|
54
|
+
let last = 0;
|
|
55
|
+
let running = true;
|
|
56
|
+
const tick = (t) => {
|
|
57
|
+
if (last) frames.push(t - last);
|
|
58
|
+
last = t;
|
|
59
|
+
if (running) win.requestAnimationFrame(tick);
|
|
60
|
+
};
|
|
61
|
+
win.requestAnimationFrame(tick);
|
|
62
|
+
|
|
63
|
+
const rect = host.getBoundingClientRect();
|
|
64
|
+
const cx = rect.left + rect.width / 2;
|
|
65
|
+
const cy = rect.top + rect.height / 2;
|
|
66
|
+
|
|
67
|
+
const wheel = (dx, dy, ctrl) => host.dispatchEvent(new win.WheelEvent('wheel', {
|
|
68
|
+
deltaX: dx, deltaY: dy, ctrlKey: !!ctrl,
|
|
69
|
+
clientX: cx, clientY: cy, bubbles: true, cancelable: true,
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
const nextFrame = () => new Promise((r) => win.requestAnimationFrame(() => r()));
|
|
73
|
+
|
|
74
|
+
// The world transform is the ground truth that the gesture LANDED. Without
|
|
75
|
+
// this check a probe whose synthetic events get swallowed reports a beautiful
|
|
76
|
+
// 60fps idle page as a baseline — the exact way a benchmark lies.
|
|
77
|
+
const world = doc.querySelector('.dc-world');
|
|
78
|
+
const transformOf = () => (world ? win.getComputedStyle(world).transform + '|' + (world.style.zoom || '') : '');
|
|
79
|
+
|
|
80
|
+
(async () => {
|
|
81
|
+
// Warm-up: one frame so the first measured delta isn't the install cost.
|
|
82
|
+
await nextFrame();
|
|
83
|
+
|
|
84
|
+
// FIT-ALL first when asked. This is the state the moodboard RCA identified
|
|
85
|
+
// as the painful one and the state a probe most easily misses: at the
|
|
86
|
+
// canvas's stored viewport only a handful of boards are on screen, so
|
|
87
|
+
// content-visibility culls the rest and the measurement flatters the
|
|
88
|
+
// engine. Zoomed out to the whole plane nothing culls, every board paints,
|
|
89
|
+
// and that is where the user says it stops being usable. Cmd+0 is the
|
|
90
|
+
// canvas's own fit shortcut (canvas-lib keydown, case 0).
|
|
91
|
+
// NB: no backticks in this comment — it lives inside a template literal.
|
|
92
|
+
if (${fitAll ? 'true' : 'false'}) {
|
|
93
|
+
doc.dispatchEvent(new win.KeyboardEvent('keydown', {
|
|
94
|
+
key: '0', code: 'Digit0', metaKey: true, bubbles: true, cancelable: true,
|
|
95
|
+
}));
|
|
96
|
+
host.dispatchEvent(new win.KeyboardEvent('keydown', {
|
|
97
|
+
key: '0', code: 'Digit0', metaKey: true, bubbles: true, cancelable: true,
|
|
98
|
+
}));
|
|
99
|
+
// Let the fit animation finish before the measured gesture starts.
|
|
100
|
+
await new Promise((r) => win.setTimeout(r, 800));
|
|
101
|
+
}
|
|
102
|
+
// Baseline for the did-it-move check is taken HERE, after any fit, not at
|
|
103
|
+
// harness install. Repeated passes are deterministic: pass N ends exactly
|
|
104
|
+
// where "fit + the same pan" lands, so an install-time baseline compares two
|
|
105
|
+
// identical states and rejects a gesture that ran perfectly.
|
|
106
|
+
const t0 = transformOf();
|
|
107
|
+
const panStart = frames.length;
|
|
108
|
+
for (let i = 0; i < ${pan}; i++) { wheel(-12, -9, false); await nextFrame(); }
|
|
109
|
+
const panEnd = frames.length;
|
|
110
|
+
const tPan = transformOf();
|
|
111
|
+
|
|
112
|
+
// Zoom: alternate in/out so the run neither drifts to the zoom clamp nor
|
|
113
|
+
// ends somewhere a follow-up run would start from differently.
|
|
114
|
+
// Zoom IN first, then back out — symmetric (net scale change ~0) but it
|
|
115
|
+
// starts in the direction that always has headroom. Starting outward dies
|
|
116
|
+
// at fit-all, where the viewport already sits on ZOOM_MIN: the clamp makes
|
|
117
|
+
// the first half a no-op, the transform never moves, and the validity gate
|
|
118
|
+
// correctly rejects the whole run.
|
|
119
|
+
// Sampled at the TURNAROUND, not at the end: the run is symmetric, so its
|
|
120
|
+
// endpoint transform equals its start. Comparing endpoints would report
|
|
121
|
+
// "zoom never happened" for a zoom that happened twice.
|
|
122
|
+
let tZoomMid = '';
|
|
123
|
+
for (let i = 0; i < ${zoom}; i++) {
|
|
124
|
+
wheel(0, i < ${zoom} / 2 ? -8 : 8, true);
|
|
125
|
+
await nextFrame();
|
|
126
|
+
if (i === Math.floor(${zoom} / 2) - 1) tZoomMid = transformOf();
|
|
127
|
+
}
|
|
128
|
+
const zoomEnd = frames.length;
|
|
129
|
+
const tZoom = tZoomMid;
|
|
130
|
+
|
|
131
|
+
// Settle window — the tail spike after the gesture is its own symptom
|
|
132
|
+
// ("seká po dojezdu"), so it is measured separately, not averaged away.
|
|
133
|
+
const settleStart = frames.length;
|
|
134
|
+
await new Promise((r) => win.setTimeout(r, 900));
|
|
135
|
+
running = false;
|
|
136
|
+
if (po) { try { po.disconnect(); } catch {} }
|
|
137
|
+
|
|
138
|
+
const slice = (a, b) => frames.slice(a, b).filter((n) => n > 0);
|
|
139
|
+
const pct = (arr, p) => {
|
|
140
|
+
if (!arr.length) return null;
|
|
141
|
+
const s = arr.slice().sort((a, b) => a - b);
|
|
142
|
+
return Math.round(s[Math.min(s.length - 1, Math.floor(s.length * p))] * 100) / 100;
|
|
143
|
+
};
|
|
144
|
+
const stat = (arr) => ({
|
|
145
|
+
frames: arr.length,
|
|
146
|
+
p50: pct(arr, 0.5),
|
|
147
|
+
p95: pct(arr, 0.95),
|
|
148
|
+
max: arr.length ? Math.round(Math.max(...arr) * 100) / 100 : null,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const gestureFrames = slice(panStart, zoomEnd);
|
|
152
|
+
win.__maudePerfResult = {
|
|
153
|
+
pan: stat(slice(panStart, panEnd)),
|
|
154
|
+
zoom: stat(slice(panEnd, zoomEnd)),
|
|
155
|
+
gesture: stat(gestureFrames),
|
|
156
|
+
settle: stat(slice(settleStart, frames.length)),
|
|
157
|
+
longtasks,
|
|
158
|
+
artboardRenders: win.__dcPerf ? win.__dcPerf.artboardRenders : null,
|
|
159
|
+
annotationRenders: win.__dcPerf ? win.__dcPerf.annotationRenders : null,
|
|
160
|
+
instrumented: !!(win.__dcPerf && win.__dcPerf.instrumented),
|
|
161
|
+
panApplied: tPan !== t0,
|
|
162
|
+
zoomApplied: tZoom !== tPan,
|
|
163
|
+
};
|
|
164
|
+
})();
|
|
165
|
+
return 'STARTED';
|
|
166
|
+
})()`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Shape check behind `parseAndValidateResult` — validates a harness result
|
|
171
|
+
* before ANY of it is printed or recorded.
|
|
172
|
+
*
|
|
173
|
+
* The harness parks its result on `window.__maudePerfResult` — a global inside
|
|
174
|
+
* the canvas origin, which DDR-054 treats as untrusted. A canvas from a cloned
|
|
175
|
+
* repo can define that global itself, ahead of our IIFE, and hand back whatever
|
|
176
|
+
* shape it likes. The numbers would be laundered downstream by Math.round, but
|
|
177
|
+
* the per-pass progress line and the refusal message print fields verbatim, so
|
|
178
|
+
* without this gate a hostile canvas gets attacker-authored text into the
|
|
179
|
+
* invoking agent's transcript wearing a first-party tool's voice.
|
|
180
|
+
*
|
|
181
|
+
* Returns the value only when every leaf is the primitive it claims to be.
|
|
182
|
+
*/
|
|
183
|
+
export function parseAndValidateResult(raw) {
|
|
184
|
+
let decoded;
|
|
185
|
+
try {
|
|
186
|
+
decoded = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
187
|
+
} catch {
|
|
188
|
+
// The PARSE is part of the trust boundary, not a step before it. A canvas
|
|
189
|
+
// owns its window and can replace JSON.stringify, so `raw` is arbitrary
|
|
190
|
+
// text — and a JSON.parse SyntaxError quotes a snippet of its input, which
|
|
191
|
+
// would carry attacker-authored text into the invoking agent's transcript
|
|
192
|
+
// through the generic error handler.
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
return validateResult(decoded);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function validateResult(r) {
|
|
199
|
+
const num = (v) => v === null || (typeof v === 'number' && Number.isFinite(v));
|
|
200
|
+
const stat = (o) => o && typeof o === 'object' && num(o.p50) && num(o.p95) && num(o.max);
|
|
201
|
+
if (!r || typeof r !== 'object') return null;
|
|
202
|
+
if (!stat(r.gesture) || !stat(r.pan) || !stat(r.zoom) || !stat(r.settle)) return null;
|
|
203
|
+
if (!num(r.longtasks) || !num(r.artboardRenders) || !num(r.annotationRenders)) return null;
|
|
204
|
+
if (typeof r.panApplied !== 'boolean' || typeof r.zoomApplied !== 'boolean') return null;
|
|
205
|
+
if (typeof r.instrumented !== 'boolean') return null;
|
|
206
|
+
return r;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Median across kept passes — robust to the one pass that hit a GC. */
|
|
210
|
+
export function medianOf(passes) {
|
|
211
|
+
const med = (pick) => {
|
|
212
|
+
const vals = passes.map(pick).filter((v) => v != null);
|
|
213
|
+
if (!vals.length) return null;
|
|
214
|
+
const s = vals.slice().sort((a, b) => a - b);
|
|
215
|
+
return Math.round(s[Math.floor(s.length / 2)] * 100) / 100;
|
|
216
|
+
};
|
|
217
|
+
return {
|
|
218
|
+
pan: { p95: med((p) => p.pan.p95) },
|
|
219
|
+
zoom: { p95: med((p) => p.zoom.p95) },
|
|
220
|
+
gesture: {
|
|
221
|
+
p50: med((p) => p.gesture.p50),
|
|
222
|
+
p95: med((p) => p.gesture.p95),
|
|
223
|
+
max: med((p) => p.gesture.max),
|
|
224
|
+
},
|
|
225
|
+
settle: { max: med((p) => p.settle.max) },
|
|
226
|
+
longtasks: med((p) => p.longtasks),
|
|
227
|
+
artboardRenders: med((p) => p.artboardRenders),
|
|
228
|
+
annotationRenders: med((p) => p.annotationRenders),
|
|
229
|
+
instrumented: passes.some((p) => p.instrumented),
|
|
230
|
+
passes: passes.length,
|
|
231
|
+
// The spread across kept passes IS a result: a delta smaller than this is
|
|
232
|
+
// noise, and the report says so rather than leaving the reader to guess.
|
|
233
|
+
p95Spread:
|
|
234
|
+
passes.length > 1
|
|
235
|
+
? Math.round(
|
|
236
|
+
(Math.max(...passes.map((p) => p.gesture.p95)) -
|
|
237
|
+
Math.min(...passes.map((p) => p.gesture.p95))) *
|
|
238
|
+
100
|
|
239
|
+
) / 100
|
|
240
|
+
: null,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function buildRow({ result, label, engineTag, opts }) {
|
|
245
|
+
return {
|
|
246
|
+
date: new Date().toISOString(),
|
|
247
|
+
canvas: label,
|
|
248
|
+
engine: engineTag,
|
|
249
|
+
url: opts.url,
|
|
250
|
+
panFrames: opts.pan,
|
|
251
|
+
zoomFrames: opts.zoom,
|
|
252
|
+
p50FrameMs: result.gesture.p50,
|
|
253
|
+
p95FrameMs: result.gesture.p95,
|
|
254
|
+
maxFrameMs: result.gesture.max,
|
|
255
|
+
panP95Ms: result.pan.p95,
|
|
256
|
+
zoomP95Ms: result.zoom.p95,
|
|
257
|
+
settleMaxMs: result.settle.max,
|
|
258
|
+
longtasks: result.longtasks,
|
|
259
|
+
artboardRenders: result.artboardRenders,
|
|
260
|
+
annotationRenders: result.annotationRenders,
|
|
261
|
+
instrumented: result.instrumented,
|
|
262
|
+
passes: result.passes,
|
|
263
|
+
p95SpreadMs: result.p95Spread,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Delta vs the previous run of the same (canvas, engine) pair. */
|
|
268
|
+
export function deltaLine(label, cur, prev, unit = 'ms') {
|
|
269
|
+
if (cur == null) return ` ${label.padEnd(22)} —`;
|
|
270
|
+
if (prev == null) return ` ${label.padEnd(22)} ${cur}${unit} (no prior run)`;
|
|
271
|
+
const d = Math.round((cur - prev) * 100) / 100;
|
|
272
|
+
const pctChange = prev === 0 ? null : Math.round((d / prev) * 1000) / 10;
|
|
273
|
+
const arrow = d === 0 ? '=' : d < 0 ? '▼' : '▲';
|
|
274
|
+
const pctTxt = pctChange == null ? '' : ` ${pctChange > 0 ? '+' : ''}${pctChange}%`;
|
|
275
|
+
return ` ${label.padEnd(22)} ${cur}${unit} ${arrow} ${d > 0 ? '+' : ''}${d}${unit}${pctTxt} (was ${prev}${unit})`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function readHistory(path, label, engineTag) {
|
|
279
|
+
if (!path || !existsSync(path)) return null;
|
|
280
|
+
let prev = null;
|
|
281
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
282
|
+
if (!line.trim()) continue;
|
|
283
|
+
try {
|
|
284
|
+
const row = JSON.parse(line);
|
|
285
|
+
if (row.canvas === label && row.engine === engineTag) prev = row;
|
|
286
|
+
} catch {
|
|
287
|
+
/* a corrupt line must not sink the run — history is a convenience, not a source of truth */
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return prev;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* One-line, control-character-free rendering of a caller-supplied string.
|
|
295
|
+
* A canvas filename comes from the repo and POSIX allows newlines in it, so
|
|
296
|
+
* echoing one raw lets a hostile repo inject its own line into a report an
|
|
297
|
+
* agent reads.
|
|
298
|
+
*/
|
|
299
|
+
function safeLabel(v) {
|
|
300
|
+
let out = '';
|
|
301
|
+
for (const ch of String(v).slice(0, 200)) {
|
|
302
|
+
const code = ch.codePointAt(0);
|
|
303
|
+
const unsafe =
|
|
304
|
+
code < 0x20 || // C0 — newline and friends: the line-injection case
|
|
305
|
+
code === 0x7f || // DEL
|
|
306
|
+
(code >= 0x80 && code <= 0x9f) || // C1 — some terminals still act on these
|
|
307
|
+
(code >= 0x202a && code <= 0x202e) || // bidi embedding / override
|
|
308
|
+
(code >= 0x2066 && code <= 0x2069); // bidi isolates
|
|
309
|
+
// Bidi controls can't break a line, but they can visually reorder one, so a
|
|
310
|
+
// filename could make a report read differently than it is.
|
|
311
|
+
out += unsafe ? ' ' : ch;
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function renderReport({ row, prev, opts, label, engineTag }) {
|
|
317
|
+
const out = [];
|
|
318
|
+
out.push(`\n canvas: ${safeLabel(label)} engine: ${safeLabel(engineTag)}`);
|
|
319
|
+
out.push(` gesture: ${opts.pan} pan frames + ${opts.zoom} zoom frames\n`);
|
|
320
|
+
out.push(deltaLine('frame p50', row.p50FrameMs, prev?.p50FrameMs));
|
|
321
|
+
out.push(deltaLine('frame p95', row.p95FrameMs, prev?.p95FrameMs));
|
|
322
|
+
out.push(deltaLine('frame max', row.maxFrameMs, prev?.maxFrameMs));
|
|
323
|
+
out.push(deltaLine('pan p95', row.panP95Ms, prev?.panP95Ms));
|
|
324
|
+
out.push(deltaLine('zoom p95', row.zoomP95Ms, prev?.zoomP95Ms));
|
|
325
|
+
out.push(deltaLine('settle max', row.settleMaxMs, prev?.settleMaxMs));
|
|
326
|
+
out.push(deltaLine('long tasks', row.longtasks, prev?.longtasks, ''));
|
|
327
|
+
out.push(deltaLine('artboard renders', row.artboardRenders, prev?.artboardRenders, ''));
|
|
328
|
+
out.push(deltaLine('annotation renders', row.annotationRenders, prev?.annotationRenders, ''));
|
|
329
|
+
if (!row.instrumented) {
|
|
330
|
+
out.push(
|
|
331
|
+
'\n note: render counters are not instrumented in this build — frame timings are still valid.'
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
if (row.p95SpreadMs != null) {
|
|
335
|
+
out.push(
|
|
336
|
+
`\n p95 spread across ${row.passes} kept passes: ${row.p95SpreadMs}ms ` +
|
|
337
|
+
'— treat any delta smaller than this as noise.'
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
if (opts.history) out.push(`\n history: ${opts.history}`);
|
|
341
|
+
out.push(
|
|
342
|
+
` ${prev ? 'compared against the previous run' : 'first run — this IS the baseline'}\n`
|
|
343
|
+
);
|
|
344
|
+
return out.join('\n');
|
|
345
|
+
}
|
|
@@ -47,6 +47,7 @@ const {
|
|
|
47
47
|
out, // encoded file destination
|
|
48
48
|
mode = 'comp', // 'comp' | 'ordinary'
|
|
49
49
|
audio, // '1' | '0' — include audio in the render-lib path (default on; presence-checked below)
|
|
50
|
+
'frame-format': frameFormatArg, // 'jpeg' | 'png' (default) — the frame-step screenshot intermediate, Task 9
|
|
50
51
|
'license-key': licenseKeyArg,
|
|
51
52
|
timeout = '60',
|
|
52
53
|
scale = '1',
|
|
@@ -67,6 +68,8 @@ const deviceScaleFactor = Math.max(1, Math.min(4, Number(scale) || 1));
|
|
|
67
68
|
// always passes an explicit '0'/'1'.
|
|
68
69
|
const wantAudio = audio !== '0';
|
|
69
70
|
const licenseKey = licenseKeyArg || 'free-license';
|
|
71
|
+
// Task 9 — opt-in JPEG frame-step intermediate; unrecognized/absent value is PNG.
|
|
72
|
+
const frameFormat = frameFormatArg === 'jpeg' ? 'jpeg' : 'png';
|
|
70
73
|
|
|
71
74
|
/** Wait a real turn of the event loop + a frame so a seek settles before shot. */
|
|
72
75
|
const SETTLE_MS = 16;
|
|
@@ -267,6 +270,7 @@ try {
|
|
|
267
270
|
mode,
|
|
268
271
|
deviceScaleFactor,
|
|
269
272
|
timeoutMs,
|
|
273
|
+
frameFormat,
|
|
270
274
|
fallbackReason: renderFallbackReason,
|
|
271
275
|
});
|
|
272
276
|
} catch (fallbackErr) {
|
|
@@ -308,6 +312,10 @@ async function frameStepCapture({
|
|
|
308
312
|
mode,
|
|
309
313
|
deviceScaleFactor,
|
|
310
314
|
timeoutMs,
|
|
315
|
+
// Task 9 — 'jpeg' | 'png' (default). Only ever affects the encode transport,
|
|
316
|
+
// never a --dump-frames debug dump (that always stays lossless PNG) and
|
|
317
|
+
// never gif (video.ts refuses to pass 'jpeg' through for gif).
|
|
318
|
+
frameFormat = 'png',
|
|
311
319
|
// Set (to the renderer's error message) only when this run is the
|
|
312
320
|
// graceful-degradation fallback for a failed renderMediaOnWeb path; stamps
|
|
313
321
|
// `degraded`/`audioDropped` onto the stdout summary so video.ts can warn.
|
|
@@ -438,6 +446,11 @@ async function frameStepCapture({
|
|
|
438
446
|
// Per-stage accumulators. "The export takes 15 minutes" was, for a long time,
|
|
439
447
|
// answerable only by guessing which stage owned the second — so every
|
|
440
448
|
// optimization was aimed at a suspicion. One line at the end fixes that.
|
|
449
|
+
// Task 9 — a debug dump must stay lossless (it's for human inspection), and
|
|
450
|
+
// gif never gets a lossy intermediate stacked under its own quantization —
|
|
451
|
+
// so the knob only actually applies to a plain video encode.
|
|
452
|
+
const useJpeg = frameFormat === 'jpeg' && !isGif && !dump;
|
|
453
|
+
const shotFormat = useJpeg ? 'jpeg' : 'png';
|
|
441
454
|
const stageMs = { seek: 0, settle: 0, screenshot: 0, encode: 0 };
|
|
442
455
|
for (let f = 0; f < frameCount; f += 1) {
|
|
443
456
|
const tSeek = Date.now();
|
|
@@ -447,7 +460,7 @@ async function frameStepCapture({
|
|
|
447
460
|
await page.waitForTimeout(SETTLE_MS);
|
|
448
461
|
stageMs.settle += Date.now() - tSettle;
|
|
449
462
|
const tShot = Date.now();
|
|
450
|
-
const shot = await page.screenshot({ clip });
|
|
463
|
+
const shot = await page.screenshot(useJpeg ? { clip, type: 'jpeg', quality: 90 } : { clip });
|
|
451
464
|
stageMs.screenshot += Date.now() - tShot;
|
|
452
465
|
if (dump) {
|
|
453
466
|
const p = join(dump, `frame-${String(f).padStart(5, '0')}.png`);
|
|
@@ -458,11 +471,11 @@ async function frameStepCapture({
|
|
|
458
471
|
const tEnc = Date.now();
|
|
459
472
|
const b64 = shot.toString('base64');
|
|
460
473
|
await page.evaluate(
|
|
461
|
-
async ({ b64, isGif }) => {
|
|
474
|
+
async ({ b64, isGif, format }) => {
|
|
462
475
|
if (isGif) return window.__maudeEnc.addGifFrame(b64);
|
|
463
|
-
return window.__maudeEnc.addVideoFrame(b64);
|
|
476
|
+
return window.__maudeEnc.addVideoFrame(b64, format);
|
|
464
477
|
},
|
|
465
|
-
{ b64, isGif }
|
|
478
|
+
{ b64, isGif, format: shotFormat }
|
|
466
479
|
);
|
|
467
480
|
// NB: this stage is the double CDP crossing — the screenshot bytes go out
|
|
468
481
|
// to node as base64 and straight back into the same page, to an encoder
|
|
@@ -15,18 +15,27 @@
|
|
|
15
15
|
# Usage:
|
|
16
16
|
# import-figma.sh --board <figjam-url> --root <repo> [--design-root .design]
|
|
17
17
|
# [--slug <name>] [--dry-run] [--json]
|
|
18
|
+
# import-figma.sh --pages <figma-url> --root <repo> [--folder <name>] [--editable]
|
|
18
19
|
# import-figma.sh --frames <figma-url> --root <repo> (Phase 3)
|
|
19
20
|
# import-figma.sh --tokens <figma-url> --root <repo> (Phase 4)
|
|
21
|
+
# import-figma.sh --explode <artboard-id> --canvas <rel-path> --root <repo>
|
|
22
|
+
# [--confirm-document] [--dry-run] [--json] (Phase 7)
|
|
20
23
|
#
|
|
21
24
|
# Needs a Figma personal access token with the `file_content:read` scope, added
|
|
22
25
|
# once in Settings.
|
|
23
26
|
#
|
|
27
|
+
# `--explode` needs NO token — it reads the Figma DESKTOP app's local Dev Mode
|
|
28
|
+
# MCP server over loopback (DDR-219 D2) rather than the REST API. It does need
|
|
29
|
+
# that app running, in Dev Mode, with the same file as the active tab, on a Dev
|
|
30
|
+
# or Full seat. It is not an import route: the artboard must already exist on an
|
|
31
|
+
# imported canvas.
|
|
32
|
+
#
|
|
24
33
|
# Exit: 0 ok · 1 other · 2 usage · 3 validation reject · 4 fetch/parse error ·
|
|
25
34
|
# 5 no token configured · 6 write/containment error.
|
|
26
35
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
27
36
|
|
|
28
37
|
case "$1" in
|
|
29
|
-
--help|-h) sed -n '2,
|
|
38
|
+
--help|-h) sed -n '2,34p' "$0" | sed 's/^# \?//'; exit 0 ;;
|
|
30
39
|
esac
|
|
31
40
|
|
|
32
41
|
if ! command -v bun >/dev/null 2>&1; then
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# perf.sh — render-performance benchmark for a canvas (`maude design perf`).
|
|
3
|
+
#
|
|
4
|
+
# Drives a scripted pan + zoom against a live canvas and reports frame-time
|
|
5
|
+
# percentiles, long-task count and the React render count during the gesture,
|
|
6
|
+
# with a DELTA against the previous run of the same (canvas, engine) pair. The
|
|
7
|
+
# history file makes "before and after" a property of the tool rather than
|
|
8
|
+
# something the operator has to remember to write down.
|
|
9
|
+
#
|
|
10
|
+
# Deliberately NOT a CI gate. Headless frame timings swing with GPU load and
|
|
11
|
+
# machine state, so an absolute threshold would go red for reasons unrelated to
|
|
12
|
+
# the code and train everyone to ignore it. The gate is the delta, read by a
|
|
13
|
+
# human, on one machine. (Plan: .ai/plans/feature-canvas-render-performance.md)
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
# perf.sh [--root <repo>] [--canvas <rel-path>] [--fixture]
|
|
17
|
+
# [--boards N] [--strokes N]
|
|
18
|
+
# [--pan N] [--zoom N] [--timeout S] [--repeat N]
|
|
19
|
+
# [--engine chromium|safari]
|
|
20
|
+
# [--history <path>] [--json]
|
|
21
|
+
#
|
|
22
|
+
# --engine safari measures WebKit through safaridriver (one-time
|
|
23
|
+
# `safaridriver --enable`, needs admin auth). Use it for anything the Tauri
|
|
24
|
+
# desktop shell has to be fast at: the desktop is WKWebView, and Chromium does
|
|
25
|
+
# NOT reproduce WebKit's compositing / re-raster behaviour — a canvas can sit
|
|
26
|
+
# at a warm 60fps in headless Chromium while being unusable in the .app.
|
|
27
|
+
#
|
|
28
|
+
# --canvas Canvas to measure, relative to <designRoot> (default: _active.json).
|
|
29
|
+
# --fixture Generate + measure the synthetic fixture instead (--boards/--strokes).
|
|
30
|
+
# --json Raw JSON (current + previous run) instead of the human report.
|
|
31
|
+
#
|
|
32
|
+
# Reads: $DESIGN_ROOT/_server.json (must exist — run `maude design server-up` first)
|
|
33
|
+
# Writes: $DESIGN_ROOT/_smoke/perf/history.jsonl (append-only, per-machine runtime state)
|
|
34
|
+
# $DESIGN_ROOT/ui/perf-fixture.tsx (only with --fixture)
|
|
35
|
+
#
|
|
36
|
+
# Exit: 0 measured / 1 missing dep or capture failure / 2 bad args.
|
|
37
|
+
|
|
38
|
+
REPO=""
|
|
39
|
+
CANVAS=""
|
|
40
|
+
FIXTURE=0
|
|
41
|
+
BOARDS=128
|
|
42
|
+
STROKES=150
|
|
43
|
+
PAN=60
|
|
44
|
+
ZOOM=40
|
|
45
|
+
REPEAT=3
|
|
46
|
+
PROBE_ENGINE="chromium"
|
|
47
|
+
FIT_ALL=0
|
|
48
|
+
STUDIO=0
|
|
49
|
+
TIMEOUT=30
|
|
50
|
+
HISTORY=""
|
|
51
|
+
JSON=0
|
|
52
|
+
|
|
53
|
+
while [ $# -gt 0 ]; do
|
|
54
|
+
case "$1" in
|
|
55
|
+
--root) REPO="$2"; shift 2 ;;
|
|
56
|
+
--canvas) CANVAS="$2"; shift 2 ;;
|
|
57
|
+
--fixture) FIXTURE=1; shift ;;
|
|
58
|
+
--boards) BOARDS="$2"; shift 2 ;;
|
|
59
|
+
--strokes) STROKES="$2"; shift 2 ;;
|
|
60
|
+
--pan) PAN="$2"; shift 2 ;;
|
|
61
|
+
--zoom) ZOOM="$2"; shift 2 ;;
|
|
62
|
+
--timeout) TIMEOUT="$2"; shift 2 ;;
|
|
63
|
+
--repeat) REPEAT="$2"; shift 2 ;;
|
|
64
|
+
--engine) PROBE_ENGINE="$2"; shift 2 ;;
|
|
65
|
+
--fit-all) FIT_ALL=1; shift ;;
|
|
66
|
+
--studio) STUDIO=1; shift ;;
|
|
67
|
+
--history) HISTORY="$2"; shift 2 ;;
|
|
68
|
+
--json) JSON=1; shift ;;
|
|
69
|
+
--help|-h)
|
|
70
|
+
sed -n '2,30p' "$0" | sed 's/^# \?//'
|
|
71
|
+
exit 0
|
|
72
|
+
;;
|
|
73
|
+
*)
|
|
74
|
+
echo "perf.sh: unknown arg '$1' (try --help)" >&2
|
|
75
|
+
exit 2
|
|
76
|
+
;;
|
|
77
|
+
esac
|
|
78
|
+
done
|
|
79
|
+
|
|
80
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
81
|
+
|
|
82
|
+
# JS runtime. The packaged desktop app ships no user `node` (DDR-177) but always
|
|
83
|
+
# has a `bun` on PATH (real, or the compiled-sidecar shim `maude` stages), so
|
|
84
|
+
# prefer bun and keep node as the developer-machine fallback.
|
|
85
|
+
if command -v bun >/dev/null 2>&1; then JS_RUNTIME="bun"; else JS_RUNTIME="node"; fi
|
|
86
|
+
|
|
87
|
+
# ---------- resolve repo + design root (mirrors smoke.sh) ----------
|
|
88
|
+
if [ -z "$REPO" ]; then
|
|
89
|
+
REPO="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
|
|
90
|
+
fi
|
|
91
|
+
DESIGN_ROOT="$REPO/.design"
|
|
92
|
+
[ -d "$DESIGN_ROOT" ] || { echo "perf.sh: no .design/ under $REPO" >&2; exit 1; }
|
|
93
|
+
|
|
94
|
+
STATE="$DESIGN_ROOT/_server.json"
|
|
95
|
+
[ -f "$STATE" ] || { echo "perf.sh: $STATE missing — run 'maude design server-up' first" >&2; exit 1; }
|
|
96
|
+
|
|
97
|
+
if command -v jq >/dev/null 2>&1; then
|
|
98
|
+
PORT=$(jq -r .port "$STATE" 2>/dev/null)
|
|
99
|
+
else
|
|
100
|
+
PORT=$(sed -nE 's/.*"port"[[:space:]]*:[[:space:]]*([0-9]+).*/\1/p' "$STATE" | head -n1)
|
|
101
|
+
fi
|
|
102
|
+
[ -n "$PORT" ] || { echo "perf.sh: no port in $STATE" >&2; exit 1; }
|
|
103
|
+
|
|
104
|
+
# ---------- fixture generation ----------
|
|
105
|
+
if [ "$FIXTURE" = "1" ]; then
|
|
106
|
+
# Counts are validated as plain integers and paths travel via argv, never
|
|
107
|
+
# interpolated into the script body: `--boards "1); <js>; ("` or a design root
|
|
108
|
+
# containing a quote would otherwise execute inside this node process, which
|
|
109
|
+
# runs with the user's full privileges. `maude design perf` is reachable from
|
|
110
|
+
# an agent session, so its arguments are not automatically trustworthy.
|
|
111
|
+
case "$BOARDS" in ''|*[!0-9]*) echo "perf.sh: --boards must be a positive integer" >&2; exit 2 ;; esac
|
|
112
|
+
case "$STROKES" in ''|*[!0-9]*) echo "perf.sh: --strokes must be a non-negative integer" >&2; exit 2 ;; esac
|
|
113
|
+
# Upper bound too: the guards above accept any magnitude, and `--boards 99999999`
|
|
114
|
+
# is a cheap way to make an auto-approved verb fill a disk.
|
|
115
|
+
[ "$BOARDS" -le 2000 ] || { echo "perf.sh: --boards must be <= 2000" >&2; exit 2; }
|
|
116
|
+
[ "$STROKES" -le 5000 ] || { echo "perf.sh: --strokes must be <= 5000" >&2; exit 2; }
|
|
117
|
+
"$JS_RUNTIME" -e '
|
|
118
|
+
const [modPath, designRoot, boards, strokes] = process.argv.slice(1);
|
|
119
|
+
import(modPath).then((m) => {
|
|
120
|
+
const r = m.writePerfCanvas({
|
|
121
|
+
designRoot,
|
|
122
|
+
boards: Number(boards),
|
|
123
|
+
strokes: Number(strokes),
|
|
124
|
+
});
|
|
125
|
+
console.error("→ fixture written: " + r.canvasPath);
|
|
126
|
+
}).catch((e) => {
|
|
127
|
+
console.error("perf.sh: fixture generation failed: " + e.message);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
});
|
|
130
|
+
' "$SCRIPT_DIR/../test/fixtures/perf-canvas.mjs" "$DESIGN_ROOT" "$BOARDS" "$STROKES" || exit 1
|
|
131
|
+
CANVAS="ui/perf-fixture.tsx"
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
# ---------- resolve canvas ----------
|
|
135
|
+
if [ -z "$CANVAS" ]; then
|
|
136
|
+
ACTIVE_FILE="$DESIGN_ROOT/_active.json"
|
|
137
|
+
if [ -f "$ACTIVE_FILE" ] && command -v jq >/dev/null 2>&1; then
|
|
138
|
+
CANVAS=$(jq -r '.active // empty' "$ACTIVE_FILE" 2>/dev/null)
|
|
139
|
+
fi
|
|
140
|
+
fi
|
|
141
|
+
[ -n "$CANVAS" ] || { echo "perf.sh: no --canvas and no active canvas in _active.json" >&2; exit 2; }
|
|
142
|
+
|
|
143
|
+
# `_active.json` stores the path relative to the REPO (".design/ui/x.tsx"); the
|
|
144
|
+
# canvas-shell query param wants it the same way, so normalize either input
|
|
145
|
+
# shape to the repo-relative form rather than guessing at call sites.
|
|
146
|
+
DESIGN_REL="${DESIGN_ROOT#"$REPO"/}"
|
|
147
|
+
case "$CANVAS" in
|
|
148
|
+
"$DESIGN_REL"/*) REL_FULL="$CANVAS" ;;
|
|
149
|
+
*) REL_FULL="$DESIGN_REL/$CANVAS" ;;
|
|
150
|
+
esac
|
|
151
|
+
# Containment: an existence check alone would accept `--canvas ../../../x.tsx`
|
|
152
|
+
# and leave the boundary entirely to the dev-server route. A benchmark has no
|
|
153
|
+
# business probing that gate.
|
|
154
|
+
case "$REL_FULL" in
|
|
155
|
+
*..*) echo "perf.sh: --canvas must not contain '..'" >&2; exit 2 ;;
|
|
156
|
+
esac
|
|
157
|
+
[ -f "$REPO/$REL_FULL" ] || { echo "perf.sh: canvas not found: $REPO/$REL_FULL" >&2; exit 2; }
|
|
158
|
+
|
|
159
|
+
# Percent-encode the whole value, not just spaces: a canvas named with `#`, `&`
|
|
160
|
+
# or `%` would otherwise truncate or rewrite the query string.
|
|
161
|
+
REL_ENC=$("$JS_RUNTIME" -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$REL_FULL")
|
|
162
|
+
URL="http://localhost:${PORT}/_canvas-shell.html?canvas=${REL_ENC}"
|
|
163
|
+
|
|
164
|
+
# ---------- history path ----------
|
|
165
|
+
# `_smoke/` is already IGNORED runtime state (DDR-115), so the benchmark log
|
|
166
|
+
# inherits the right gitignore posture without touching any of the three lists.
|
|
167
|
+
if [ -z "$HISTORY" ]; then
|
|
168
|
+
HISTORY="$DESIGN_ROOT/_smoke/perf/history.jsonl"
|
|
169
|
+
fi
|
|
170
|
+
# Containment: `perf` is auto-approved in ACP sessions (`Bash(maude:*)`), so an
|
|
171
|
+
# unconstrained --history would be an arbitrary mkdir + append outside every
|
|
172
|
+
# write-scope gate. The default already lives under the design root; require it.
|
|
173
|
+
HISTORY_ABS=$("$JS_RUNTIME" -e 'const p=require("path");process.stdout.write(p.resolve(process.argv[1]))' "$HISTORY")
|
|
174
|
+
DESIGN_ABS=$("$JS_RUNTIME" -e 'const p=require("path");process.stdout.write(p.resolve(process.argv[1]))' "$DESIGN_ROOT")
|
|
175
|
+
case "$HISTORY_ABS" in
|
|
176
|
+
"$DESIGN_ABS"/*) ;;
|
|
177
|
+
*) echo "perf.sh: --history must resolve under $DESIGN_ABS (got $HISTORY_ABS)" >&2; exit 2 ;;
|
|
178
|
+
esac
|
|
179
|
+
HISTORY="$HISTORY_ABS"
|
|
180
|
+
mkdir -p "$(dirname "$HISTORY")"
|
|
181
|
+
|
|
182
|
+
# ---------- engine tag ----------
|
|
183
|
+
# The tag is part of the history KEY, not decoration: a WKWebView number and a
|
|
184
|
+
# headless-Chromium number are not comparable, and silently averaging them would
|
|
185
|
+
# make every delta meaningless.
|
|
186
|
+
ENGINE_TAG="${MAUDE_PERF_ENGINE_TAG:-}"
|
|
187
|
+
if [ -z "$ENGINE_TAG" ]; then
|
|
188
|
+
if [ -n "$MAUDE_DESKTOP" ]; then ENGINE_TAG="webkit-desktop"; else ENGINE_TAG="blink-headless"; fi
|
|
189
|
+
fi
|
|
190
|
+
|
|
191
|
+
if [ "$PROBE_ENGINE" = "safari" ]; then
|
|
192
|
+
command -v safaridriver >/dev/null 2>&1 || {
|
|
193
|
+
echo "perf.sh: safaridriver not found (macOS only)" >&2
|
|
194
|
+
exit 1
|
|
195
|
+
}
|
|
196
|
+
PROBE_URL="$URL"
|
|
197
|
+
if [ "$STUDIO" = "1" ]; then
|
|
198
|
+
# Studio mode drives the real app shell (root URL + a file-tree click),
|
|
199
|
+
# not the bare canvas page — see _perf-probe-safari.mjs for why that
|
|
200
|
+
# difference changes the numbers.
|
|
201
|
+
PROBE_URL="http://localhost:${PORT}/"
|
|
202
|
+
SLUG=$(printf '%s' "${REL_FULL#"$DESIGN_REL"/}" \
|
|
203
|
+
| sed -E 's/\.[A-Za-z]+$//' | tr '[:upper:]' '[:lower:]' \
|
|
204
|
+
| sed -E 's/[^a-z0-9]+/-/g; s/^-+|-+$//g')
|
|
205
|
+
fi
|
|
206
|
+
echo "→ perf: $REL_FULL | port: $PORT | engine: webkit-safari${STUDIO:+ | studio}" >&2
|
|
207
|
+
ARGS=(--url "$PROBE_URL" --label "$REL_FULL$([ "$STUDIO" = "1" ] && echo " (studio)")" --history "$HISTORY"
|
|
208
|
+
--timeout "$TIMEOUT" --pan "$PAN" --zoom "$ZOOM" --repeat "$REPEAT")
|
|
209
|
+
[ "$STUDIO" = "1" ] && ARGS+=(--studio "$SLUG")
|
|
210
|
+
[ "$FIT_ALL" = "1" ] && ARGS+=(--fit-all)
|
|
211
|
+
[ "$JSON" = "1" ] && ARGS+=(--json)
|
|
212
|
+
exec "$JS_RUNTIME" "$SCRIPT_DIR/_perf-probe-safari.mjs" "${ARGS[@]}"
|
|
213
|
+
fi
|
|
214
|
+
|
|
215
|
+
AB="${MAUDE_AGENT_BROWSER:-agent-browser}"
|
|
216
|
+
command -v "$AB" >/dev/null 2>&1 || {
|
|
217
|
+
echo "perf.sh: agent-browser not on PATH (required — the probe needs scripted gestures + rAF timing)" >&2
|
|
218
|
+
exit 1
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
echo "→ perf: $REL_FULL | port: $PORT | engine: $ENGINE_TAG" >&2
|
|
222
|
+
|
|
223
|
+
ARGS=(--url "$URL" --label "$REL_FULL" --history "$HISTORY" --engine-tag "$ENGINE_TAG"
|
|
224
|
+
--timeout "$TIMEOUT" --pan "$PAN" --zoom "$ZOOM" --repeat "$REPEAT")
|
|
225
|
+
[ "$FIT_ALL" = "1" ] && ARGS+=(--fit-all)
|
|
226
|
+
[ "$JSON" = "1" ] && ARGS+=(--json)
|
|
227
|
+
|
|
228
|
+
exec "$JS_RUNTIME" "$SCRIPT_DIR/_perf-probe.mjs" "${ARGS[@]}"
|