@deeeed/metamask-harness 0.42.0 → 0.44.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 (43) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +5 -0
  3. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +363 -55
  4. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +23 -10
  5. package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +2 -0
  6. package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +296 -25
  7. package/adapters/mobile/bridge-runtime/lib/config.cjs +14 -4
  8. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +4 -2
  9. package/adapters/mobile/reload-app.mjs +99 -1
  10. package/adapters/mobile/start-console-forwarder.sh +5 -1
  11. package/dist/adapters/extension/browser-cdp.js +174 -0
  12. package/dist/adapters/extension/network-observer.js +19 -110
  13. package/dist/adapters/extension/performance-observer.js +75 -0
  14. package/dist/adapters/mobile/frame-metrics.js +45 -0
  15. package/dist/adapters/mobile/performance-observer.js +43 -0
  16. package/dist/adapters/mobile/prepare.js +12 -0
  17. package/dist/adapters/performance/cdp-trace.js +342 -0
  18. package/dist/adapters/performance/js-task-metrics.js +35 -0
  19. package/dist/adapters.js +39 -5
  20. package/dist/artifact-files.js +92 -0
  21. package/dist/async.js +19 -0
  22. package/dist/commands/call.js +63 -4
  23. package/dist/commands/run-engine.js +265 -139
  24. package/dist/commands/run-report.js +68 -0
  25. package/dist/commands/run.js +67 -3
  26. package/dist/execution-provenance.js +342 -0
  27. package/dist/network-observation.js +59 -47
  28. package/dist/performance-observation.js +465 -0
  29. package/dist/run-diagnostics.js +36 -11
  30. package/dist/runner.js +44 -13
  31. package/docs/PERFORMANCE-CAPTURE.md +33 -0
  32. package/docs/RECIPES.md +7 -0
  33. package/library/actions/mobile/perps/performance-capture.mjs +570 -189
  34. package/library/actions/mobile/perps/perps.mjs +122 -0
  35. package/library/actions/mobile/platform/bridge.mjs +43 -8
  36. package/library/actions/mobile/platform/native-session.mjs +1 -1
  37. package/library/actions/mobile/wallet/lock.mjs +1 -4
  38. package/library/actions/mobile/wallet/select_account.mjs +129 -17
  39. package/library/manifests/extension.action-manifest.json +85 -0
  40. package/library/manifests/mobile.action-manifest.json +125 -3
  41. package/library/recipes/mobile/perps/performance.recipe.json +73 -47
  42. package/package.json +1 -1
  43. package/scripts/site-contrast.mjs +43 -27
@@ -59,6 +59,18 @@ async function prepareMobile(target, opts = {}) {
59
59
  return { status: EXIT.runtime, output: msg };
60
60
  }
61
61
  const iosAccessibilityChanged = platform === "ios" && report.decision !== "unknown" && enableIosAccessibility(json);
