@deeeed/metamask-harness 0.73.0 → 0.75.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 (62) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +1 -1
  3. package/adapters/shared/ensure-runner-deps.sh +15 -3
  4. package/dist/action-values.js +15 -0
  5. package/dist/adapters/core/actions.js +20 -0
  6. package/dist/adapters/core/live-script-env.js +36 -0
  7. package/dist/adapters/core/run.js +54 -0
  8. package/dist/adapters/core/surface.js +8 -1
  9. package/dist/adapters/extension/action-inputs.js +206 -0
  10. package/dist/adapters/extension/actions.js +51 -0
  11. package/dist/adapters/extension/console-capture.js +13 -1
  12. package/dist/adapters/extension/launch.js +315 -0
  13. package/dist/adapters/extension/launched-browser.js +51 -0
  14. package/dist/adapters/extension/run.js +105 -0
  15. package/dist/adapters/extension/surface.js +17 -1
  16. package/dist/adapters/mobile/actions.js +54 -0
  17. package/dist/adapters/mobile/launch.js +192 -0
  18. package/dist/adapters/mobile/lifecycle.js +300 -0
  19. package/dist/adapters/mobile/run.js +156 -0
  20. package/dist/adapters/mobile/runtime-identity.js +34 -0
  21. package/dist/adapters/mobile/surface.js +19 -1
  22. package/dist/adapters/mobile/ui.js +701 -0
  23. package/dist/adapters/performance/cdp-trace.js +24 -6
  24. package/dist/adapters/performance/device-platform.js +10 -0
  25. package/dist/adapters/registry.js +17 -0
  26. package/dist/adapters/surface.js +6 -11
  27. package/dist/adapters/terminal/actions.js +43 -0
  28. package/dist/adapters/terminal/console-capture.js +14 -1
  29. package/dist/adapters/terminal/launch.js +85 -0
  30. package/dist/adapters/terminal/run.js +30 -0
  31. package/dist/adapters/terminal/surface.js +9 -1
  32. package/dist/adapters.js +32 -824
  33. package/dist/cli-commands.js +1 -0
  34. package/dist/command-contract.js +3 -0
  35. package/dist/commands/call.js +2 -5
  36. package/dist/commands/launch/device.js +342 -0
  37. package/dist/commands/launch/index.js +9 -942
  38. package/dist/commands/launch/quote.js +7 -0
  39. package/dist/commands/pin.js +226 -0
  40. package/dist/commands/review.js +3 -1
  41. package/dist/commands/run-engine.js +38 -138
  42. package/dist/commands/run-report.js +4 -48
  43. package/dist/commands/run.js +6 -48
  44. package/dist/commands/shared.js +2 -2
  45. package/dist/doctor.js +3 -1
  46. package/dist/execution-provenance.js +3 -2
  47. package/dist/harness-pin.js +10 -0
  48. package/dist/heal-bounds.js +3 -2
  49. package/dist/live-adapter-contract.js +8 -34
  50. package/dist/manifest.js +3 -5
  51. package/dist/metamask-action-validation.js +5 -196
  52. package/dist/mm-harness-cli.js +27 -2
  53. package/dist/network-observation.js +10 -3
  54. package/dist/performance-observation.js +15 -22
  55. package/dist/pr-body/render.js +3 -1
  56. package/dist/recording-target.js +4 -39
  57. package/dist/run-diagnostics.js +21 -60
  58. package/dist/run-recording.js +5 -4
  59. package/dist/runner.js +20 -368
  60. package/docs/CONTRIBUTING.md +20 -3
  61. package/package.json +2 -1
  62. package/scripts/completions.sh +2 -1
