@deeeed/metamask-harness 0.86.2 → 0.88.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 (61) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/adapters/extension/ensure-browser.sh +6 -10
  3. package/adapters/extension/inject.mjs +1 -0
  4. package/adapters/extension/launch-browser.cjs +31 -4
  5. package/adapters/extension/profile-extension-state.cjs +156 -0
  6. package/adapters/extension/readiness.mjs +21 -2
  7. package/adapters/manifest.json +8 -0
  8. package/adapters/mobile/wait-for-bridge.cjs +9 -3
  9. package/dist/adapters/extension/actions.js +2 -1
  10. package/dist/adapters/extension/run.js +6 -0
  11. package/dist/adapters/mobile/action-inputs.js +20 -0
  12. package/dist/adapters/mobile/actions.js +18 -1
  13. package/dist/adapters/mobile/android-screenrecord.js +274 -0
  14. package/dist/adapters/mobile/readiness.js +2 -1
  15. package/dist/adapters/mobile/recording-outcome.js +54 -0
  16. package/dist/adapters/mobile/recording-target.js +11 -0
  17. package/dist/adapters/mobile/run.js +58 -15
  18. package/dist/adapters/mobile/ui.js +96 -2
  19. package/dist/adapters/perps-mainnet-cleanup.js +122 -0
  20. package/dist/adapters/perps-order-inputs.js +43 -28
  21. package/dist/adapters.js +4 -1
  22. package/dist/commands/doctor.js +33 -14
  23. package/dist/commands/status-probe.js +2 -1
  24. package/dist/engine.js +11 -2
  25. package/dist/funding-execution-context.js +13 -8
  26. package/dist/host.js +6 -2
  27. package/dist/index.js +3 -0
  28. package/dist/mm-harness-cli.js +4 -2
  29. package/dist/provider.js +72 -0
  30. package/dist/recipe-security.js +1 -0
  31. package/docs/CONTRIBUTING.md +7 -0
  32. package/docs/RECIPES.md +96 -9
  33. package/docs/RELEASE-QA-CAPABILITY-MAP.md +5 -1
  34. package/docs/cli-contract.md +1 -1
  35. package/library/actions/core/perps/read_account.mjs +4 -2
  36. package/library/actions/core/perps/read_orders.mjs +4 -2
  37. package/library/actions/core/perps/read_positions.mjs +4 -2
  38. package/library/actions/extension/analytics/consent.mjs +7 -2
  39. package/library/actions/extension/perps/compare_provider_market.mjs +12 -8
  40. package/library/actions/extension/perps/mutation-receipt.mjs +124 -26
  41. package/library/actions/extension/perps/perps.mjs +298 -39
  42. package/library/actions/extension/perps/read_snapshot.mjs +6 -2
  43. package/library/actions/extension/perps/set_network.mjs +6 -0
  44. package/library/actions/extension/platform/cdp.mjs +6 -2
  45. package/library/actions/extension/ui/navigate.mjs +8 -0
  46. package/library/actions/mobile/perps/perps.mjs +264 -102
  47. package/library/actions/mobile/perps/place_order.mjs +7 -4
  48. package/library/actions/mobile/platform/bridge.mjs +171 -10
  49. package/library/actions/mobile/wallet/home.mjs +7 -2
  50. package/library/actions/mobile/wallet/select_account.mjs +4 -0
  51. package/library/actions/shared/app/live-adapter-deadline.mjs +8 -0
  52. package/library/actions/shared/perps/mainnet-order-ledger.mjs +378 -0
  53. package/library/actions/shared/perps/order-policy.mjs +56 -19
  54. package/library/actions/shared/perps/rate-limit.mjs +93 -0
  55. package/library/actions/shared/perps/visible-state.mjs +3 -2
  56. package/library/actions/shared/wallet/fixture-accounts.mjs +9 -3
  57. package/library/manifests/extension.action-manifest.json +36 -5
  58. package/library/manifests/mobile.action-manifest.json +48 -10
  59. package/package.json +14 -9
  60. package/scripts/validate-release-report.mjs +14 -2
  61. package/site/assets/help-recipes.json +2 -1
