@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,192 @@
1
+ import fs from "node:fs";
2
+ import { applyDeviceTargeting } from "../../commands/device-target.js";
3
+ import { launchMobile } from "../../commands/launch/mobile.js";
4
+ import { shellQuote } from "../../commands/launch/quote.js";
5
+ import { EXIT, str, writeInteractiveProgress } from "../../commands/shared.js";
6
+ import { RECOVERY_CODE } from "../../heal-bounds.js";
7
+ import { recipeRuntimePath } from "../../paths.js";
8
+ function mobileProvisioningBlocked(output) {
9
+ return /open-device: configured iOS simulator '.+' does not exist|open-device: no MetaMask bundle found|fast mode requires an installed (?:iOS dev client|Android dev client)/u.test(output);
10
+ }
11
+ function missingConfiguredIosSimulator(output) {
12
+ return /open-device: configured iOS simulator '([^']+)' does not exist/u.exec(output)?.[1];
13
+ }
14
+ function ambiguousConfiguredIosSimulator(output) {
15
+ return /open-device: iOS simulator target ("[^"]+") is ambiguous/u.exec(output)?.[1];
16
+ }
17
+ function mobileBridgeTargetMissing(output) {
18
+ return /no bridge target matched|wait-for-bridge: readiness deadline expired/iu.test(output);
19
+ }
20
+ function mobileProvisionCommand(target, mobileTarget) {
21
+ const platform = mobileTarget === "android" ? "android" : "ios";
22
+ const device = platform === "ios" ? process.env.SIM_UDID || process.env.IOS_SIMULATOR : process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || process.env.ANDROID_DEVICE;
23
+ const parts = ["mm-harness", "provision", "runway", platform, "--adapter", "mobile", "--target", shellQuote(target)];
24
+ if (device) parts.push("--device", shellQuote(device));
25
+ return parts.join(" ");
26
+ }
27
+ function isIosSimulatorUdid(value) {
28
+ return /^[0-9A-Fa-f-]{36}$/u.test(value);
29
+ }
30
+ function launchDeviceLabel(mobileTarget) {
31
+ if (mobileTarget === "android") {
32
+ return process.env.ANDROID_TARGET_DEVICE_NAME || process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || process.env.ANDROID_DEVICE || "android device";
33
+ }
34
+ if (mobileTarget === "ios") {
35
+ return process.env.IOS_SIMULATOR || process.env.SIM_UDID || "iOS simulator";
36
+ }
37
+ return process.env.IOS_SIMULATOR || process.env.ADB_SERIAL || "booted device";
38
+ }
39
+ function metroLaunchEvidence(target) {
40
+ const evidencePath = recipeRuntimePath(target, "metro-generation.json");
41
+ let descriptor;
42
+ try {
43
+ descriptor = fs.openSync(evidencePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
44
+ const stat = fs.fstatSync(descriptor);
45
+ if (!stat.isFile() || stat.size > 256 * 1024) return void 0;
46
+ return JSON.parse(fs.readFileSync(descriptor, "utf8"));
47
+ } catch {
48
+ return void 0;
49
+ } finally {
50
+ if (descriptor !== void 0) fs.closeSync(descriptor);
51
+ }
52
+ }
53
+ const mobileLaunchProfile = {
54
+ refuseFlags({ mobileTarget }) {
55
+ if (!mobileTarget) {
56
+ return { message: "target is required for mobile.", userAction: "mm-harness launch ios or mm-harness launch android" };
57
+ }
58
+ return void 0;
59
+ },
60
+ applyEnv({ adapter, options, mobileTarget, target }) {
61
+ const device = str(options, "device");
62
+ if (!device && mobileTarget !== "ios" && mobileTarget !== "android") return void 0;
63
+ const targetingOptions = {
64
+ ...options,
65
+ ...mobileTarget ? { platform: mobileTarget } : {}
66
+ };
67
+ const result = applyDeviceTargeting("launch", adapter, targetingOptions, { gate: false, rerun: "" });
68
+ if ("code" in result) {
69
+ if (mobileTarget === "ios" && result.code === "DEVICE_NOT_FOUND") {
70
+ process.env.IOS_SIMULATOR = device;
71
+ process.env.SIM_UDID = isIosSimulatorUdid(device) ? device : "";
72
+ process.env.ADB_SERIAL = "";
73
+ process.env.ANDROID_SERIAL = "";
74
+ process.env.ANDROID_DEVICE = "";
75
+ process.env.ANDROID_TARGET_DEVICE_NAME = "";
76
+ } else {
77
+ return result;
78
+ }
79
+ }
80
+ if (mobileTarget === "android" && device && !process.env.ADB_SERIAL && !process.env.ANDROID_SERIAL) {
81
+ return {
82
+ ok: false,
83
+ code: "DEVICE_WRONG_PLATFORM",
84
+ message: `--device ${device} did not resolve to an Android device for launch android.`,
85
+ userAction: `mm-harness status --target ${shellQuote(target)} --all-devices --json`
86
+ };
87
+ }
88
+ if (mobileTarget === "ios" && device && !process.env.IOS_SIMULATOR) {
89
+ return {
90
+ ok: false,
91
+ code: "DEVICE_WRONG_PLATFORM",
92
+ message: `--device ${device} did not resolve to an iOS simulator for launch ios.`,
93
+ userAction: `mm-harness status --target ${shellQuote(target)} --all-devices --json`
94
+ };
95
+ }
96
+ return void 0;
97
+ },
98
+ start({ options, mobileTarget, machine, tier, wantClearMetro }) {
99
+ const device = str(options, "device") ?? (mobileTarget === "android" ? process.env.ADB_SERIAL ?? process.env.ANDROID_SERIAL : process.env.IOS_SIMULATOR ?? process.env.SIM_UDID) ?? "configured device";
100
+ const watcherPort = str(options, "watcherPort") ?? process.env.WATCHER_PORT ?? "auto";
101
+ writeInteractiveProgress(
102
+ machine,
103
+ `\u2192 mobile launch \u2014 ${mobileTarget} \xB7 ${tier === "build" ? "native build" : "quick readiness"} \xB7 ${device} \xB7 Metro ${watcherPort === "auto" ? "auto" : `:${watcherPort}`}${wantClearMetro ? " \xB7 clear cache" : ""}`
104
+ );
105
+ },
106
+ nativeInputs: ["ios", "android", "package.json", "yarn.lock"],
107
+ rebuildCommand: ({ mobileTarget }) => `mm-harness launch ${mobileTarget} --build`,
108
+ compose: ({ target, mobileTarget, machine, remoteFlagRequest }, tier, restartApp, clearMetro) => launchMobile(target, mobileTarget, tier, machine, restartApp, clearMetro, remoteFlagRequest?.flags),
109
+ classify(_launch, output) {
110
+ if (output.includes("open-device: Android device is locked.")) {
111
+ return {
112
+ code: "MOBILE_DEVICE_LOCKED",
113
+ message: "the selected Android device is locked.",
114
+ recoverable: false,
115
+ userAction: "Unlock the selected Android device, then retry the launch.",
116
+ exitCode: EXIT.runtime,
117
+ originalError: output.trim()
118
+ };
119
+ }
120
+ if (/open-device: Android (?:lock state unavailable|display preparation failed)\./u.test(output)) {
121
+ return {
122
+ code: "MOBILE_DEVICE_UI_UNAVAILABLE",
123
+ message: "the selected Android display could not be prepared.",
124
+ recoverable: false,
125
+ userAction: "Check adb access and the selected device screen, then retry the launch.",
126
+ exitCode: EXIT.runtime,
127
+ originalError: output.trim()
128
+ };
129
+ }
130
+ return void 0;
131
+ },
132
+ async beforeHeal(launch, attempt, state) {
133
+ const { target, mobileTarget, tier } = launch;
134
+ const output = attempt.output;
135
+ const missingIosSimulator = missingConfiguredIosSimulator(output);
136
+ const ambiguousIosSimulator = ambiguousConfiguredIosSimulator(output);
137
+ if (ambiguousIosSimulator) {
138
+ return launch.fail(state, {
139
+ code: "MOBILE_DEVICE_AMBIGUOUS",
140
+ message: `more than one iOS simulator matches ${ambiguousIosSimulator}.`,
141
+ recoverable: false,
142
+ userAction: `mm-harness status --adapter mobile --target ${shellQuote(target)} --all-devices --json`,
143
+ exitCode: EXIT.runtime,
144
+ originalError: output.trim() || void 0
145
+ });
146
+ }
147
+ if (missingIosSimulator && isIosSimulatorUdid(missingIosSimulator)) {
148
+ return launch.fail(state, {
149
+ code: "MOBILE_DEVICE_NOT_FOUND",
150
+ message: `the configured iOS simulator ${missingIosSimulator} no longer exists.`,
151
+ recoverable: false,
152
+ userAction: `mm-harness status --adapter mobile --target ${shellQuote(target)} --all-devices --json`,
153
+ exitCode: EXIT.runtime,
154
+ originalError: output.trim() || void 0
155
+ });
156
+ }
157
+ if (mobileProvisioningBlocked(output)) {
158
+ return launch.fail(state, {
159
+ code: "MOBILE_PROVISION_REQUIRED",
160
+ message: `mobile ${mobileTarget ?? "runtime"} is not provisioned for this slot.`,
161
+ recoverable: false,
162
+ userAction: mobileProvisionCommand(target, mobileTarget),
163
+ exitCode: EXIT.runtime,
164
+ originalError: output.trim() || void 0
165
+ });
166
+ }
167
+ if (tier === "build" && /open-device: (?:Android|iOS) native build failed/u.test(output)) {
168
+ return launch.fail(state, {
169
+ code: "MOBILE_NATIVE_BUILD_FAILED",
170
+ message: "the selected Mobile native build failed; refusing to repeat the expensive build as a Metro recovery.",
171
+ recoverable: false,
172
+ exitCode: EXIT.runtime,
173
+ originalError: output.trim() || void 0
174
+ });
175
+ }
176
+ return void 0;
177
+ },
178
+ recovery(_launch, output) {
179
+ const restartApp = mobileBridgeTargetMissing(output);
180
+ return { code: restartApp ? "mobile.app-restarted" : RECOVERY_CODE.mobile, restartApp };
181
+ },
182
+ // Mobile proved its set inside launchMobile (null: nothing requested and no
183
+ // runtime answered).
184
+ provenFlags: (_launch, requested, proven) => ({ flags: proven === void 0 ? requested : proven }),
185
+ verifyArgs: ({ mobileTarget }) => mobileTarget ? ["--platform", mobileTarget] : [],
186
+ platformLabel: ({ mobileTarget }) => mobileTarget ?? null,
187
+ deviceLabel: ({ mobileTarget }) => launchDeviceLabel(mobileTarget),
188
+ evidence: metroLaunchEvidence
189
+ };
190
+ export {
191
+ mobileLaunchProfile
192
+ };
@@ -0,0 +1,300 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import {
3
+ bridgeCommand,
4
+ MOBILE_BRIDGE_ERROR_CODES
5
+ } from "../../../library/actions/mobile/platform/bridge.mjs";
6
+ import { resolveMobileToolPath } from "../../../library/actions/mobile/platform/tool-paths.mjs";
7
+ import {
8
+ MOBILE_LIFECYCLE_DEADLINE_FIELD,
9
+ MOBILE_LIFECYCLE_PREPARED
10
+ } from "../../app-lifecycle.js";
11
+ import {
12
+ mobileSourceFingerprint,
13
+ recordMobileSourceBaseline
14
+ } from "./source-freshness.js";
15
+ function mobileSourceAwareLifecycleAdapters(lifecycle, readinessProbe = probeMobileLifecycleRuntime, sourceFreshness = {
16
+ fingerprint: mobileSourceFingerprint,
17
+ record: recordMobileSourceBaseline
18
+ }, processIdentity = {
19
+ readIosPid: readIosAppPid,
20
+ readAndroidPid: readAndroidAppPid
21
+ }, runtimeMarker = readMobileLifecycleRuntimeIdentity) {
22
+ return lifecycle.map((entry) => ({
23
+ ...entry,
24
+ async execute(node, context) {
25
+ const command = node.command ?? node.event ?? node.state;
26
+ const opaqueRuntime = context.env?.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1";
27
+ const requiresProcessContinuity = command !== "restart" && node.require_process_continuity === true;
28
+ if (opaqueRuntime && !requiresProcessContinuity) {
29
+ return entry.execute(node, context);
30
+ }
31
+ const reloadsSource = !opaqueRuntime && (command === "launch" || command === "foreground" || command === "restart");
32
+ const recordsSource = !opaqueRuntime && command === "restart";
33
+ const androidLifecycle = isAndroidLifecycle(node, context.env ?? {});
34
+ const checksIosContinuity = command === "foreground" && !androidLifecycle;
35
+ const checksRequiredContinuity = requiresProcessContinuity;
36
+ const recordsProcessIdentity = checksIosContinuity || checksRequiredContinuity;
37
+ const readProcessId = androidLifecycle ? processIdentity.readAndroidPid ?? readAndroidAppPid : processIdentity.readIosPid;
38
+ const processBefore = recordsProcessIdentity ? readProcessId(node, context) : void 0;
39
+ const fingerprint = recordsSource ? sourceFreshness.fingerprint(context.projectRoot) : void 0;
40
+ const previousRuntimeIdentity = recordsSource ? await runtimeMarker(node, context) : void 0;
41
+ const androidRestart = command === "restart" && androidLifecycle;
42
+ const initialResult = await entry.execute(node, context);
43
+ const readinessDeadline = reloadsSource ? mobileLifecycleReadinessDeadline(command, node) : void 0;
44
+ let result = initialResult;
45
+ let runtimeReady = false;
46
+ if (androidRestart) {
47
+ try {
48
+ const remainingMs = Math.max(
49
+ 1,
50
+ (readinessDeadline ?? Date.now()) - Date.now()
51
+ );
52
+ const handoffDeadline = Math.min(
53
+ readinessDeadline ?? Number.POSITIVE_INFINITY,
54
+ Date.now() + Math.min(1e4, Math.max(1, remainingMs / 2))
55
+ );
56
+ await readinessProbe(
57
+ node,
58
+ context,
59
+ previousRuntimeIdentity,
60
+ handoffDeadline
61
+ );
62
+ runtimeReady = true;
63
+ } catch (error) {
64
+ if (readinessDeadline !== void 0 && Date.now() >= readinessDeadline) {
65
+ throw error;
66
+ }
67
+ const foregroundTimeoutMs = Math.max(
68
+ 1,
69
+ (readinessDeadline ?? Date.now() + 1) - Date.now()
70
+ );
71
+ const foregroundResult = await entry.execute(
72
+ {
73
+ ...node,
74
+ command: "foreground",
75
+ settle_ms: 0,
76
+ timeout_ms: foregroundTimeoutMs,
77
+ [MOBILE_LIFECYCLE_DEADLINE_FIELD]: readinessDeadline,
78
+ [MOBILE_LIFECYCLE_PREPARED]: true
79
+ },
80
+ context
81
+ );
82
+ result = mergeAndroidRestartResults(initialResult, foregroundResult);
83
+ }
84
+ }
85
+ if (reloadsSource && !runtimeReady) {
86
+ if (readinessDeadline !== void 0 && Date.now() >= readinessDeadline) {
87
+ throw new Error(
88
+ `Mobile lifecycle ${String(command)} exhausted its runtime readiness deadline before recovery completed.`
89
+ );
90
+ }
91
+ await readinessProbe(
92
+ node,
93
+ context,
94
+ previousRuntimeIdentity,
95
+ readinessDeadline
96
+ );
97
+ }
98
+ if (recordsProcessIdentity) {
99
+ const processAfter = readProcessId(node, context);
100
+ const processPreserved = processBefore !== null && processBefore === processAfter;
101
+ const actualTransition = processPreserved ? command === "background" ? "background_same_process" : "foreground_resume" : processAfter !== null ? "cold_relaunch" : "unknown";
102
+ const processContinuity = processPreserved ? "preserved" : processAfter === null ? "unknown" : "changed";
103
+ const resultRecord = asRecord(result);
104
+ result = {
105
+ ...resultRecord,
106
+ output: {
107
+ ...asRecord(resultRecord.output),
108
+ actualTransition,
109
+ processContinuity,
110
+ processBefore,
111
+ processAfter
112
+ }
113
+ };
114
+ if (checksRequiredContinuity && !processPreserved) {
115
+ const platform = androidLifecycle ? "Android" : "iOS";
116
+ throw new Error(
117
+ `${platform} ${String(command)} did not prove app-process continuity (actualTransition=${actualTransition}, before=${String(processBefore)}, after=${String(processAfter)}); this lifecycle sample cannot be classified as a same-process reconnect.`
118
+ );
119
+ }
120
+ }
121
+ if (fingerprint !== void 0 && !sourceFreshness.record(context.projectRoot, fingerprint)) {
122
+ throw new Error(
123
+ "Mobile source changed while the app was reloading; the loaded-source baseline was not recorded. Next: retry app.lifecycle restart."
124
+ );
125
+ }
126
+ return result;
127
+ }
128
+ }));
129
+ }
130
+ function readAndroidAppPid(node, context) {
131
+ const env = context.env ?? {};
132
+ const device = node.adb_serial ?? node.android_device ?? node.device ?? env.ADB_SERIAL ?? env.ANDROID_SERIAL ?? process.env.ADB_SERIAL ?? process.env.ANDROID_SERIAL;
133
+ const packageId = node.package_id ?? node.packageName ?? node.app_id ?? env.ANDROID_PACKAGE_ID ?? process.env.ANDROID_PACKAGE_ID ?? "io.metamask";
134
+ if (typeof device !== "string" || typeof packageId !== "string") return null;
135
+ try {
136
+ const adb = resolveMobileToolPath("adb", { required: true });
137
+ const output = execFileSync(
138
+ adb,
139
+ ["-s", device, "shell", "pidof", packageId],
140
+ { encoding: "utf8", timeout: 5e3, stdio: ["ignore", "pipe", "ignore"] }
141
+ );
142
+ const pid = Number.parseInt(output.trim().split(/\s+/u)[0] ?? "", 10);
143
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+ function readIosAppPid(node, context) {
149
+ const env = context.env ?? {};
150
+ const device = node.simulator ?? node.ios_simulator ?? env.SIM_UDID ?? env.IOS_SIMULATOR ?? process.env.SIM_UDID ?? process.env.IOS_SIMULATOR;
151
+ const bundleId = node.bundle_id ?? node.bundleId ?? env.IOS_BUNDLE_ID ?? process.env.IOS_BUNDLE_ID ?? "io.metamask.MetaMask";
152
+ if (typeof device !== "string" || typeof bundleId !== "string") return null;
153
+ try {
154
+ const output = execFileSync(
155
+ "xcrun",
156
+ ["simctl", "spawn", device, "launchctl", "list"],
157
+ { encoding: "utf8", timeout: 5e3 }
158
+ );
159
+ const labelPrefix = `UIKitApplication:${bundleId}[`;
160
+ const line = output.split("\n").find((candidate) => candidate.includes(labelPrefix));
161
+ if (!line) return null;
162
+ const pid = Number.parseInt(line.trim().split(/\s+/u)[0] ?? "", 10);
163
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
164
+ } catch {
165
+ return null;
166
+ }
167
+ }
168
+ function isAndroidLifecycle(node, env) {
169
+ const platform = node.platform ?? env.PLATFORM ?? process.env.PLATFORM;
170
+ if (platform === "android") return true;
171
+ if (platform === "ios") return false;
172
+ return Boolean(
173
+ node.adb_serial ?? node.android_device ?? env.ADB_SERIAL ?? env.ANDROID_SERIAL ?? process.env.ADB_SERIAL ?? process.env.ANDROID_SERIAL
174
+ );
175
+ }
176
+ function mergeAndroidRestartResults(initialResult, foregroundResult) {
177
+ const initial = asRecord(initialResult);
178
+ const foreground = asRecord(foregroundResult);
179
+ const initialOutput = asRecord(initial.output);
180
+ const foregroundOutput = asRecord(foreground.output);
181
+ return {
182
+ ...initial,
183
+ output: {
184
+ ...initialOutput,
185
+ command: "restart",
186
+ calls: [
187
+ ...Array.isArray(initialOutput.calls) ? initialOutput.calls : [],
188
+ ...Array.isArray(foregroundOutput.calls) ? foregroundOutput.calls : []
189
+ ],
190
+ settleMs: foregroundOutput.settleMs ?? initialOutput.settleMs ?? 0,
191
+ foregroundEnsured: true
192
+ }
193
+ };
194
+ }
195
+ async function probeMobileLifecycleRuntime(node, context, previousRuntimeIdentity, deadlineMs) {
196
+ const command = node.command ?? node.event ?? node.state;
197
+ const timeoutMs = resolveMobileLifecycleReadinessTimeout(
198
+ command,
199
+ node.runtime_ready_timeout_ms
200
+ );
201
+ const startedAt = Date.now();
202
+ const deadline = deadlineMs ?? startedAt + timeoutMs;
203
+ let lastError;
204
+ while (Date.now() < deadline) {
205
+ try {
206
+ const status = await bridgeCommand(
207
+ {
208
+ action: "app.lifecycle",
209
+ node: {
210
+ ...node,
211
+ cdp_timeout_ms: Math.min(8e3, Math.max(1, deadline - Date.now()))
212
+ },
213
+ context
214
+ },
215
+ ["status-selected"]
216
+ );
217
+ if (status?.agenticPresent === true && status.route && (!previousRuntimeIdentity || status.runtimeIdentity !== previousRuntimeIdentity)) {
218
+ return status;
219
+ }
220
+ lastError = new Error(
221
+ previousRuntimeIdentity && status?.runtimeIdentity === previousRuntimeIdentity ? "retiring Mobile runtime is still selected" : "selected Mobile runtime is not ready"
222
+ );
223
+ } catch (error) {
224
+ lastError = error;
225
+ }
226
+ const remainingMs = deadline - Date.now();
227
+ if (remainingMs > 0) {
228
+ await new Promise(
229
+ (resolve) => setTimeout(resolve, Math.min(1e3, remainingMs))
230
+ );
231
+ }
232
+ }
233
+ throw new Error(
234
+ `Mobile lifecycle ${String(command)} did not expose a usable pinned __AGENTIC__ runtime within ${Math.max(0, deadline - startedAt)}ms: ${lastError instanceof Error ? lastError.message : String(lastError)}`
235
+ );
236
+ }
237
+ async function readMobileLifecycleRuntimeIdentity(node, context) {
238
+ const deadline = Date.now() + 5e3;
239
+ let lastError;
240
+ do {
241
+ try {
242
+ const status = await bridgeCommand(
243
+ {
244
+ action: "app.lifecycle",
245
+ node: {
246
+ ...node,
247
+ cdp_timeout_ms: Math.min(1e3, Math.max(1, deadline - Date.now()))
248
+ },
249
+ context
250
+ },
251
+ ["target-identity-selected"]
252
+ );
253
+ if (typeof status?.runtimeIdentity === "string") {
254
+ return status.runtimeIdentity;
255
+ }
256
+ lastError = new Error("selected Mobile runtime has no stable identity");
257
+ } catch (error) {
258
+ if (error instanceof Error && "code" in error && error.code === MOBILE_BRIDGE_ERROR_CODES.NO_TARGET) {
259
+ return void 0;
260
+ }
261
+ lastError = error;
262
+ }
263
+ const remainingMs = deadline - Date.now();
264
+ if (remainingMs <= 0) {
265
+ break;
266
+ }
267
+ await new Promise(
268
+ (resolve) => setTimeout(resolve, Math.min(250, remainingMs))
269
+ );
270
+ } while (Date.now() < deadline);
271
+ throw new Error(
272
+ `Mobile lifecycle restart could not identify the retiring runtime: ${lastError instanceof Error ? lastError.message : String(lastError)}`
273
+ );
274
+ }
275
+ function mobileLifecycleReadinessDeadline(command, node) {
276
+ const timeoutMs = resolveMobileLifecycleReadinessTimeout(
277
+ command,
278
+ node.runtime_ready_timeout_ms
279
+ );
280
+ return Date.now() + timeoutMs;
281
+ }
282
+ function resolveMobileLifecycleReadinessTimeout(command, value) {
283
+ if (value === void 0) {
284
+ return command === "launch" || command === "restart" || command === "foreground" ? 6e4 : 3e4;
285
+ }
286
+ const timeout = Number(value);
287
+ if (!Number.isInteger(timeout) || timeout < 1e3 || timeout > 12e4) {
288
+ throw new Error(
289
+ "app.lifecycle.runtime_ready_timeout_ms must be an integer from 1000 through 120000."
290
+ );
291
+ }
292
+ return timeout;
293
+ }
294
+ function asRecord(value) {
295
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
296
+ }
297
+ export {
298
+ mobileSourceAwareLifecycleAdapters,
299
+ resolveMobileLifecycleReadinessTimeout
300
+ };
@@ -0,0 +1,156 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import path from "node:path";
3
+ import { isRecord } from "../../commands/parse-args.js";
4
+ import { runnerDir } from "../../paths.js";
5
+ import { devicePlatformFromEnv } from "../performance/device-platform.js";
6
+ import {
7
+ IOS_SIMULATOR_RECORDING_TARGET_PREFIX,
8
+ resolveAndroidDeviceSerial,
9
+ resolveMobileIosSimulatorName
10
+ } from "../../recording-target.js";
11
+ import { requireCurrentMobileReleaseArtifactState } from "./release-artifact-state.js";
12
+ const EXPLICIT_DEVICE_KEYS = [
13
+ "PLATFORM",
14
+ "MM_HARNESS_EXPLICIT_PLATFORM",
15
+ "IOS_SIMULATOR",
16
+ "SIM_UDID",
17
+ "ADB_SERIAL",
18
+ "ANDROID_SERIAL",
19
+ "ANDROID_DEVICE",
20
+ "ANDROID_TARGET_DEVICE_NAME"
21
+ ];
22
+ const mobileRun = {
23
+ pinnedEnv() {
24
+ if (!process.env.MM_HARNESS_EXPLICIT_PLATFORM) return void 0;
25
+ return Object.fromEntries(EXPLICIT_DEVICE_KEYS.map((key) => [key, process.env[key]]));
26
+ },
27
+ envKeys: ["METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME", "ANDROID_PACKAGE_ID", "ADB_SERIAL", "ANDROID_SERIAL"],
28
+ activateEnv(projectRoot) {
29
+ const state = requireCurrentMobileReleaseArtifactState(projectRoot);
30
+ if (!state) return;
31
+ const selected = process.env.ADB_SERIAL ?? process.env.ANDROID_SERIAL;
32
+ if (selected && selected !== state.deviceSerial) {
33
+ throw new Error(
34
+ `The Mobile release artifact is installed on ${state.deviceSerial}, not ${selected}. Next: rerun runtime-launch for ${selected}.`
35
+ );
36
+ }
37
+ process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME = "1";
38
+ process.env.ANDROID_PACKAGE_ID = state.packageId;
39
+ process.env.ADB_SERIAL = state.deviceSerial;
40
+ process.env.ANDROID_SERIAL = state.deviceSerial;
41
+ },
42
+ childEnv: (base) => ({
43
+ ...base,
44
+ WATCHER_PORT: process.env.WATCHER_PORT ?? base.CDP_PORT ?? base.RECIPE_CDP_PORT,
45
+ METRO_PORT: process.env.METRO_PORT ?? process.env.WATCHER_PORT ?? base.CDP_PORT ?? base.RECIPE_CDP_PORT,
46
+ IOS_SIMULATOR: process.env.IOS_SIMULATOR,
47
+ SIM_UDID: process.env.SIM_UDID,
48
+ ANDROID_DEVICE: process.env.ANDROID_DEVICE,
49
+ ANDROID_TARGET_DEVICE_NAME: process.env.ANDROID_TARGET_DEVICE_NAME,
50
+ ADB_SERIAL: process.env.ADB_SERIAL,
51
+ ANDROID_SERIAL: process.env.ANDROID_SERIAL,
52
+ ANDROID_PACKAGE_ID: process.env.ANDROID_PACKAGE_ID,
53
+ METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME: process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME
54
+ }),
55
+ // An installed release artifact is opaque: no harness HUD inside it.
56
+ autoHud: () => process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME !== "1",
57
+ // Always clear a HUD step the engine left on-device (a failed run strands
58
+ // the FAIL banner). Best-effort — never masks the run's real outcome.
59
+ async teardown(projectRoot, env) {
60
+ const { hideMobileHudOnTeardown } = await import("./ui.js");
61
+ await hideMobileHudOnTeardown(projectRoot, env);
62
+ },
63
+ // A release artifact is checked before the overlay install; the loaded
64
+ // source only matters for a development build the caller did not restart.
65
+ runtimeCheck({ target, heal, state, json, appRestartAuthored }) {
66
+ if (requireCurrentMobileReleaseArtifactState(target) !== null || appRestartAuthored) return void 0;
67
+ return async () => {
68
+ const { ensureMobileProofRuntime } = await import("../../commands/run-engine.js");
69
+ return ensureMobileProofRuntime(target, heal, state, json);
70
+ };
71
+ }
72
+ };
73
+ const mobileRecording = {
74
+ target: (context) => resolveMobileRecordingTarget(context),
75
+ // An Android device records through the mirror, with screenrecord as the
76
+ // fallback; otherwise the selected iOS simulator records.
77
+ async videoRecorder() {
78
+ const androidSerial = resolveAndroidDeviceSerial();
79
+ const iosSimulator = androidSerial ? void 0 : resolveMobileIosSimulatorName();
80
+ if (!androidSerial && !iosSimulator) return void 0;
81
+ const { createAndroidVideoRecorder, createIosSimulatorVideoRecorder } = await import("./video-recorder.js");
82
+ if (androidSerial) {
83
+ const { createAndroidMirrorVideoRecorder } = await import("@farmslot/recipe-harness");
84
+ return createAndroidMirrorVideoRecorder({ serial: androidSerial, fallback: createAndroidVideoRecorder(androidSerial) });
85
+ }
86
+ return createIosSimulatorVideoRecorder(iosSimulator);
87
+ }
88
+ };
89
+ async function resolveMobileRecordingTarget(context) {
90
+ const platform = (context.env.PLATFORM ?? process.env.PLATFORM ?? "ios").trim().toLowerCase();
91
+ const androidSerial = resolveAndroidDeviceSerial(context.env);
92
+ if (platform === "android" || androidSerial) {
93
+ if (!androidSerial) {
94
+ throw new Error(
95
+ "Could not resolve an Android device for --record-video. Set ADB_SERIAL or pass --device to mm-harness run."
96
+ );
97
+ }
98
+ return {
99
+ kind: "android-device",
100
+ serial: androidSerial
101
+ };
102
+ }
103
+ const simulator = resolveMobileIosSimulatorName(context.env);
104
+ if (!simulator) {
105
+ throw new Error(
106
+ "Could not resolve an iOS simulator name for --record-video. Set IOS_SIMULATOR or pass --simulator to the recipe hook."
107
+ );
108
+ }
109
+ return {
110
+ kind: "window-id",
111
+ windowId: `${IOS_SIMULATOR_RECORDING_TARGET_PREFIX}${simulator}`
112
+ };
113
+ }
114
+ const mobileDiagnostics = {
115
+ issueBuffer: {
116
+ arm(projectRoot) {
117
+ if (process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1") return false;
118
+ if (!runMobileIssueCommand(projectRoot, "issues-arm")) return false;
119
+ return runMobileIssueCommand(projectRoot, "issues-collect") !== null;
120
+ },
121
+ collect(projectRoot) {
122
+ const result = runMobileIssueCommand(projectRoot, "issues-collect");
123
+ return isRecord(result) && Array.isArray(result.entries) ? result.entries : null;
124
+ }
125
+ }
126
+ };
127
+ const mobileObservation = {
128
+ network: {
129
+ backend: async (target, env) => (await import("../../network-observation.js")).createMobileNetworkBackend(target, env)
130
+ },
131
+ performance: {
132
+ backend: async (target, env) => (await import("./performance-observer.js")).createMobilePerformanceBackend(target, env),
133
+ platform: (env) => devicePlatformFromEnv(env)
134
+ }
135
+ };
136
+ function runMobileIssueCommand(projectRoot, command) {
137
+ const bridge = path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
138
+ const result = spawnSync(process.execPath, [bridge, command], {
139
+ cwd: projectRoot,
140
+ env: { ...process.env, APP_ROOT: projectRoot, CDP_TIMEOUT: "5000" },
141
+ encoding: "utf8",
142
+ timeout: 1e4
143
+ });
144
+ if (result.status !== 0) return null;
145
+ try {
146
+ return JSON.parse(result.stdout.trim());
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+ export {
152
+ mobileDiagnostics,
153
+ mobileObservation,
154
+ mobileRecording,
155
+ mobileRun
156
+ };
@@ -0,0 +1,34 @@
1
+ import { readAndroidNativeRuntimeIdentity } from "./release-artifact.js";
2
+ import { readMobileReleaseArtifactState } from "./release-artifact-state.js";
3
+ function withMobileNativeRuntimeIdentity(adapters, identity = {
4
+ state: readMobileReleaseArtifactState,
5
+ read: readAndroidNativeRuntimeIdentity
6
+ }) {
7
+ return adapters.map((entry) => ({
8
+ ...entry,
9
+ async execute(node, context) {
10
+ const state = identity.state(context.projectRoot);
11
+ if (!state) {
12
+ if (context.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1") {
13
+ throw new Error("Mobile release-artifact actions require current trusted runtime state. Next: rerun runtime-launch with the exact artifact.");
14
+ }
15
+ return entry.execute(node, context);
16
+ }
17
+ identity.read(state);
18
+ const result = await entry.execute(node, context);
19
+ const nativeRuntimeIdentity = identity.read(state);
20
+ const output = result.output && typeof result.output === "object" && !Array.isArray(result.output) ? result.output : { result: result.output };
21
+ return {
22
+ ...result,
23
+ output: {
24
+ ...output,
25
+ nativeRuntimeIdentity,
26
+ runtimeSessionId: nativeRuntimeIdentity.runtimeSessionId
27
+ }
28
+ };
29
+ }
30
+ }));
31
+ }
32
+ export {
33
+ withMobileNativeRuntimeIdentity
34
+ };