@@ -0,0 +1,701 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { bridgeCommand, evalAsync, evalSync, MOBILE_BRIDGE_ERROR_CODES, selectBridgeStatusEntry, simulatorScreenshot } from "../../../library/actions/mobile/platform/bridge.mjs";
4
+ import { observeNativeUi } from "../../../library/actions/mobile/platform/observe-ui.mjs";
5
+ import { nativeAgentDeviceStateDir } from "../../../library/actions/mobile/platform/native-session-name.mjs";
6
+ import { resolveMobileToolPath } from "../../../library/actions/mobile/platform/tool-paths.mjs";
7
+ import { isRecord, traceText } from "../../action-values.js";
8
+ import { runLiveAdapterScript } from "../../live-adapter-contract.js";
9
+ const execFileAsync = promisify(execFile);
10
+ function sleep(ms) {
11
+ return new Promise((resolve) => setTimeout(resolve, ms));
12
+ }
13
+ function optionalScalarText(value, label) {
14
+ if (value === void 0 || value === null) return void 0;
15
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
16
+ return String(value);
17
+ }
18
+ throw new Error(`${label} must be a string, number, or boolean.`);
19
+ }
20
+ function scalarText(value, label, fallback) {
21
+ return optionalScalarText(value, label) ?? fallback;
22
+ }
23
+ function firstScalarText(record, keys, label, fallback) {
24
+ for (const key of keys) {
25
+ const value = optionalScalarText(record[key], `${label}.${key}`);
26
+ if (value !== void 0) return value;
27
+ }
28
+ if (fallback !== void 0) return fallback;
29
+ throw new Error(`${label} requires one of: ${keys.join(", ")}.`);
30
+ }
31
+ function uiInputFor(context, input) {
32
+ return {
33
+ action: input.action,
34
+ node: input.node,
35
+ context: {
36
+ nodeId: context.nodeId,
37
+ projectRoot: context.projectRoot,
38
+ artifactsDir: context.artifactsDir,
39
+ env: context.env
40
+ }
41
+ };
42
+ }
43
+ function mobileProbeOutput(status, input, projectRoot) {
44
+ const entries = Array.isArray(status) ? status : [status];
45
+ const selected = selectBridgeStatusEntry(status, input) ?? null;
46
+ const route = isRecord(selected) && isRecord(selected.route) ? selected.route : null;
47
+ return {
48
+ reachable: isRecord(selected) && selected.agenticPresent === true && Boolean(route),
49
+ bridge: mobileBridgePath(projectRoot),
50
+ targetCount: entries.filter((entry) => isRecord(entry)).length,
51
+ deviceName: isRecord(selected) && typeof selected.deviceName === "string" ? selected.deviceName : null,
52
+ routeName: route && typeof route.name === "string" ? route.name : null,
53
+ accountPresent: isRecord(selected) && isRecord(selected.account)
54
+ };
55
+ }
56
+ function mobileBridgePath(_projectRoot) {
57
+ return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT || "runner:adapters/mobile/bridge-runtime/cdp-bridge.cjs";
58
+ }
59
+ function resolveMobileWaitTarget(payload) {
60
+ const testId = optionalScalarText(
61
+ payload.test_id ?? payload.testID,
62
+ "Mobile ui.wait_for.test_id"
63
+ );
64
+ const expectedText = optionalScalarText(payload.text, "ui.wait_for.text");
65
+ if (!testId && !expectedText) {
66
+ throw new Error(
67
+ "Mobile ui.wait_for requires one of: test_id, testID, text."
68
+ );
69
+ }
70
+ const expected = scalarText(
71
+ payload.expected,
72
+ "ui.wait_for.expected",
73
+ "present"
74
+ ).toLowerCase();
75
+ const textMatch = scalarText(
76
+ payload.text_match ?? payload.textMatch,
77
+ "ui.wait_for.text_match",
78
+ "contains"
79
+ ).toLowerCase();
80
+ if (!["contains", "exact"].includes(textMatch)) {
81
+ throw new Error("ui.wait_for.text_match must be contains or exact.");
82
+ }
83
+ if (!testId && textMatch === "exact") {
84
+ throw new Error(
85
+ "Mobile text-only ui.wait_for supports text_match=contains; exact matching also requires test_id/testID."
86
+ );
87
+ }
88
+ return {
89
+ testId,
90
+ expectedText,
91
+ expected,
92
+ textMatch,
93
+ visibility: expected === "visible" || expected === "hidden" ? "viewport" : "tree"
94
+ };
95
+ }
96
+ const RETRYABLE_MOBILE_WAIT_CODES = /* @__PURE__ */ new Set([
97
+ MOBILE_BRIDGE_ERROR_CODES.NO_TARGET,
98
+ MOBILE_BRIDGE_ERROR_CODES.CDP_TIMEOUT,
99
+ MOBILE_BRIDGE_ERROR_CODES.WS_CLOSED,
100
+ MOBILE_BRIDGE_ERROR_CODES.METRO_UNREACHABLE
101
+ ]);
102
+ function isRetryableMobileWaitError(error) {
103
+ return Boolean(
104
+ isRecord(error) && typeof error.code === "string" && RETRYABLE_MOBILE_WAIT_CODES.has(error.code)
105
+ );
106
+ }
107
+ async function waitForMobileTarget(input, payload) {
108
+ const timeoutMs = Number(payload.timeout_ms ?? payload.timeoutMs ?? 1e4);
109
+ const deadline = Date.now() + timeoutMs;
110
+ const { testId, expectedText, expected, textMatch, visibility } = resolveMobileWaitTarget(payload);
111
+ const query = {
112
+ ...testId ? { testId } : {},
113
+ ...expectedText && textMatch === "contains" ? { textContains: expectedText } : {},
114
+ visibility
115
+ };
116
+ const queryExpression = `(function(){
117
+ const api = globalThis.__AGENTIC__;
118
+ if (!api || typeof api.queryUiTarget !== 'function') {
119
+ return Promise.resolve(JSON.stringify({ unsupported: true }));
120
+ }
121
+ return api.queryUiTarget(${JSON.stringify(query)}).then(function(result){
122
+ return JSON.stringify(result);
123
+ });
124
+ })()`;
125
+ const textExpression = testId ? `(function(){
126
+ const api = globalThis.__AGENTIC__;
127
+ if (!api?.getTextByTestId) return null;
128
+ return api.getTextByTestId(${JSON.stringify(testId)});
129
+ })()` : null;
130
+ let lastResult = null;
131
+ let lastText = null;
132
+ let lastBridgeError = null;
133
+ const expectsAbsent = expected === "absent" || expected === "hidden" || expected === "not_present";
134
+ while (Date.now() <= deadline) {
135
+ const remainingMs = Math.max(1, deadline - Date.now());
136
+ const probeTimeoutMs = Math.min(5e3, remainingMs);
137
+ const probeInput = {
138
+ ...input,
139
+ node: {
140
+ ...input.node,
141
+ bridge_timeout_ms: probeTimeoutMs,
142
+ cdp_timeout_ms: probeTimeoutMs
143
+ }
144
+ };
145
+ let result;
146
+ try {
147
+ result = await evalAsync(probeInput, queryExpression);
148
+ } catch (error) {
149
+ if (!isRetryableMobileWaitError(error)) throw error;
150
+ lastBridgeError = error;
151
+ await sleep(250);
152
+ continue;
153
+ }
154
+ if (!isRecord(result) || result.unsupported === true) {
155
+ throw new Error(
156
+ "Mobile ui.wait_for requires the current __AGENTIC__.queryUiTarget bridge for text-only or viewport-visible assertions."
157
+ );
158
+ }
159
+ lastResult = result;
160
+ const present = result.present === true;
161
+ const visible = result.visible === true;
162
+ let textMatched = !expectedText || textMatch === "contains";
163
+ if (expectedText && textMatch === "exact" && textExpression) {
164
+ try {
165
+ lastText = await evalSync(probeInput, textExpression);
166
+ } catch (error) {
167
+ if (!isRetryableMobileWaitError(error)) throw error;
168
+ lastBridgeError = error;
169
+ await sleep(250);
170
+ continue;
171
+ }
172
+ textMatched = traceText(lastText) === expectedText;
173
+ }
174
+ const matched = expected === "visible" ? visible && textMatched : expected === "hidden" ? !visible : expectsAbsent ? !present : present && textMatched;
175
+ if (matched) {
176
+ return {
177
+ matched: true,
178
+ ...testId ? { testId } : {},
179
+ ...expectedText ? { text: expectedText, textMatch } : {},
180
+ expected,
181
+ present,
182
+ visible,
183
+ visibility,
184
+ ...isRecord(result.rect) ? { rect: result.rect } : {}
185
+ };
186
+ }
187
+ await sleep(250);
188
+ }
189
+ const target = [
190
+ testId ? `testID ${JSON.stringify(testId)}` : "",
191
+ expectedText ? `text ${textMatch} ${JSON.stringify(expectedText)}` : ""
192
+ ].filter(Boolean).join(" with ");
193
+ const exactReason = textMatch === "exact" ? `; last text=${JSON.stringify(lastText)}` : "";
194
+ const bridgeReason = lastBridgeError ? `; last bridge error=${traceText(lastBridgeError)}` : "";
195
+ throw new Error(
196
+ `Timed out waiting for mobile ${target} to be ${expected}; last result=${JSON.stringify(lastResult)}${exactReason}${bridgeReason}.`
197
+ );
198
+ }
199
+ const MOBILE_BRIDGE_HANDLERS = {
200
+ screenshot: handleMobileScreenshot,
201
+ status: handleMobileStatus,
202
+ navigate: handleMobileNavigate,
203
+ press: handleMobilePress,
204
+ keyPress: handleMobileKeyPress,
205
+ setInput: handleMobileSetInput,
206
+ scroll: handleMobileScroll,
207
+ waitFor: handleMobileWaitFor,
208
+ hud: handleMobileHud,
209
+ observeUi: handleMobileObserveUi
210
+ };
211
+ async function sendMobileBridgeCommand(command, context) {
212
+ const handler = MOBILE_BRIDGE_HANDLERS[command.command];
213
+ if (!handler) {
214
+ throw new Error(`React Native bridge command ${command.command} is not implemented by the MetaMask runner transport.`);
215
+ }
216
+ return handler(command.payload, context);
217
+ }
218
+ function mobileUiInput(context, command, payload) {
219
+ return uiInputFor(context, { action: `ui.${command}`, node: payload });
220
+ }
221
+ async function handleMobileScreenshot(payload, context) {
222
+ const input = mobileUiInput(context, "screenshot", payload);
223
+ const relPath = scalarText(payload.path, "ui.screenshot.path", `screenshots/${context.nodeId}.png`);
224
+ const artifact = await simulatorScreenshot(input, relPath);
225
+ context.registerArtifact(artifact);
226
+ return { captured: true, path: artifact.path, artifact };
227
+ }
228
+ async function handleMobileStatus(payload, context) {
229
+ return bridgeCommand(mobileUiInput(context, "status", payload), ["status"]);
230
+ }
231
+ async function handleMobileObserveUi(payload, context) {
232
+ return observeNativeUi(payload, context);
233
+ }
234
+ async function handleMobileNavigate(payload, context) {
235
+ const live = await runLiveAdapterScript({
236
+ platform: "mobile",
237
+ action: "ui.navigate",
238
+ node: payload,
239
+ context
240
+ });
241
+ if (!live) throw new Error("ui.navigate requires library/actions/mobile/ui/navigate.mjs.");
242
+ return isRecord(live.result) ? { ...live.result, liveAdapter: live.script } : live.result;
243
+ }
244
+ async function handleMobilePress(payload, context) {
245
+ const identifiers = ["test_id", "testID", "selector"].map((field) => optionalScalarText(payload[field], `ui.press.${field}`)).filter((value) => value !== void 0);
246
+ const text = optionalScalarText(payload.text, "ui.press.text");
247
+ if (identifiers.length + Number(text !== void 0) !== 1) {
248
+ throw new Error("ui.press requires exactly one of: test_id, testID, selector, text.");
249
+ }
250
+ const target = text ?? identifiers[0];
251
+ if (target.trim() === "") throw new Error("ui.press target must be non-empty.");
252
+ const longPress = payload.long_press === true;
253
+ if (longPress && text !== void 0) {
254
+ throw new Error("ui.press long_press=true requires test_id, testID, or selector targeting.");
255
+ }
256
+ const input = mobileUiInput(context, "press", payload);
257
+ if (text !== void 0) return bridgeCommand(input, ["press-text", target]);
258
+ return bridgeCommand(input, [longPress ? "long-press-test-id" : "press-test-id", target]);
259
+ }
260
+ async function handleMobileKeyPress(payload, context) {
261
+ const key = scalarText(payload.key, "ui.key_press.key", "Enter");
262
+ const input = mobileUiInput(context, "key_press", payload);
263
+ return bridgeCommand(input, ["key-press", key]);
264
+ }
265
+ async function handleMobileSetInput(payload, context) {
266
+ const input = mobileUiInput(context, "set_input", payload);
267
+ const testId = firstScalarText(payload, ["test_id", "testID"], "ui.set_input");
268
+ const value = scalarText(payload.value ?? payload.text, "ui.set_input.value", "");
269
+ const result = await bridgeCommand(input, ["set-input", testId, value]);
270
+ if (isRecord(result) && result.ok === false) {
271
+ throw new Error(`ui.set_input failed for mobile testID ${testId}: ${traceText(result.error ?? result)}`);
272
+ }
273
+ return isRecord(result) ? result : { result, testId, value };
274
+ }
275
+ async function handleMobileScroll(payload, context) {
276
+ const input = mobileUiInput(context, "scroll", payload);
277
+ const testId = optionalScalarText(payload.test_id ?? payload.testID, "ui.scroll.test_id");
278
+ const offset = scalarText(payload.offset ?? payload.delta_y ?? payload.deltaY, "ui.scroll.offset", "600");
279
+ const intoView = payload.scroll_into_view === true || payload.into_view === true;
280
+ const measureFromTestId = optionalScalarText(
281
+ payload.measure_from_test_id ?? payload.measureFromTestId,
282
+ "ui.scroll.measure_from_test_id"
283
+ );
284
+ const measureTargetTestId = optionalScalarText(
285
+ payload.measure_target_test_id ?? payload.measureTargetTestId,
286
+ "ui.scroll.measure_target_test_id"
287
+ );
288
+ const measureTargetText = optionalScalarText(
289
+ payload.measure_target_text ?? payload.measureTargetText,
290
+ "ui.scroll.measure_target_text"
291
+ );
292
+ const measureTargetCount = Number(Boolean(measureTargetTestId)) + Number(Boolean(measureTargetText));
293
+ const measureRequiredPresentTestId = optionalScalarText(
294
+ payload.measure_required_present_test_id ?? payload.measureRequiredPresentTestId,
295
+ "ui.scroll.measure_required_present_test_id"
296
+ );
297
+ if (Boolean(measureFromTestId) !== (measureTargetCount === 1)) {
298
+ throw new Error(
299
+ "ui.scroll visible-transition measurement requires measure_from_test_id and exactly one of measure_target_test_id or measure_target_text."
300
+ );
301
+ }
302
+ if (measureFromTestId && measureTargetCount === 1) {
303
+ if (intoView) {
304
+ throw new Error(
305
+ "ui.scroll visible-transition measurement uses an explicit offset and does not support scroll_into_view."
306
+ );
307
+ }
308
+ return measureMobileScrollTransition(input, {
309
+ animated: payload.animated === true,
310
+ fromTestId: measureFromTestId,
311
+ offset: Number(offset),
312
+ ...measureTargetTestId ? { targetTestId: measureTargetTestId } : {},
313
+ ...measureTargetText ? { targetText: measureTargetText } : {},
314
+ ...measureRequiredPresentTestId ? { requiredPresentTestId: measureRequiredPresentTestId } : {},
315
+ timeoutMs: Math.max(
316
+ 1,
317
+ Number(payload.timeout_ms ?? payload.timeoutMs ?? 3e4)
318
+ ),
319
+ ...testId ? { scrollTestId: testId } : {}
320
+ });
321
+ }
322
+ if (testId && intoView) {
323
+ const before = await queryMobileViewportTarget(input, testId);
324
+ if (before.visible === true) {
325
+ return {
326
+ ok: true,
327
+ testId,
328
+ intoView: true,
329
+ alreadyVisible: true,
330
+ rect: before.rect
331
+ };
332
+ }
333
+ }
334
+ const args = testId ? [
335
+ "scroll-view",
336
+ "--test-id",
337
+ testId,
338
+ "--offset",
339
+ offset,
340
+ ...intoView ? ["--into-view"] : [],
341
+ animatedFlag(payload)
342
+ ] : ["scroll-view", "--offset", offset, animatedFlag(payload)];
343
+ const timeoutMs = Math.max(0, Number(payload.timeout_ms ?? payload.timeoutMs ?? 5e3));
344
+ const deadline = Date.now() + timeoutMs;
345
+ let result;
346
+ while (true) {
347
+ try {
348
+ result = await bridgeCommand(input, args);
349
+ break;
350
+ } catch (error) {
351
+ if (!testId || Date.now() >= deadline) throw error;
352
+ await sleep(100);
353
+ }
354
+ }
355
+ if (testId && intoView) {
356
+ const after = await waitForMobileViewportTarget(
357
+ input,
358
+ testId,
359
+ timeoutMs
360
+ );
361
+ if (after.visible !== true) {
362
+ throw new Error(
363
+ `ui.scroll did not bring mobile testID ${JSON.stringify(testId)} into the viewport; last result=${JSON.stringify(after)}.`
364
+ );
365
+ }
366
+ }
367
+ return {
368
+ ...isRecord(result) ? result : { result },
369
+ intoView
370
+ };
371
+ }
372
+ async function measureMobileScrollTransition(input, options) {
373
+ const result = await bridgeCommand(
374
+ {
375
+ ...input,
376
+ node: {
377
+ ...input.node,
378
+ bridge_timeout_ms: options.timeoutMs + 5e3,
379
+ cdp_timeout_ms: options.timeoutMs + 5e3
380
+ }
381
+ },
382
+ ["measure-scroll-transition", JSON.stringify(options)]
383
+ );
384
+ if (!isRecord(result) || result.unsupported === true) {
385
+ throw new Error(
386
+ "ui.scroll visible-transition measurement requires queryUiTarget and scrollView on the current mobile bridge."
387
+ );
388
+ }
389
+ if (result.ok !== true) {
390
+ throw new Error(
391
+ `ui.scroll visible-transition measurement failed: ${traceText(result.error ?? result)}.`
392
+ );
393
+ }
394
+ return {
395
+ ...result,
396
+ measureFromTestId: options.fromTestId,
397
+ ...options.targetTestId ? { measureTargetTestId: options.targetTestId } : {},
398
+ ...options.targetText ? { measureTargetText: options.targetText } : {},
399
+ ...options.requiredPresentTestId ? { measureRequiredPresentTestId: options.requiredPresentTestId } : {}
400
+ };
401
+ }
402
+ async function queryMobileViewportTarget(input, testId) {
403
+ const expression = `(function(){
404
+ const api = globalThis.__AGENTIC__;
405
+ if (!api || typeof api.queryUiTarget !== 'function') {
406
+ return Promise.resolve(JSON.stringify({ unsupported: true }));
407
+ }
408
+ return api.queryUiTarget(${JSON.stringify({ testId, visibility: "viewport" })}).then(function(result){
409
+ return JSON.stringify(result);
410
+ });
411
+ })()`;
412
+ const result = await evalAsync(input, expression);
413
+ if (!isRecord(result) || result.unsupported === true) {
414
+ throw new Error(
415
+ "Mobile ui.scroll scroll_into_view requires the current __AGENTIC__.queryUiTarget bridge."
416
+ );
417
+ }
418
+ return result;
419
+ }
420
+ async function waitForMobileViewportTarget(input, testId, timeoutMs) {
421
+ const deadline = Date.now() + Math.max(0, Math.min(timeoutMs, 5e3));
422
+ let result = await queryMobileViewportTarget(input, testId);
423
+ while (result.visible !== true && Date.now() < deadline) {
424
+ await sleep(100);
425
+ result = await queryMobileViewportTarget(input, testId);
426
+ }
427
+ return result;
428
+ }
429
+ async function handleMobileWaitFor(payload, context) {
430
+ return waitForMobileTarget(mobileUiInput(context, "waitFor", payload), payload);
431
+ }
432
+ async function handleMobileHud(payload, context) {
433
+ const automaticProgress = typeof payload.action_name === "string" || context.nodeId === "recipe-complete" && isRecord(payload.progress) && payload.progress.complete === true;
434
+ const captureProgress = payload.action_name === "ui.screenshot" || payload.action_name === "ui.capture_surface";
435
+ const input = mobileUiInput(
436
+ context,
437
+ "hud",
438
+ automaticProgress ? {
439
+ ...payload,
440
+ bridge_timeout_ms: captureProgress ? 1e4 : 2e3,
441
+ cdp_timeout_ms: captureProgress ? 1e4 : 2e3
442
+ } : payload
443
+ );
444
+ if (payload.clear === true) {
445
+ try {
446
+ return await bridgeCommand(input, ["hide-step"]);
447
+ } catch (error) {
448
+ return mobileHudSkippedOrThrow(error);
449
+ }
450
+ }
451
+ const hud = mobileHudPayload(payload, context);
452
+ try {
453
+ const result = await bridgeCommand(input, [
454
+ automaticProgress && !captureProgress ? "show-step-json-deferred" : "show-step-json",
455
+ JSON.stringify(hud.step)
456
+ ]);
457
+ return { hud: true, nodeId: hud.nodeId, status: hud.status, result };
458
+ } catch (error) {
459
+ return mobileHudSkippedOrThrow(error, { nodeId: hud.nodeId, status: hud.status });
460
+ }
461
+ }
462
+ async function hideMobileHudOnTeardown(projectRoot, env = {}) {
463
+ if (env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1" || process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1") return;
464
+ if (process.env.METAMASK_RECIPE_AUTO_HUD === "0" || process.env.METAMASK_RECIPE_AUTO_HUD === "false") return;
465
+ const input = {
466
+ node: { bridge_timeout_ms: 8e3, cdp_timeout_ms: 5e3 },
467
+ context: { nodeId: "teardown", projectRoot, artifactsDir: projectRoot, env }
468
+ };
469
+ try {
470
+ await bridgeCommand(input, ["hide-step-deferred"]);
471
+ } catch {
472
+ }
473
+ }
474
+ function mobileHudSkippedOrThrow(error, extra = {}) {
475
+ const reason = traceText(error);
476
+ const code = isRecord(error) && typeof error.code === "string" ? error.code : void 0;
477
+ if (isMobileHudLifecycleSkip(reason, code)) {
478
+ process.stderr.write(`app.hud skipped (bridge target down or transitioning): ${reason}
479
+ `);
480
+ return {
481
+ hud: false,
482
+ skipped: true,
483
+ warning: "app.hud skipped because the mobile bridge target is down during lifecycle transition.",
484
+ ...extra,
485
+ reason
486
+ };
487
+ }
488
+ throw new Error(`app.hud bridge command failed outside a lifecycle/no-target transition: ${reason}`);
489
+ }
490
+ const HUD_LIFECYCLE_SKIP_CODES = /* @__PURE__ */ new Set([
491
+ MOBILE_BRIDGE_ERROR_CODES.NO_TARGET,
492
+ MOBILE_BRIDGE_ERROR_CODES.CDP_TIMEOUT,
493
+ MOBILE_BRIDGE_ERROR_CODES.WS_CLOSED,
494
+ MOBILE_BRIDGE_ERROR_CODES.METRO_UNREACHABLE
495
+ ]);
496
+ function isMobileHudLifecycleSkip(reason, code) {
497
+ if (code && HUD_LIFECYCLE_SKIP_CODES.has(code)) return true;
498
+ const haystack = reason.toLowerCase();
499
+ return [
500
+ "no responding bridge target",
501
+ "pinned android device",
502
+ "bridge probe failed",
503
+ "econnrefused",
504
+ "timed out",
505
+ "no react native bridge target",
506
+ // Metro down mid-run: HUD cannot paint, so skip rather than abort (the real
507
+ // action fails on its own). Matches the METRO_UNREACHABLE code branch above.
508
+ "cannot reach metro",
509
+ // Dev-client foreground/reload kills the Hermes page mid-flight: pending
510
+ // CDP calls surface as a message timeout or a closed socket.
511
+ "cdp message timeout",
512
+ "websocket closed"
513
+ ].some((needle) => haystack.includes(needle));
514
+ }
515
+ function animatedFlag(payload) {
516
+ return payload.animated === true ? "--animated" : "--no-animated";
517
+ }
518
+ function mobileHudPayload(payload, context) {
519
+ const nodeId = scalarText(payload.node_id ?? payload.nodeId, "app.hud.node_id", context.nodeId);
520
+ const status = scalarText(payload.status, "app.hud.status", "running");
521
+ const text = optionalScalarText(payload.intent ?? payload.text ?? payload.detail, "app.hud.intent");
522
+ if (!text || !text.trim()) {
523
+ throw new Error("app.hud requires intent/text/detail; automatic recipe progress should provide node intent.");
524
+ }
525
+ const detail = scalarText(payload.detail, "app.hud.detail", "");
526
+ const error = scalarText(payload.error, "app.hud.error", "");
527
+ const progress = mobileHudProgress(payload.progress);
528
+ const display = isRecord(payload.display) ? payload.display : {};
529
+ const showDebug = display.showDebug === true;
530
+ const showDetail = display.showDetail === true;
531
+ const proofTarget = payload.proofTarget ?? payload.proof_target;
532
+ const displayId = [mobileHudStatusLabel(status), progress.text].filter(Boolean).join(" ");
533
+ return {
534
+ nodeId,
535
+ status,
536
+ step: {
537
+ id: displayId || nodeId,
538
+ nodeId,
539
+ status,
540
+ intent: text,
541
+ progress: progress.value,
542
+ detail: showDetail && detail !== text ? detail : void 0,
543
+ error: error || void 0,
544
+ debug: showDebug ? {
545
+ nodeId,
546
+ proofTarget
547
+ } : void 0
548
+ }
549
+ };
550
+ }
551
+ function mobileHudProgress(progress) {
552
+ if (!isRecord(progress)) return { text: "" };
553
+ if (typeof progress.current !== "number" || typeof progress.total !== "number") return { text: "" };
554
+ return {
555
+ text: `${progress.current}/${progress.total}`,
556
+ value: { current: progress.current, total: progress.total }
557
+ };
558
+ }
559
+ function mobileHudStatusLabel(status) {
560
+ if (status === "fail") return "fail";
561
+ if (status === "pass") return "pass";
562
+ return "run";
563
+ }
564
+ async function probeMobileBridgeTarget(node, context) {
565
+ const uiInput = { action: "cdp.target", nodeId: context.nodeId, node };
566
+ const status = await bridgeCommand(uiInputFor(context, uiInput), ["status"]);
567
+ const probe = mobileProbeOutput(status, uiInput, context.projectRoot);
568
+ if (!probe.reachable) {
569
+ throw new Error("cdp.target required a reachable mobile React Native bridge, but bridge status did not expose a selected app route.");
570
+ }
571
+ return probe;
572
+ }
573
+ async function executeNativeProviderAction(action, node, context, createTransport) {
574
+ if (!createTransport) {
575
+ throw new Error(
576
+ `${action} requires the MetaMask native UI provider. Next: npm install -g @deeeed/metamask-harness@latest`
577
+ );
578
+ }
579
+ const platform = mobileNativePlatform(context.env);
580
+ const device = platform === "android" ? context.env.ADB_SERIAL ?? context.env.ANDROID_SERIAL ?? process.env.ADB_SERIAL ?? process.env.ANDROID_SERIAL : context.env.SIM_UDID ?? context.env.IOS_SIMULATOR ?? process.env.SIM_UDID ?? process.env.IOS_SIMULATOR;
581
+ if (!device) {
582
+ throw new Error(
583
+ `${action} requires one selected ${platform} device. Next: mm-harness status --adapter mobile --all-devices --json`
584
+ );
585
+ }
586
+ if (platform === "android" && action === "ui.swipe" && isCoordinateTarget(node.target)) {
587
+ return executeAndroidCoordinateSwipe(node, String(device));
588
+ }
589
+ const app = platform === "android" ? context.env.ANDROID_PACKAGE_ID ?? process.env.ANDROID_PACKAGE_ID ?? "io.metamask" : context.env.IOS_BUNDLE_ID ?? process.env.IOS_BUNDLE_ID ?? "io.metamask.MetaMask";
590
+ const session = (platform === "ios" ? `mm-harness-${process.pid}-${device}` : `mm-harness-${process.pid}-${context.nodeId}`).replace(/[^a-zA-Z0-9._-]/gu, "-");
591
+ const stateDir = nativeAgentDeviceStateDir(context.env ?? {}, context.projectRoot, String(device));
592
+ let transport;
593
+ try {
594
+ transport = await createTransport({ platform, device, app, session, stateDir });
595
+ } catch (error) {
596
+ throw new Error(
597
+ `${action} could not start the MetaMask native UI provider. Next: npm install -g @deeeed/metamask-harness@latest`,
598
+ { cause: error }
599
+ );
600
+ }
601
+ let executionError;
602
+ let output;
603
+ try {
604
+ output = await transport.execute(action, node, context);
605
+ } catch (error) {
606
+ const message = error instanceof Error ? error.message : String(error);
607
+ executionError = /^Native recipe actions require the optional agent-device provider:/u.test(message) ? new Error(
608
+ `${action} could not start the MetaMask native UI provider. Next: npm install -g @deeeed/metamask-harness@latest`,
609
+ { cause: error }
610
+ ) : error;
611
+ }
612
+ let closeError;
613
+ try {
614
+ await transport.close?.();
615
+ } catch (error) {
616
+ closeError = error;
617
+ }
618
+ if (executionError && closeError) {
619
+ const message = executionError instanceof Error ? executionError.message : String(executionError);
620
+ throw new AggregateError([executionError, closeError], message);
621
+ }
622
+ if (executionError) throw executionError;
623
+ if (closeError) throw closeError;
624
+ return output;
625
+ }
626
+ function isCoordinateTarget(value) {
627
+ return isRecord(value) && typeof value.x === "number" && Number.isFinite(value.x) && typeof value.y === "number" && Number.isFinite(value.y);
628
+ }
629
+ async function executeAndroidCoordinateSwipe(node, device) {
630
+ const target = node.target;
631
+ const direction = String(node.direction ?? "").toLowerCase();
632
+ if (!["up", "down", "left", "right"].includes(direction)) {
633
+ throw new Error("ui.swipe.direction must be up, down, left, or right.");
634
+ }
635
+ const distance = Number(node.distance ?? 300);
636
+ const durationMs = Number(node.duration_ms ?? 300);
637
+ if (!Number.isFinite(distance) || distance <= 0) {
638
+ throw new Error("ui.swipe.distance must be a positive number.");
639
+ }
640
+ if (!Number.isFinite(durationMs) || durationMs <= 0) {
641
+ throw new Error("ui.swipe.duration_ms must be a positive number.");
642
+ }
643
+ const start = { x: Math.round(target.x), y: Math.round(target.y) };
644
+ const end = {
645
+ x: Math.round(start.x + (direction === "left" ? -distance : direction === "right" ? distance : 0)),
646
+ y: Math.round(start.y + (direction === "up" ? -distance : direction === "down" ? distance : 0))
647
+ };
648
+ const adbPath = resolveMobileToolPath("adb", { required: true });
649
+ try {
650
+ await execFileAsync(
651
+ adbPath,
652
+ [
653
+ "-s",
654
+ device,
655
+ "shell",
656
+ "input",
657
+ "swipe",
658
+ String(start.x),
659
+ String(start.y),
660
+ String(end.x),
661
+ String(end.y),
662
+ String(Math.round(durationMs))
663
+ ],
664
+ { encoding: "utf8", timeout: Math.round(durationMs) + 1e4 }
665
+ );
666
+ } catch (error) {
667
+ throw new Error(
668
+ `Android ui.swipe failed on ${device}: ${error instanceof Error ? error.message : String(error)}`
669
+ );
670
+ }
671
+ const settleMs = node.settle === false ? 0 : 300;
672
+ if (settleMs > 0) await sleep(settleMs);
673
+ return {
674
+ action: "ui.swipe",
675
+ resolvedStart: start,
676
+ resolvedEnd: end,
677
+ backend: "adb-input-swipe",
678
+ segments: 1,
679
+ settlement: {
680
+ method: "bounded-post-gesture",
681
+ waitedMs: settleMs,
682
+ lifecycleNeutral: true
683
+ }
684
+ };
685
+ }
686
+ function mobileNativePlatform(env) {
687
+ if (env.PLATFORM === "android" || env.PLATFORM === "ios") return env.PLATFORM;
688
+ if (env.ADB_SERIAL || env.ANDROID_SERIAL) return "android";
689
+ if (env.SIM_UDID || env.IOS_SIMULATOR) return "ios";
690
+ if (process.env.PLATFORM === "android" || process.env.PLATFORM === "ios") return process.env.PLATFORM;
691
+ return process.env.ADB_SERIAL || process.env.ANDROID_SERIAL ? "android" : "ios";
692
+ }
693
+ export {
694
+ executeNativeProviderAction,
695
+ hideMobileHudOnTeardown,
696
+ isMobileHudLifecycleSkip,
697
+ isRetryableMobileWaitError,
698
+ probeMobileBridgeTarget,
699
+ resolveMobileWaitTarget,
700
+ sendMobileBridgeCommand
701
+ };