@@ -0,0 +1,274 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { createAndroidVideoRecorder } from "@farmslot/adapter-rn";
5
+ import { optionalVideoTiming } from "@farmslot/recipe-runner";
6
+ import { recordingFailure } from "./recording-outcome.js";
7
+ const SEGMENT_SECONDS = 180;
8
+ const START_TIMEOUT_MS = 5e3;
9
+ const STOP_TIMEOUT_MS = 1e4;
10
+ const START_GRACE_MS = 1e3;
11
+ const HOST_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
12
+ function createAndroidScreenrecordRecorder(serial, options = {}) {
13
+ const adb = (args) => spawnSync("adb", ["-s", serial, ...args], { encoding: "utf8", timeout: 6e4 });
14
+ return {
15
+ name: "adb-screenrecord",
16
+ platform: "android",
17
+ async doctor() {
18
+ const device = await createAndroidVideoRecorder(serial).doctor();
19
+ if (!device.ok) return device;
20
+ const ffmpeg = spawnSync(ffmpegPath(), ["-version"], { encoding: "utf8", timeout: 1e4 });
21
+ if (ffmpeg.status !== 0) {
22
+ return {
23
+ ok: false,
24
+ code: "ffmpeg_unavailable",
25
+ message: `ffmpeg, which joins Android screenrecord files, is unavailable: ${failure(ffmpeg)}`,
26
+ suggestedFix: "Install ffmpeg (brew install ffmpeg) or set FFMPEG_PATH."
27
+ };
28
+ }
29
+ return { ok: true, code: "ok", message: `Android on-device screen recording is ready on ${serial}.` };
30
+ },
31
+ start: (request) => startRecording(serial, request, adb, options)
32
+ };
33
+ }
34
+ async function startRecording(serial, request, adb, options) {
35
+ if (request.target.kind !== "android-device" || request.target.serial !== serial) {
36
+ throw new Error("Android recording target does not match the selected device.");
37
+ }
38
+ const pollMs = options.pollMs ?? 100;
39
+ const host = options.host ?? process;
40
+ const running = screenrecordPids(adb);
41
+ if (running.length > 0) {
42
+ throw new Error(`Android screenrecord is already running on ${serial} (pid ${running.join(", ")}). Stop that recording before retrying.`);
43
+ }
44
+ const prefix = `${options.remoteDir ?? "/sdcard/Download"}/mm-harness-recording-${Date.now()}-${process.pid}`;
45
+ const segments = [];
46
+ let stopping = false;
47
+ let interruption;
48
+ let chain = Promise.resolve();
49
+ const localFiles = /* @__PURE__ */ new Set();
50
+ const segmentFailure = (segment) => `screenrecord on ${serial} stopped with exit ${segment.exitCode ?? "error"}: ${segment.output.trim() || "no recorder output"}`;
51
+ const startSegment = async () => {
52
+ const remotePath = `${prefix}-${segments.length}.mp4`;
53
+ const startedAtUnixMs = Date.now();
54
+ const child = spawn(
55
+ "adb",
56
+ ["-s", serial, "shell", "screenrecord", "--time-limit", String(options.segmentSeconds ?? SEGMENT_SECONDS), remotePath],
57
+ { stdio: ["ignore", "ignore", "pipe"] }
58
+ );
59
+ const exited = new Promise((resolve) => {
60
+ child.once("error", () => resolve(null));
61
+ child.once("exit", (code) => resolve(code));
62
+ });
63
+ const segment = { remotePath, child, output: "", startedAtUnixMs, exited };
64
+ child.stderr?.setEncoding("utf8");
65
+ child.stderr?.on("data", (chunk) => {
66
+ segment.output = (segment.output + chunk).slice(-2e3);
67
+ });
68
+ segments.push(segment);
69
+ void exited.then((code) => {
70
+ segment.stoppedAtUnixMs = Date.now();
71
+ segment.exitCode = code;
72
+ if (!segment.pid || segment.killed || interruption || stopping) return;
73
+ if (code !== 0) {
74
+ interruption = segmentFailure(segment);
75
+ return;
76
+ }
77
+ chain = startSegment().catch((error) => {
78
+ interruption ??= error instanceof Error ? error.message : String(error);
79
+ });
80
+ });
81
+ const deadline = Date.now() + (options.startTimeoutMs ?? START_TIMEOUT_MS);
82
+ while (Date.now() < deadline) {
83
+ if (segment.stoppedAtUnixMs !== void 0) {
84
+ throw new Error(`Android screenrecord exited before capture started on ${serial}: ${segment.output.trim() || "no recorder output"}`);
85
+ }
86
+ const pid = screenrecordPids(adb)[0];
87
+ if (pid) {
88
+ segment.pid = pid;
89
+ return;
90
+ }
91
+ await delay(pollMs);
92
+ }
93
+ child.kill("SIGTERM");
94
+ throw new Error(`Android screenrecord did not start on ${serial} within ${options.startTimeoutMs ?? START_TIMEOUT_MS}ms.`);
95
+ };
96
+ const abandon = () => {
97
+ const live = segments.filter((segment) => segment.stoppedAtUnixMs === void 0);
98
+ for (const segment of live) segment.killed = true;
99
+ if (live.length > 0) {
100
+ const starting = live.some((segment) => !segment.pid);
101
+ const graceUntil = Date.now() + (starting ? START_GRACE_MS : 0);
102
+ const giveUpAt = Date.now() + (options.stopTimeoutMs ?? STOP_TIMEOUT_MS);
103
+ const signaled = /* @__PURE__ */ new Set();
104
+ while (Date.now() < giveUpAt) {
105
+ const pids = screenrecordPids(adb).filter((pid) => starting || live.some((segment) => segment.pid === pid));
106
+ if (pids.length === 0 && Date.now() >= graceUntil) break;
107
+ for (const pid of pids) {
108
+ if (!signaled.has(pid)) adb(["shell", "kill", "-2", pid]);
109
+ signaled.add(pid);
110
+ }
111
+ sleepSync(pollMs);
112
+ }
113
+ for (const segment of live) segment.child.kill("SIGTERM");
114
+ }
115
+ if (segments.length > 0) adb(["shell", "rm", "-f", ...segments.map((segment) => segment.remotePath)]);
116
+ for (const localPath of localFiles) fs.rmSync(localPath, { force: true });
117
+ };
118
+ const onExit = () => abandon();
119
+ const signalHandlers = Object.fromEntries(
120
+ HOST_SIGNALS.map((signal) => [
121
+ signal,
122
+ () => {
123
+ stopping = true;
124
+ abandon();
125
+ removeGuards();
126
+ if (host.listenerCount(signal) === 0) host.kill(host.pid, signal);
127
+ }
128
+ ])
129
+ );
130
+ const removeGuards = () => {
131
+ host.removeListener("exit", onExit);
132
+ for (const signal of HOST_SIGNALS) host.removeListener(signal, signalHandlers[signal]);
133
+ };
134
+ host.once("exit", onExit);
135
+ for (const signal of HOST_SIGNALS) host.once(signal, signalHandlers[signal]);
136
+ try {
137
+ await startSegment();
138
+ } catch (error) {
139
+ stopping = true;
140
+ abandon();
141
+ removeGuards();
142
+ throw error;
143
+ }
144
+ let stopped = false;
145
+ return {
146
+ async stop() {
147
+ if (stopped) throw new Error(`Android screen recording on ${serial} was already stopped.`);
148
+ stopped = true;
149
+ stopping = true;
150
+ const stagingDir = path.dirname(request.outputPath);
151
+ try {
152
+ await chain;
153
+ const tail = segments.at(-1);
154
+ if (tail.stoppedAtUnixMs === void 0 && tail.pid) adb(["shell", "kill", "-2", tail.pid]);
155
+ if (!await settles(tail.exited, options.stopTimeoutMs ?? STOP_TIMEOUT_MS)) {
156
+ tail.killed = true;
157
+ tail.child.kill("SIGKILL");
158
+ throw new Error(`Android screenrecord on ${serial} did not stop within ${options.stopTimeoutMs ?? STOP_TIMEOUT_MS}ms.`);
159
+ }
160
+ const kept = interruption ? segments.filter((segment) => segment.exitCode === 0) : tail.exitCode === 0 ? segments : segments.slice(0, -1);
161
+ if (kept.length === 0) {
162
+ throw recordingFailure(interruption ? "RECORDER_INTERRUPTED" : "RECORDING_FAILED", interruption ?? segmentFailure(tail));
163
+ }
164
+ fs.mkdirSync(stagingDir, { recursive: true });
165
+ const recorded = [];
166
+ for (const [index, segment] of kept.entries()) {
167
+ const localPath = path.join(stagingDir, `${path.basename(request.outputPath)}.segment-${index}.mp4`);
168
+ localFiles.add(localPath);
169
+ const pull = adb(["pull", segment.remotePath, localPath]);
170
+ if (pull.status !== 0) throw new Error(`Could not pull Android recording from ${serial}: ${failure(pull)}`);
171
+ const bytes = fs.statSync(localPath).size;
172
+ if (bytes > 0) recorded.push({ ...segment, localPath, bytes });
173
+ }
174
+ if (recorded.length === 0) throw recordingFailure("RECORDING_FAILED", `Android recording output is empty: ${request.outputPath}`);
175
+ const startedAtUnixMs = recorded[0].startedAtUnixMs;
176
+ const stoppedAtUnixMs = recorded.at(-1).stoppedAtUnixMs;
177
+ const placed = recorded.map((segment) => ({ ...segment, offsetMs: segment.startedAtUnixMs - startedAtUnixMs }));
178
+ joinSegments(placed, request.outputPath);
179
+ const timingEvidencePath = `${path.basename(request.outputPath)}.segments.json`;
180
+ fs.writeFileSync(
181
+ path.join(stagingDir, timingEvidencePath),
182
+ `${JSON.stringify({
183
+ recorder: "adb-screenrecord",
184
+ serial,
185
+ segments: placed.map((segment) => ({
186
+ offsetMs: segment.offsetMs,
187
+ startedAtUnixMs: segment.startedAtUnixMs,
188
+ stoppedAtUnixMs: segment.stoppedAtUnixMs,
189
+ bytes: segment.bytes
190
+ }))
191
+ })}
192
+ `
193
+ );
194
+ const timing = await optionalVideoTiming(request.outputPath, { startedAtUnixMs, stoppedAtUnixMs });
195
+ const recorder = { name: "adb-screenrecord", platform: "android", target: { selector: "adb-serial", value: serial } };
196
+ return {
197
+ ...timing.timing ? { timing: { ...timing.timing, clock: { ...timing.timing.clock, latestZeroUnixMs: latestVideoZero(timing.timing, placed) } } } : timing,
198
+ timingEvidencePath,
199
+ recorder,
200
+ // The runner keeps the files finished before the failure and fails the run as CAPTURE_INTERRUPTED.
201
+ ...interruption ? {
202
+ interruption: {
203
+ frames: timing.timing?.framesMs.length ?? 0,
204
+ mediaTimeMs: timing.timing?.framesMs.at(-1) ?? 0,
205
+ cause: interruption
206
+ }
207
+ } : {}
208
+ };
209
+ } finally {
210
+ abandon();
211
+ removeGuards();
212
+ }
213
+ }
214
+ };
215
+ }
216
+ function latestVideoZero(timing, segments) {
217
+ return segments.reduce((latest, segment, index) => {
218
+ const to = segments[index + 1]?.offsetMs ?? Infinity;
219
+ const last = timing.framesMs.filter((time) => time >= segment.offsetMs && time < to).at(-1);
220
+ return last === void 0 ? latest : Math.max(latest, segment.stoppedAtUnixMs - last);
221
+ }, timing.clock.latestZeroUnixMs);
222
+ }
223
+ function joinSegments(segments, outputPath) {
224
+ if (segments.length === 1) {
225
+ fs.renameSync(segments[0].localPath, outputPath);
226
+ return;
227
+ }
228
+ const listPath = `${outputPath}.segments.txt`;
229
+ const list = segments.map((segment, index) => {
230
+ const entry = `file '${segment.localPath.replaceAll("'", "'\\''")}'`;
231
+ const next = segments[index + 1];
232
+ return next ? `${entry}
233
+ duration ${((next.offsetMs - segment.offsetMs) / 1e3).toFixed(3)}` : entry;
234
+ });
235
+ fs.writeFileSync(listPath, `${list.join("\n")}
236
+ `);
237
+ try {
238
+ const join = spawnSync(ffmpegPath(), ["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", listPath, "-c", "copy", outputPath], {
239
+ encoding: "utf8"
240
+ });
241
+ if (join.status !== 0) throw new Error(`Could not join Android recording segments: ${failure(join)}`);
242
+ } finally {
243
+ fs.rmSync(listPath, { force: true });
244
+ }
245
+ }
246
+ function ffmpegPath() {
247
+ return process.env.FFMPEG_PATH || "ffmpeg";
248
+ }
249
+ function screenrecordPids(adb) {
250
+ const result = adb(["shell", "pidof", "screenrecord"]);
251
+ if (result.status !== 0) return [];
252
+ return result.stdout.trim().split(/\s+/u).filter(Boolean);
253
+ }
254
+ function settles(promise, ms) {
255
+ return new Promise((resolve) => {
256
+ const timer = setTimeout(() => resolve(false), ms);
257
+ void promise.then(() => {
258
+ clearTimeout(timer);
259
+ resolve(true);
260
+ });
261
+ });
262
+ }
263
+ function failure(result) {
264
+ return result.error?.message || result.stderr?.trim() || result.stdout?.trim() || `exit ${result.status ?? "unknown"}`;
265
+ }
266
+ function sleepSync(ms) {
267
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
268
+ }
269
+ function delay(ms) {
270
+ return new Promise((resolve) => setTimeout(resolve, ms));
271
+ }
272
+ export {
273
+ createAndroidScreenrecordRecorder
274
+ };
@@ -18,7 +18,8 @@ const mobileReadiness = {
18
18
  orphanDevServers: detectOrphanMetros,
19
19
  captureProviders: ["simctl", "adb"],
20
20
  fixes: [{ id: "mobile-perps-env", apply: (target) => ensureMobilePerpsEnvironment(target).length > 0 }],
21
- devices: async () => (await import("./devices.js")).mobileDevices
21
+ devices: async () => (await import("./devices.js")).mobileDevices,
22
+ recorder: async () => (await import("./run.js")).mobileRecorderCheck()
22
23
  };