62
+ const rebuildNative = preflightMode === "rebuild-native" || preflightMode === "clean";
63
+ if (report.decision === "ready" && rebuildNative) {
64
+ const launch = await dispatchActionSequence(
65
+ launchActions(path.resolve(target), clearMetro),
66
+ target,
67
+ platform,
68
+ json,
69
+ preflightMode,
70
+ opts.watcherPort
71
+ );
72
+ return launch.status === 0 ? startMobileConsoleForwarder(target, platform, json, opts.watcherPort) : launch;
73
+ }
62
74
  if (report.decision === "ready" && clearMetro) {
63
75
  const launch = await dispatchActionSequence(
64
76
  withAppRestart(
@@ -0,0 +1,342 @@
1
+ import {
2
+ summarizeFrames
3
+ } from "../mobile/frame-metrics.js";
4
+ import { withTimeout } from "../../async.js";
5
+ import {
6
+ summarizeJavaScriptTasks
7
+ } from "./js-task-metrics.js";
8
+ const FRAME_BUDGET_MS = 1e3 / 60;
9
+ const MAX_TRACE_BYTES = 64 * 1024 * 1024;
10
+ const MAX_TRACE_EVENTS = 25e4;
11
+ const MOBILE_TRACE_CATEGORIES = [
12
+ "blink.user_timing",
13
+ "disabled-by-default-devtools.timeline.frame"
14
+ ];
15
+ const EXTENSION_TRACE_CATEGORIES = [
16
+ "blink.user_timing",
17
+ "devtools.timeline",
18
+ "disabled-by-default-devtools.timeline",
19
+ "disabled-by-default-devtools.timeline.frame",
20
+ "v8.execute"
21
+ ];
22
+ function createCdpTraceCollector(client, platform, marker) {
23
+ let active;
24
+ const offData = client.on("Tracing.dataCollected", (params) => {
25
+ if (!active || !Array.isArray(params.value)) return;
26
+ for (const value of params.value) {
27
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
28
+ const event = value;
29
+ const bytes = Buffer.byteLength(JSON.stringify(event));
30
+ if (active.events.length >= MAX_TRACE_EVENTS || active.bytes + bytes > MAX_TRACE_BYTES) {
31
+ active.overflow = true;
32
+ continue;
33
+ }
34
+ active.events.push(event);
35
+ active.bytes += bytes;
36
+ }
37
+ });
38
+ const offComplete = client.on("Tracing.tracingComplete", (params) => {
39
+ if (!active) return;
40
+ active.dataLossOccurred = params.dataLossOccurred === true;
41
+ active.complete?.();
42
+ });
43
+ return {
44
+ async start(id) {
45
+ if (active) throw new Error("Only one CDP performance trace may be active.");
46
+ let complete;
47
+ const completePromise = new Promise((resolve) => {
48
+ complete = resolve;
49
+ });
50
+ active = {
51
+ id,
52
+ events: [],
53
+ bytes: 0,
54
+ overflow: false,
55
+ dataLossOccurred: false,
56
+ complete,
57
+ completePromise
58
+ };
59
+ let tracingStarted = false;
60
+ try {
61
+ await client.send(
62
+ "Tracing.start",
63
+ {
64
+ categories: (platform === "extension" ? EXTENSION_TRACE_CATEGORIES : MOBILE_TRACE_CATEGORIES).join(","),
65
+ transferMode: "ReportEvents"
66
+ },
67
+ 15e3
68
+ );
69
+ tracingStarted = true;
70
+ const markerName = `mmh-clock-${id}-${Date.now()}`;
71
+ const sync = await marker(markerName);
72
+ active.markerName = markerName;
73
+ active.markerHostEpochMs = sync.hostEpochMs;
74
+ active.markerUncertaintyMs = sync.uncertaintyMs;
75
+ } catch (error) {
76
+ if (tracingStarted) {
77
+ await client.send("Tracing.end", {}, 5e3).catch(() => void 0);
78
+ await withTimeout(active.completePromise, {
79
+ message: "CDP tracing cleanup did not report tracingComplete.",
80
+ timeoutMs: 1e3
81
+ }).catch(() => void 0);
82
+ }
83
+ active = void 0;
84
+ throw error;
85
+ }
86
+ },
87
+ async end(id) {
88
+ const capture = active;
89
+ if (!capture || capture.id !== id) {
90
+ throw new Error(`Performance capture is not active: ${id}`);
91
+ }
92
+ try {
93
+ await client.send("Tracing.end", {}, 15e3);
94
+ await withTimeout(capture.completePromise, {
95
+ message: "CDP tracing did not report tracingComplete.",
96
+ timeoutMs: 3e4
97
+ });
98
+ } finally {
99
+ active = void 0;
100
+ }
101
+ return parseTraceCapture({
102
+ platform,
103
+ events: capture.events,
104
+ markerName: capture.markerName,
105
+ markerHostEpochMs: capture.markerHostEpochMs,
106
+ markerUncertaintyMs: capture.markerUncertaintyMs,
107
+ dataLossOccurred: capture.dataLossOccurred,
108
+ overflow: capture.overflow,
109
+ unavailableReasons: [],
110
+ coverageGapReasons: []
111
+ });
112
+ },
113
+ async close() {
114
+ if (active) {
115
+ await client.send("Tracing.end", {}, 5e3).catch(() => void 0);
116
+ await withTimeout(active.completePromise, {
117
+ message: "CDP tracing cleanup did not report tracingComplete.",
118
+ timeoutMs: 1e3
119
+ }).catch(() => void 0);
120
+ active = void 0;
121
+ }
122
+ offData();
123
+ offComplete();
124
+ client.close();
125
+ }
126
+ };
127
+ }
128
+ function parseTraceCapture(capture) {
129
+ const marker = findClockMarker(capture.events, capture.markerName);
130
+ if (capture.platform === "extension" && !finite(marker?.pid)) {
131
+ const reason = "Extension trace marker did not identify a renderer process.";
132
+ return {
133
+ platform: capture.platform,
134
+ javascript: unavailableJavaScriptSource("cdp-js-runtime-tasks", reason),
135
+ nativeUi: unavailableNativeUiSource("cdp-native-frame-timings", reason),
136
+ trace: traceEvidence(capture, "unresolved")
137
+ };
138
+ }
139
+ const scopedEvents = capture.platform === "extension" && finite(marker?.pid) ? capture.events.filter((event) => event.pid === marker.pid) : capture.events;
140
+ const scope = capture.platform !== "extension" ? "mobile-host" : finite(marker?.pid) ? "extension-renderer" : "unresolved";
141
+ const trace = traceEvidence(
142
+ { ...capture, events: scopedEvents },
143
+ scope,
144
+ finite(marker?.pid) ? marker.pid : void 0
145
+ );
146
+ if (!finite(marker?.ts) || capture.markerHostEpochMs === void 0 || capture.markerUncertaintyMs === void 0) {
147
+ const reason = "CDP trace clock marker was unavailable.";
148
+ return {
149
+ platform: capture.platform,
150
+ javascript: unavailableJavaScriptSource("cdp-js-runtime-tasks", reason),
151
+ nativeUi: unavailableNativeUiSource(
152
+ "cdp-native-frame-timings",
153
+ reason
154
+ ),
155
+ trace
156
+ };
157
+ }
158
+ const traceToEpochMs = (timestampUs) => capture.markerHostEpochMs + (timestampUs - marker.ts) / 1e3;
159
+ const gapReasons = [
160
+ ...capture.coverageGapReasons,
161
+ ...capture.overflow ? ["CDP trace retention limit was reached."] : [],
162
+ ...capture.dataLossOccurred ? ["CDP reported trace data loss."] : []
163
+ ];
164
+ const nativeSamples = nativeFrameSamples(
165
+ scopedEvents,
166
+ capture.platform,
167
+ traceToEpochMs
168
+ );
169
+ const javascriptSamples = javascriptTaskSamples(
170
+ scopedEvents,
171
+ traceToEpochMs
172
+ );
173
+ const status = capture.overflow || capture.dataLossOccurred ? "partial" : "complete";
174
+ return {
175
+ platform: capture.platform,
176
+ javascript: javascriptSourceResult(
177
+ "cdp-js-runtime-tasks",
178
+ javascriptSamples,
179
+ status,
180
+ gapReasons,
181
+ traceEventNames(scopedEvents),
182
+ capture.markerUncertaintyMs
183
+ ),
184
+ nativeUi: nativeUiSourceResult(
185
+ capture.platform === "extension" ? "chromium-cdp-frame-timings" : "react-native-cdp-frame-timings",
186
+ nativeSamples,
187
+ status,
188
+ gapReasons,
189
+ traceEventNames(scopedEvents),
190
+ capture.markerUncertaintyMs
191
+ ),
192
+ trace
193
+ };
194
+ }
195
+ function traceEvidence(capture, scope, rendererProcessId) {
196
+ const counts = /* @__PURE__ */ new Map();
197
+ for (const event of capture.events) {
198
+ const name = String(event.name ?? "");
199
+ counts.set(name, (counts.get(name) ?? 0) + 1);
200
+ }
201
+ return {
202
+ beginFrameCount: counts.get("BeginFrame") ?? 0,
203
+ dataLossOccurred: capture.dataLossOccurred,
204
+ drawFrameCount: counts.get("DrawFrame") ?? 0,
205
+ overflow: capture.overflow,
206
+ profileChunkCount: counts.get("ProfileChunk") ?? 0,
207
+ ...rendererProcessId === void 0 ? {} : { rendererProcessId },
208
+ runTaskCount: counts.get("RunTask") ?? 0,
209
+ scope,
210
+ totalEventCount: capture.events.length
211
+ };
212
+ }
213
+ function nativeFrameSamples(events, platform, traceToEpochMs) {
214
+ const begins = /* @__PURE__ */ new Map();
215
+ const frames = [];
216
+ for (const event of events) {
217
+ if (event.name !== "BeginFrame" && event.name !== "DrawFrame") continue;
218
+ const sequence = frameSequence(event);
219
+ if (!sequence || !finite(event.ts)) continue;
220
+ if (event.name === "BeginFrame") {
221
+ begins.set(sequence, event);
222
+ continue;
223
+ }
224
+ const begin = begins.get(sequence);
225
+ if (!begin || !finite(begin.ts)) continue;
226
+ frames.push({ beginUs: begin.ts, endUs: event.ts });
227
+ }
228
+ frames.sort((first, second) => first.beginUs - second.beginUs);
229
+ if (platform === "ios") {
230
+ const samples = [];
231
+ for (let index = 1; index < frames.length; index += 1) {
232
+ const previous = frames[index - 1];
233
+ const current = frames[index];
234
+ if (!previous || !current) continue;
235
+ const durationMs = (current.beginUs - previous.beginUs) / 1e3;
236
+ if (durationMs <= 0) continue;
237
+ samples.push({
238
+ completedAtEpochMs: traceToEpochMs(current.beginUs),
239
+ durationMs,
240
+ janky: durationMs > FRAME_BUDGET_MS * 1.01
241
+ });
242
+ }
243
+ return samples;
244
+ }
245
+ return frames.map((frame) => {
246
+ const durationMs = (frame.endUs - frame.beginUs) / 1e3;
247
+ return {
248
+ completedAtEpochMs: traceToEpochMs(frame.endUs),
249
+ durationMs,
250
+ janky: durationMs > FRAME_BUDGET_MS * 1.01
251
+ };
252
+ }).filter((sample) => sample.durationMs > 0);
253
+ }
254
+ function javascriptTaskSamples(events, traceToEpochMs) {
255
+ return events.filter(
256
+ (event) => event.name === "RunTask" && event.ph === "X" && finite(event.ts) && finite(event.dur) && event.dur > 0
257
+ ).map((event) => ({
258
+ completedAtEpochMs: traceToEpochMs(event.ts + event.dur),
259
+ durationMs: event.dur / 1e3
260
+ }));
261
+ }
262
+ function findClockMarker(events, markerName) {
263
+ if (!markerName) return void 0;
264
+ return events.find(
265
+ (candidate) => candidate.name === markerName || asRecord(candidate.args).sync_id === markerName || asRecord(asRecord(candidate.args).data).sync_id === markerName
266
+ );
267
+ }
268
+ function frameSequence(event) {
269
+ const args = asRecord(event.args);
270
+ const value = args.frameSeqId ?? asRecord(args.data).frameSeqId;
271
+ return value === void 0 ? void 0 : String(value);
272
+ }
273
+ function javascriptSourceResult(kind, samples, status, coverageGapReasons, observedEventNames, clockSyncUncertaintyMs) {
274
+ return {
275
+ status: samples.length > 0 ? status : "partial",
276
+ kind,
277
+ unavailableReasons: [],
278
+ coverageGapReasons: [
279
+ ...coverageGapReasons,
280
+ ...samples.length > 0 ? [] : [
281
+ `No ${kind} samples were recorded. Observed trace events: ${observedEventNames.join(", ") || "none"}.`
282
+ ]
283
+ ],
284
+ samples,
285
+ summary: summarizeJavaScriptTasks(samples),
286
+ clockSyncUncertaintyMs
287
+ };
288
+ }
289
+ function nativeUiSourceResult(kind, samples, status, coverageGapReasons, observedEventNames, clockSyncUncertaintyMs) {
290
+ return {
291
+ status: samples.length > 0 ? status : "partial",
292
+ kind,
293
+ unavailableReasons: [],
294
+ coverageGapReasons: [
295
+ ...coverageGapReasons,
296
+ ...samples.length > 0 ? [] : [
297
+ `No ${kind} samples were recorded. Observed trace events: ${observedEventNames.join(", ") || "none"}.`
298
+ ]
299
+ ],
300
+ samples,
301
+ summary: summarizeFrames(samples),
302
+ clockSyncUncertaintyMs
303
+ };
304
+ }
305
+ function traceEventNames(events) {
306
+ const counts = /* @__PURE__ */ new Map();
307
+ for (const event of events) {
308
+ const name = String(event.name ?? "unknown");
309
+ counts.set(name, (counts.get(name) ?? 0) + 1);
310
+ }
311
+ return [...counts.entries()].sort((first, second) => second[1] - first[1]).slice(0, 20).map(([name, count]) => `${name}(${count})`);
312
+ }
313
+ function unavailableJavaScriptSource(kind, reason) {
314
+ return {
315
+ status: "unavailable",
316
+ kind,
317
+ unavailableReasons: [reason],
318
+ coverageGapReasons: [],
319
+ samples: [],
320
+ summary: { taskCount: 0 }
321
+ };
322
+ }
323
+ function unavailableNativeUiSource(kind, reason) {
324
+ return {
325
+ status: "unavailable",
326
+ kind,
327
+ unavailableReasons: [reason],
328
+ coverageGapReasons: [],
329
+ samples: [],
330
+ summary: { frameCount: 0 }
331
+ };
332
+ }
333
+ function finite(value) {
334
+ return typeof value === "number" && Number.isFinite(value);
335
+ }
336
+ function asRecord(value) {
337
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
338
+ }
339
+ export {
340
+ createCdpTraceCollector,
341
+ parseTraceCapture
342
+ };
@@ -0,0 +1,35 @@
1
+ const LONG_TASK_MS = 50;
2
+ function summarizeJavaScriptTasks(samples) {
3
+ const durations = samples.map((sample) => sample.durationMs).filter((duration) => Number.isFinite(duration) && duration > 0).sort((first, second) => first - second);
4
+ if (durations.length === 0) return { taskCount: 0 };
5
+ const longTaskCount = durations.filter(
6
+ (duration) => duration >= LONG_TASK_MS
7
+ ).length;
8
+ return {
9
+ longestTaskMs: round(durations.at(-1) ?? 0),
10
+ longTaskCount,
11
+ longTaskPercent: round(longTaskCount / durations.length * 100),
12
+ p50TaskMs: round(percentile(durations, 0.5)),
13
+ p95TaskMs: round(percentile(durations, 0.95)),
14
+ p99TaskMs: round(percentile(durations, 0.99)),
15
+ taskCount: durations.length,
16
+ totalTaskTimeMs: round(
17
+ durations.reduce((total, duration) => total + duration, 0)
18
+ )
19
+ };
20
+ }
21
+ function percentile(sorted, quantile) {
22
+ if (sorted.length === 1) return sorted[0] ?? 0;
23
+ const position = (sorted.length - 1) * quantile;
24
+ const lower = Math.floor(position);
25
+ const upper = Math.ceil(position);
26
+ const lowerValue = sorted[lower] ?? 0;
27
+ const upperValue = sorted[upper] ?? lowerValue;
28
+ return lowerValue + (upperValue - lowerValue) * (position - lower);
29
+ }
30
+ function round(value) {
31
+ return Math.round(value * 1e3) / 1e3;
32
+ }
33
+ export {
34
+ summarizeJavaScriptTasks
35
+ };
package/dist/adapters.js CHANGED
@@ -13,6 +13,7 @@ import { nativeAgentDeviceStateDir } from "../library/actions/mobile/platform/na
13
13
  import { resolveMobileToolPath } from "../library/actions/mobile/platform/tool-paths.mjs";
14
14
  import { resolveWalletImportCredentials, validateWalletImportOptions } from "../library/actions/shared/wallet/import-source.mjs";
15
15
  import { handleRunNetworkAction } from "./network-observation.js";
16
+ import { handleRunPerformanceAction } from "./performance-observation.js";
16
17
  const execFileAsync = promisify(execFile);
17
18
  const NATIVE_PROVIDER_UI_ACTIONS = /* @__PURE__ */ new Set([
18
19
  "ui.press",
@@ -107,7 +108,9 @@ const LIVE_ONLY_WALLET_ACTIONS = /* @__PURE__ */ new Set([
107
108
  const LIVE_ONLY_APP_ACTIONS = /* @__PURE__ */ new Set([
108
109
  "ui.navigate",
109
110
  "app.network_capture",
110
- "app.network_assert"
111
+ "app.network_assert",
112
+ "app.performance_capture",
113
+ "app.performance_assert"
111
114
  ]);
112
115
  function requiresLiveAdapter(platform, action) {
113
116
  return LIVE_ONLY_ACTIONS.has(action) || platform === "core" && CORE_ONLY_PERPS_ACTIONS.has(action) || LIVE_ONLY_WALLET_ACTIONS.has(action) || LIVE_ONLY_APP_ACTIONS.has(action);
@@ -145,6 +148,13 @@ function liveAdapterPathHint(platform, action) {
145
148
  return `library/actions/${platform}/${action.replaceAll(".", "/")}.mjs`;
146
149
  }
147
150
  async function semanticResult(platform, action, node, context, forceLive = false, preparedLiveAdapters) {
151
+ const performanceAction = await handleRunPerformanceAction(
152
+ platform,
153
+ action,
154
+ node,
155
+ context
156
+ );
157
+ if (performanceAction) return performanceAction;
148
158
  const networkAction = await handleRunNetworkAction(
149
159
  platform,
150
160
  action,
@@ -265,7 +275,12 @@ function createMetaMaskSemanticAdapters(platform, declaredCustomActions = [], pr
265
275
  ];
266
276
  const bundledActions = [
267
277
  ...walletActions,
268
- ...platform !== "core" ? ["app.network_capture", "app.network_assert"] : [],
278
+ ...platform !== "core" ? [
279
+ "app.network_capture",
280
+ "app.network_assert",
281
+ "app.performance_capture",
282
+ "app.performance_assert"
283
+ ] : [],
269
284
  "metamask.assets.read_visible_state",
270
285
  "metamask.assets.open_details",
271
286
  "metamask.assets.read_details",
@@ -511,6 +526,7 @@ const MOBILE_BRIDGE_HANDLERS = {
511
526
  status: handleMobileStatus,
512
527
  navigate: handleMobileNavigate,
513
528
  press: handleMobilePress,
529
+ keyPress: handleMobileKeyPress,
514
530
  setInput: handleMobileSetInput,
515
531
  scroll: handleMobileScroll,
516
532
  waitFor: handleMobileWaitFor,
@@ -570,6 +586,11 @@ async function handleMobilePress(payload, context) {
570
586
  if (text !== void 0) return bridgeCommand(input, ["press-text", target]);
571
587
  return bridgeCommand(input, [longPress ? "long-press-test-id" : "press-test-id", target]);
572
588
  }
589
+ async function handleMobileKeyPress(payload, context) {
590
+ const key = scalarText(payload.key, "ui.key_press.key", "Enter");
591
+ const input = mobileUiInput(context, "key_press", payload);
592
+ return bridgeCommand(input, ["key-press", key]);
593
+ }
573
594
  async function handleMobileSetInput(payload, context) {
574
595
  const input = mobileUiInput(context, "set_input", payload);
575
596
  const testId = firstScalarText(payload, ["test_id", "testID"], "ui.set_input");
@@ -738,7 +759,17 @@ async function handleMobileWaitFor(payload, context) {
738
759
  return waitForMobileTarget(mobileUiInput(context, "waitFor", payload), payload);
739
760
  }
740
761
  async function handleMobileHud(payload, context) {
741
- const input = mobileUiInput(context, "hud", payload);
762
+ const automaticProgress = typeof payload.action_name === "string" || context.nodeId === "recipe-complete" && isRecord(payload.progress) && payload.progress.complete === true;
763
+ const captureProgress = payload.action_name === "ui.screenshot" || payload.action_name === "ui.capture_surface";
764
+ const input = mobileUiInput(
765
+ context,
766
+ "hud",
767
+ automaticProgress ? {
768
+ ...payload,
769
+ bridge_timeout_ms: captureProgress ? 1e4 : 2e3,
770
+ cdp_timeout_ms: captureProgress ? 1e4 : 2e3
771
+ } : payload
772
+ );
742
773
  if (payload.clear === true) {
743
774
  try {
744
775
  return await bridgeCommand(input, ["hide-step"]);
@@ -748,7 +779,10 @@ async function handleMobileHud(payload, context) {
748
779
  }
749
780
  const hud = mobileHudPayload(payload, context);
750
781
  try {
751
- const result = await bridgeCommand(input, ["show-step-json", JSON.stringify(hud.step)]);
782
+ const result = await bridgeCommand(input, [
783
+ automaticProgress && !captureProgress ? "show-step-json-deferred" : "show-step-json",
784
+ JSON.stringify(hud.step)
785
+ ]);
752
786
  return { hud: true, nodeId: hud.nodeId, status: hud.status, result };
753
787
  } catch (error) {
754
788
  return mobileHudSkippedOrThrow(error, { nodeId: hud.nodeId, status: hud.status });
@@ -762,7 +796,7 @@ async function hideMobileHudOnTeardown(projectRoot, env = {}) {
762
796
  context: { nodeId: "teardown", projectRoot, artifactsDir: projectRoot, env }
763
797
  };
764
798
  try {
765
- await bridgeCommand(input, ["hide-step"]);
799
+ await bridgeCommand(input, ["hide-step-deferred"]);
766
800
  } catch {
767
801
  }
768
802
  }
@@ -0,0 +1,92 @@
1
+ import { constants as fsConstants } from "node:fs";
2
+ import {
3
+ lstat,
4
+ mkdir,
5
+ open,
6
+ readFile,
7
+ realpath,
8
+ writeFile
9
+ } from "node:fs/promises";
10
+ import path from "node:path";
11
+ async function writeContainedArtifact(artifactsDir, relativePath, value, label) {
12
+ const file = resolveContainedArtifact(artifactsDir, relativePath, label);
13
+ await mkdir(path.dirname(file), { recursive: true });
14
+ await assertContainedParent(artifactsDir, file, label);
15
+ await assertNotSymlink(file, label);
16
+ const handle = await open(
17
+ file,
18
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | fsConstants.O_NOFOLLOW,
19
+ 384
20
+ );
21
+ try {
22
+ await handle.writeFile(value);
23
+ } finally {
24
+ await handle.close();
25
+ }
26
+ }
27
+ async function readContainedJsonArtifact(artifactsDir, relativePath, maxBytes, label) {
28
+ const file = resolveContainedArtifact(artifactsDir, relativePath, label);
29
+ await assertContainedParent(artifactsDir, file, label);
30
+ await assertNotSymlink(file, label);
31
+ const handle = await open(file, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
32
+ try {
33
+ const info = await handle.stat();
34
+ if (!info.isFile() || info.size > maxBytes) {
35
+ throw new Error(`${label} is not a bounded regular file.`);
36
+ }
37
+ return JSON.parse(await handle.readFile("utf8"));
38
+ } finally {
39
+ await handle.close();
40
+ }
41
+ }
42
+ async function indexArtifactManifest(manifestPath, entries) {
43
+ const parsed = JSON.parse(await readFile(manifestPath, "utf8"));
44
+ const manifest = asRecord(parsed);
45
+ const replacementPaths = new Set(entries.map((entry) => entry.path));
46
+ const current = Array.isArray(manifest.artifacts) ? manifest.artifacts.filter(
47
+ (artifact) => !replacementPaths.has(String(asRecord(artifact).path ?? ""))
48
+ ) : [];
49
+ await writeFile(
50
+ manifestPath,
51
+ `${JSON.stringify(
52
+ { ...manifest, artifacts: [...current, ...entries] },
53
+ null,
54
+ 2
55
+ )}
56
+ `
57
+ );
58
+ }
59
+ function resolveContainedArtifact(artifactsDir, relativePath, label) {
60
+ const root = path.resolve(artifactsDir);
61
+ const file = path.resolve(root, relativePath);
62
+ const relative = path.relative(root, file);
63
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
64
+ throw new Error(`${label} path must stay inside artifactsDir.`);
65
+ }
66
+ return file;
67
+ }
68
+ async function assertContainedParent(artifactsDir, file, label) {
69
+ const root = await realpath(artifactsDir);
70
+ const parent = await realpath(path.dirname(file));
71
+ const relative = path.relative(root, parent);
72
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
73
+ throw new Error(`${label} parent must stay inside artifactsDir.`);
74
+ }
75
+ }
76
+ async function assertNotSymlink(file, label) {
77
+ try {
78
+ if ((await lstat(file)).isSymbolicLink()) {
79
+ throw new Error(`${label} must not be a symbolic link.`);
80
+ }
81
+ } catch (error) {
82
+ if (error.code !== "ENOENT") throw error;
83
+ }
84
+ }
85
+ function asRecord(value) {
86
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
87
+ }
88
+ export {
89
+ indexArtifactManifest,
90
+ readContainedJsonArtifact,
91
+ writeContainedArtifact
92
+ };
package/dist/async.js ADDED
@@ -0,0 +1,19 @@
1
+ async function withTimeout(promise, options) {
2
+ let timer;
3
+ try {
4
+ return await Promise.race([
5
+ promise,
6
+ new Promise((_resolve, reject) => {
7
+ timer = setTimeout(() => {
8
+ options.onTimeout?.();
9
+ reject(new Error(options.message));
10
+ }, options.timeoutMs);
11
+ })
12
+ ]);
13
+ } finally {
14
+ if (timer) clearTimeout(timer);
15
+ }
16
+ }
17
+ export {
18
+ withTimeout
19
+ };