23
24
  async function mobileRuntimeDecision(target, options) {
24
25
  const watcherPortRaw = optionString(options, "watcherPort") ?? process.env.WATCHER_PORT ?? process.env.METRO_PORT;
@@ -0,0 +1,54 @@
1
+ import { errorMessage, RecipeExecutionError } from "@farmslot/recipe-runner";
2
+ function recordingFailure(code, cause) {
3
+ const reason = cause.trim().replace(/[\s.:]+$/u, "");
4
+ return new RecipeExecutionError("environment", `${code}: ${reason}. The run has no video.`, { code, details: { cause: reason } });
5
+ }
6
+ function withRecordingOutcomes(recorder) {
7
+ return {
8
+ name: recorder.name,
9
+ get version() {
10
+ return recorder.version;
11
+ },
12
+ platform: recorder.platform,
13
+ // The runner asks this before start; a recorder that cannot record fails
14
+ // the run with a code, as a failed start does.
15
+ async doctor() {
16
+ let result;
17
+ try {
18
+ result = await recorder.doctor?.();
19
+ } catch (error) {
20
+ throw recordingFailure("RECORDING_FAILED", `${recorder.name} readiness check failed: ${errorMessage(error)}`);
21
+ }
22
+ if (result && !result.ok) {
23
+ const next = result.suggestedFix ? ` Next: ${result.suggestedFix}` : "";
24
+ throw recordingFailure("RECORDING_FAILED", `${recorder.name} ${result.code}: ${result.message}${next}`);
25
+ }
26
+ return result ?? { ok: true, code: "ok", message: `${recorder.name} has no readiness check.` };
27
+ },
28
+ async start(request) {
29
+ let active;
30
+ try {
31
+ active = await recorder.start(request);
32
+ } catch (error) {
33
+ throw coded(error) ?? recordingFailure("RECORDING_FAILED", errorMessage(error));
34
+ }
35
+ return {
36
+ ...active.snapshot ? { snapshot: (outputPath) => active.snapshot(outputPath) } : {},
37
+ async stop() {
38
+ try {
39
+ return await active.stop();
40
+ } catch (error) {
41
+ throw coded(error) ?? recordingFailure("RECORDING_FAILED", errorMessage(error));
42
+ }
43
+ }
44
+ };
45
+ }
46
+ };
47
+ }
48
+ function coded(error) {
49
+ return error instanceof RecipeExecutionError && typeof error.code === "string" ? error : void 0;
50
+ }
51
+ export {
52
+ recordingFailure,
53
+ withRecordingOutcomes
54
+ };
@@ -9,7 +9,18 @@ function resolveMobileIosSimulatorName(env = process.env) {
9
9
  const suffix = slot.includes("-") ? slot.slice(slot.lastIndexOf("-") + 1) : slot;
10
10
  return suffix || void 0;
11
11
  }
12
+ function resolveAndroidRecorderKind(serial, env = process.env) {
13
+ const forced = env.METAMASK_RECIPE_MOBILE_ANDROID_RECORDER?.trim().toLowerCase();
14
+ if (forced === "screenrecord" || forced === "mirror") return forced;
15
+ if (forced) {
16
+ throw new Error(
17
+ `METAMASK_RECIPE_MOBILE_ANDROID_RECORDER must be screenrecord or mirror, not "${forced}". Next: unset METAMASK_RECIPE_MOBILE_ANDROID_RECORDER`
18
+ );
19
+ }
20
+ return /^emulator-\d+$/u.test(serial) ? "mirror" : "screenrecord";
21
+ }
12
22
  export {
13
23
  resolveAndroidDeviceSerial,
24
+ resolveAndroidRecorderKind,
14
25
  resolveMobileIosSimulatorName
15
26
  };
@@ -5,6 +5,7 @@ import { IOS_SIMULATOR_RECORDING_TARGET_PREFIX } from "@farmslot/adapter-rn";
5
5
  import { devicePlatformFromEnv } from "../performance/device-platform.js";
6
6
  import {
7
7
  resolveAndroidDeviceSerial,
8
+ resolveAndroidRecorderKind,
8
9
  resolveMobileIosSimulatorName
9
10
  } from "./recording-target.js";
10
11
  import { emitProofFailure } from "../proof-failure.js";
@@ -60,11 +61,17 @@ const mobileRun = {
60
61
  // An installed release artifact is opaque: it has no dev bridge and no
61
62
  // AgentStepHud, so there is nothing to draw the HUD with.
62
63
  autoHud: () => process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME !== "1",
63
- // Always clear a HUD step the engine left on-device (a failed run strands
64
- // the FAIL banner). Best-effort — never masks the run's real outcome.
64
+ // Cancel the mainnet limit orders the run opened (a failure fails the run),
65
+ // and always clear a HUD step the engine left on-device (a failed run strands
66
+ // the FAIL banner; best-effort, never masks the run's real outcome).
65
67
  async teardown(projectRoot, env) {
66
- const { hideMobileHudOnTeardown } = await import("./ui.js");
67
- await hideMobileHudOnTeardown(projectRoot, env);
68
+ try {
69
+ const { cancelRunMainnetOrders } = await import("../perps-mainnet-cleanup.js");
70
+ await cancelRunMainnetOrders(projectRoot, env, "teardown");
71
+ } finally {
72
+ const { hideMobileHudOnTeardown } = await import("./ui.js");
73
+ await hideMobileHudOnTeardown(projectRoot, env);
74
+ }
68
75
  },
69
76
  // A release artifact is checked before the overlay install; the loaded
70
77
  // source only matters for a development build the caller did not restart.
@@ -94,20 +101,55 @@ async function ensureMobileProofRuntime(target, _heal, state, json, deps = {}) {
94
101
  }
95
102
  const mobileRecording = {
96
103
  target: (context) => resolveMobileRecordingTarget(context),
97
- // An Android device records through the mirror, with screenrecord as the
98
- // fallback; otherwise the selected iOS simulator records.
99
104
  async videoRecorder() {
100
- const androidSerial = resolveAndroidDeviceSerial();
101
- const iosSimulator = androidSerial ? void 0 : resolveMobileIosSimulatorName();
102
- if (!androidSerial && !iosSimulator) return void 0;
103
- const { createAndroidVideoRecorder, createIosSimulatorVideoRecorder } = await import("@farmslot/adapter-rn");
104
- if (androidSerial) {
105
- const { createAndroidMirrorVideoRecorder } = await import("@farmslot/recipe-runner");
106
- return createAndroidMirrorVideoRecorder({ serial: androidSerial, fallback: createAndroidVideoRecorder(androidSerial) });
107
- }
108
- return createIosSimulatorVideoRecorder(iosSimulator);
105
+ const selected = await selectMobileRecorder();
106
+ if (!selected) return void 0;
107
+ const { withRecordingOutcomes } = await import("./recording-outcome.js");
108
+ return withRecordingOutcomes(selected.recorder);
109
109
  }
110
110
  };
111
+ async function selectMobileRecorder() {
112
+ const androidSerial = resolveAndroidDeviceSerial();
113
+ const iosSimulator = androidSerial ? void 0 : resolveMobileIosSimulatorName();
114
+ if (!androidSerial && !iosSimulator) return void 0;
115
+ if (androidSerial && resolveAndroidRecorderKind(androidSerial) === "screenrecord") {
116
+ const { createAndroidScreenrecordRecorder } = await import("./android-screenrecord.js");
117
+ return { recorder: createAndroidScreenrecordRecorder(androidSerial), device: androidSerial };
118
+ }
119
+ const { createAndroidVideoRecorder, createIosSimulatorVideoRecorder } = await import("@farmslot/adapter-rn");
120
+ if (androidSerial) {
121
+ const { createAndroidMirrorVideoRecorder } = await import("@farmslot/recipe-runner");
122
+ const mirror = createAndroidMirrorVideoRecorder({ serial: androidSerial, fallback: createAndroidVideoRecorder(androidSerial) });
123
+ return { recorder: mirror, device: androidSerial };
124
+ }
125
+ return { recorder: createIosSimulatorVideoRecorder(iosSimulator), device: iosSimulator };
126
+ }
127
+ async function mobileRecorderCheck() {
128
+ let selected;
129
+ try {
130
+ selected = await selectMobileRecorder();
131
+ } catch (error) {
132
+ return {
133
+ id: "recorder",
134
+ status: "fail",
135
+ required: true,
136
+ message: "No recorder can be selected for this device.",
137
+ detail: error instanceof Error ? error.message : String(error),
138
+ userAction: "unset METAMASK_RECIPE_MOBILE_ANDROID_RECORDER, then rerun mm-harness doctor"
139
+ };
140
+ }
141
+ if (!selected) return void 0;
142
+ const { recorder, device } = selected;
143
+ const doctor = await recorder.doctor?.() ?? { ok: true, code: "ok", message: `${recorder.name} has no readiness check.` };
144
+ return {
145
+ id: "recorder",
146
+ status: doctor.ok ? "pass" : "fail",
147
+ required: true,
148
+ message: doctor.ok ? `Recording on ${device} uses ${recorder.name}.` : `Recording on ${device} uses ${recorder.name}, which cannot record (${doctor.code}).`,
149
+ detail: doctor.message,
150
+ ...doctor.ok ? {} : { userAction: doctor.suggestedFix ?? "fix the recorder named above, then rerun mm-harness doctor" }
151
+ };
152
+ }
111
153
  async function resolveMobileRecordingTarget(context) {
112
154
  const platform = (context.env.PLATFORM ?? process.env.PLATFORM ?? "ios").trim().toLowerCase();
113
155
  const androidSerial = resolveAndroidDeviceSerial(context.env);
@@ -176,6 +218,7 @@ export {
176
218
  ensureMobileProofRuntime,
177
219
  mobileDiagnostics,
178
220
  mobileObservation,
221
+ mobileRecorderCheck,
179
222
  mobileRecording,
180
223
  mobileRun
181
224
  };
@@ -109,6 +109,85 @@ function isRetryableMobileWaitError(error) {
109
109
  isRecord(error) && typeof error.code === "string" && RETRYABLE_MOBILE_WAIT_CODES.has(error.code)
110
110
  );
111
111
  }
112
+ function hiddenRouteExpression(testId) {
113
+ return `(function(){
114
+ const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
115
+ if (!hook || !hook.getFiberRoots) return null;
116
+ const hiddenAt = (props, routeKey) => {
117
+ const state = props.state;
118
+ const index = state.routes.findIndex((route) => route && route.key === routeKey);
119
+ if (index < 0 || typeof state.index !== 'number') return false;
120
+ if (state.type === 'tab') return index !== state.index;
121
+ if (state.type !== 'stack') return false;
122
+ // Mirror native-stack's getModalRouteKeys: an unset presentation after
123
+ // any modal-family screen is a modal too. On iOS a card pushed after a
124
+ // modal goes under it, so nothing hides a modal target.
125
+ const modal = [];
126
+ for (let position = 0; position <= state.index; position += 1) {
127
+ const descriptor = props.descriptors[state.routes[position].key];
128
+ if (!descriptor || !descriptor.options) return false;
129
+ const presentation = descriptor.options.presentation;
130
+ modal.push(presentation === undefined || presentation === null
131
+ ? modal.includes(true)
132
+ : !['card', 'push'].includes(presentation));
133
+ }
134
+ if (modal[index]) return false;
135
+ for (let above = index + 1; above <= state.index; above += 1) {
136
+ if (!modal[above]) return true;
137
+ }
138
+ return false;
139
+ };
140
+ const onHiddenRoute = (fiber) => {
141
+ let routeKey;
142
+ for (let owner = fiber.return; owner; owner = owner.return) {
143
+ const props = owner.memoizedProps;
144
+ if (!props) continue;
145
+ if (props.route && typeof props.route.key === 'string') routeKey = props.route.key;
146
+ if (routeKey && props.descriptors && props.state && Array.isArray(props.state.routes)
147
+ && hiddenAt(props, routeKey)) return true;
148
+ }
149
+ return false;
150
+ };
151
+ let matches = 0;
152
+ let hidden = 0;
153
+ const visit = (fiber) => {
154
+ for (; fiber; fiber = fiber.sibling) {
155
+ if (fiber.memoizedProps && fiber.memoizedProps.testID === ${JSON.stringify(testId)}) {
156
+ matches += 1;
157
+ try {
158
+ if (onHiddenRoute(fiber)) hidden += 1;
159
+ } catch (error) {
160
+ // An unreadable navigator counts as visible: never a false absent.
161
+ }
162
+ }
163
+ visit(fiber.child);
164
+ }
165
+ };
166
+ const roots = new Set();
167
+ for (const id of hook.renderers.keys()) (hook.getFiberRoots(id) || []).forEach((root) => roots.add(root));
168
+ roots.forEach((root) => visit(root.current));
169
+ return { matches, hidden };
170
+ })()`;
171
+ }
172
+ const NATIVE_READ_BUDGET_MS = 2500;
173
+ async function hiddenRouteOnly(input, probeInput, payload, testId, deadline) {
174
+ const routes = await evalSync(probeInput, hiddenRouteExpression(testId));
175
+ if (!isRecord(routes) || Number(routes.matches) < 1 || routes.hidden !== routes.matches) return null;
176
+ const remainingMs = deadline - Date.now();
177
+ if (remainingMs < NATIVE_READ_BUDGET_MS) return null;
178
+ let timer;
179
+ const observed = await Promise.race([
180
+ observeNativeUi({ refs: ["ui.visible"], node: payload }, input.context),
181
+ new Promise((resolve) => {
182
+ timer = setTimeout(() => resolve(null), remainingMs);
183
+ })
184
+ ]).finally(() => clearTimeout(timer));
185
+ if (!isRecord(observed) || Array.isArray(observed.warnings) && observed.warnings.length > 0) return null;
186
+ const visible = isRecord(observed.observations) ? observed.observations["ui.visible"] : void 0;
187
+ if (!isRecord(visible) || !Array.isArray(visible.items) || visible.truncated === true) return null;
188
+ const nativeVisible = visible.items.some((item) => isRecord(item) && item.test_id === testId && item.visible !== false);
189
+ return nativeVisible ? null : { hiddenRoute: true, nativeVisible: false };
190
+ }
112
191
  async function waitForMobileTarget(input, payload) {
113
192
  const timeoutMs = Number(payload.timeout_ms ?? payload.timeoutMs ?? 1e4);
114
193
  const deadline = Date.now() + timeoutMs;
@@ -162,8 +241,22 @@ async function waitForMobileTarget(input, payload) {
162
241
  );
163
242
  }
164
243
  lastResult = result;
165
- const present = result.present === true;
166
- const visible = result.visible === true;
244
+ let present = result.present === true;
245
+ let visible = result.visible === true;
246
+ let cachedRoute = null;
247
+ if (expectsAbsent && present && testId && visibility === "tree") {
248
+ try {
249
+ cachedRoute = await hiddenRouteOnly(input, probeInput, payload, testId, deadline);
250
+ } catch (error) {
251
+ if (!isRetryableMobileWaitError(error)) throw error;
252
+ lastBridgeError = error;
253
+ }
254
+ if (cachedRoute) {
255
+ present = false;
256
+ visible = false;
257
+ lastResult = { ...result, ...cachedRoute };
258
+ }
259
+ }
167
260
  let textMatched = !expectedText || textMatch === "contains";
168
261
  if (expectedText && textMatch === "exact" && textExpression) {
169
262
  try {
@@ -186,6 +279,7 @@ async function waitForMobileTarget(input, payload) {
186
279
  present,
187
280
  visible,
188
281
  visibility,
282
+ ...cachedRoute ?? {},
189
283
  ...isRecord(result.rect) ? { rect: result.rect } : {}
190
284
  };
191
285
  }