@deeeed/metamask-harness 0.74.0 → 0.76.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -1
- package/README.md +1 -1
- package/adapters/shared/ensure-runner-deps.sh +15 -3
- package/adapters/shared/reap-checkout-metros.sh +9 -0
- package/dist/action-values.js +15 -0
- package/dist/adapters/core/actions.js +20 -0
- package/dist/adapters/core/live-script-env.js +36 -0
- package/dist/adapters/core/overlay.js +34 -0
- package/dist/adapters/core/readiness.js +8 -0
- package/dist/adapters/core/run.js +54 -0
- package/dist/adapters/core/surface.js +12 -1
- package/dist/adapters/extension/action-inputs.js +206 -0
- package/dist/adapters/extension/actions.js +51 -0
- package/dist/adapters/extension/console-capture.js +13 -1
- package/dist/adapters/extension/doctor-checks.js +135 -0
- package/dist/adapters/extension/fixtures.js +103 -0
- package/dist/adapters/extension/launch.js +315 -0
- package/dist/adapters/extension/launched-browser.js +51 -0
- package/dist/adapters/extension/overlay.js +55 -0
- package/dist/adapters/extension/readiness.js +32 -0
- package/dist/adapters/extension/reload.js +40 -0
- package/dist/adapters/extension/run.js +105 -0
- package/dist/adapters/extension/surface.js +31 -3
- package/dist/adapters/harness-freshness.js +4 -2
- package/dist/adapters/mobile/actions.js +54 -0
- package/dist/adapters/mobile/devices.js +21 -0
- package/dist/adapters/mobile/doctor-checks.js +40 -0
- package/dist/adapters/mobile/fixtures.js +141 -0
- package/dist/adapters/mobile/launch.js +192 -0
- package/dist/adapters/mobile/lifecycle.js +300 -0
- package/dist/adapters/mobile/overlay.js +27 -0
- package/dist/adapters/mobile/prepare-target.js +38 -0
- package/dist/adapters/mobile/readiness.js +39 -0
- package/dist/adapters/mobile/reload.js +15 -0
- package/dist/adapters/mobile/run.js +156 -0
- package/dist/adapters/mobile/runtime-identity.js +34 -0
- package/dist/adapters/mobile/surface.js +39 -3
- package/dist/adapters/mobile/ui.js +701 -0
- package/dist/adapters/performance/cdp-trace.js +24 -6
- package/dist/adapters/performance/device-platform.js +10 -0
- package/dist/adapters/registry.js +1 -1
- package/dist/adapters/terminal/actions.js +43 -0
- package/dist/adapters/terminal/console-capture.js +14 -1
- package/dist/adapters/terminal/doctor-checks.js +22 -0
- package/dist/adapters/terminal/overlay.js +18 -0
- package/dist/adapters/terminal/readiness.js +11 -0
- package/dist/adapters/terminal/run.js +30 -0
- package/dist/adapters/terminal/surface.js +21 -3
- package/dist/adapters.js +32 -824
- package/dist/cli-commands.js +1 -0
- package/dist/cli.js +3 -1
- package/dist/command-contract.js +3 -0
- package/dist/commands/call.js +2 -5
- package/dist/commands/debug.js +6 -25
- package/dist/commands/device-target.js +2 -1
- package/dist/commands/doctor.js +26 -137
- package/dist/commands/fixtures.js +13 -217
- package/dist/commands/launch/device.js +2 -6
- package/dist/commands/launch/extension.js +3 -303
- package/dist/commands/launch/index.js +1 -1
- package/dist/commands/launch/mobile.js +2 -190
- package/dist/commands/launch/quote.js +7 -0
- package/dist/commands/logs.js +1 -1
- package/dist/commands/parse-args.js +9 -4
- package/dist/commands/pin.js +226 -0
- package/dist/commands/prepare.js +23 -53
- package/dist/commands/provision.js +3 -7
- package/dist/commands/reload.js +8 -47
- package/dist/commands/run-engine.js +38 -138
- package/dist/commands/run-report.js +4 -48
- package/dist/commands/run.js +6 -48
- package/dist/commands/runtime-decision.js +5 -27
- package/dist/commands/self-test.js +1 -3
- package/dist/commands/status.js +20 -28
- package/dist/commands/stop.js +1 -1
- package/dist/doctor.js +12 -120
- package/dist/execution-provenance.js +3 -2
- package/dist/harness-pin.js +10 -0
- package/dist/harness.js +31 -100
- package/dist/heal-bounds.js +3 -2
- package/dist/live-adapter-contract.js +8 -34
- package/dist/manifest.js +3 -5
- package/dist/metamask-action-validation.js +5 -196
- package/dist/mm-harness-cli.js +30 -4
- package/dist/network-observation.js +10 -3
- package/dist/paths.js +8 -2
- package/dist/performance-observation.js +15 -22
- package/dist/pr-body/render.js +3 -1
- package/dist/recording-target.js +4 -39
- package/dist/run-diagnostics.js +21 -60
- package/dist/run-recording.js +5 -4
- package/dist/runner.js +20 -368
- package/dist/runtime-context.js +12 -17
- package/docs/CONTRIBUTING.md +29 -7
- package/package.json +2 -1
- 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,27 @@
|
|
|
1
|
+
import { mobilePlatformFromConfiguredTarget, mobilePlatformFromDevices, readRuntimeContextTarget } from "./prepare-target.js";
|
|
2
|
+
const mobileHarness = {
|
|
3
|
+
install: { entry: "adapters/mobile/inject.sh", fallback: "scripts/inject-mobile-harness.sh" },
|
|
4
|
+
cleanup: { entry: "adapters/mobile/cleanup.sh", fallback: "scripts/cleanup-mobile-harness.sh", fromInstalledRunner: true },
|
|
5
|
+
// Runner-owned typed code, not a shipped leaf: no installed overlay copy can shadow it.
|
|
6
|
+
verify: () => ({ inProcess: async () => (await import("./verify.js")).runMobileVerify }),
|
|
7
|
+
restart: "mm-harness launch ios # or launch android"
|
|
8
|
+
};
|
|
9
|
+
const mobileRuntimeContext = {
|
|
10
|
+
forbiddenFields: ["cdpPort", "extensionId"],
|
|
11
|
+
localPorts: { watcher: 4e4 },
|
|
12
|
+
resources: ({ watcherPort, existing }) => ({
|
|
13
|
+
metroPort: watcherPort,
|
|
14
|
+
simulator: process.env.IOS_SIMULATOR || existing.simulator,
|
|
15
|
+
simulatorUdid: process.env.SIM_UDID || existing.simulatorUdid,
|
|
16
|
+
adbSerial: process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || existing.adbSerial
|
|
17
|
+
})
|
|
18
|
+
};
|
|
19
|
+
const mobilePrepare = {
|
|
20
|
+
clearMetro: true,
|
|
21
|
+
devicePlatform: (target, devices) => mobilePlatformFromConfiguredTarget(process.env, readRuntimeContextTarget(target)) ?? mobilePlatformFromDevices(devices)
|
|
22
|
+
};
|
|
23
|
+
export {
|
|
24
|
+
mobileHarness,
|
|
25
|
+
mobilePrepare,
|
|
26
|
+
mobileRuntimeContext
|
|
27
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { recipeRuntimeDir } from "../../paths.js";
|
|
4
|
+
function readRuntimeContextTarget(target) {
|
|
5
|
+
const file = path.join(target, recipeRuntimeDir(), "agentic-runtime.json");
|
|
6
|
+
let parsed;
|
|
7
|
+
try {
|
|
8
|
+
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
9
|
+
} catch {
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
12
|
+
const record = parsed ?? {};
|
|
13
|
+
const str = (key) => typeof record[key] === "string" ? record[key] : void 0;
|
|
14
|
+
return { simulator: str("simulator"), simulatorUdid: str("simulatorUdid"), adbSerial: str("adbSerial") };
|
|
15
|
+
}
|
|
16
|
+
function mobilePlatformFromConfiguredTarget(env, context) {
|
|
17
|
+
const ios = Boolean(env.IOS_SIMULATOR || env.SIM_UDID || context.simulator || context.simulatorUdid);
|
|
18
|
+
const android = Boolean(
|
|
19
|
+
env.ADB_SERIAL || env.ANDROID_SERIAL || env.ANDROID_TARGET_DEVICE_NAME || env.ANDROID_DEVICE || context.adbSerial
|
|
20
|
+
);
|
|
21
|
+
if (ios === android) return void 0;
|
|
22
|
+
return ios ? "ios" : "android";
|
|
23
|
+
}
|
|
24
|
+
function mobilePlatformFromDevices(devices) {
|
|
25
|
+
const selected = devices.filter((device) => device?.selected === true);
|
|
26
|
+
const candidates = selected.length > 0 ? selected : devices;
|
|
27
|
+
const platforms = [
|
|
28
|
+
...new Set(
|
|
29
|
+
candidates.map((device) => device?.platform).filter((value) => value === "ios" || value === "android")
|
|
30
|
+
)
|
|
31
|
+
];
|
|
32
|
+
return platforms.length === 1 ? platforms[0] : void 0;
|
|
33
|
+
}
|
|
34
|
+
export {
|
|
35
|
+
mobilePlatformFromConfiguredTarget,
|
|
36
|
+
mobilePlatformFromDevices,
|
|
37
|
+
readRuntimeContextTarget
|
|
38
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { optionFlag, optionString, parsePort } from "../../commands/parse-args.js";
|
|
2
|
+
import { detectOrphanMetros, mobileToolDoctorChecks } from "./doctor-checks.js";
|
|
3
|
+
import { ensureMobilePerpsEnvironment, mobilePerpsEnvironment } from "./perps-env.js";
|
|
4
|
+
const mobileReadiness = {
|
|
5
|
+
compatibilityMode(_target, shape) {
|
|
6
|
+
if (shape.injectedHarness && shape.agenticService) return "runner bridge with injected app bridge";
|
|
7
|
+
if (shape.agenticService) return "runner bridge with app bridge";
|
|
8
|
+
return "runner bridge available; app bridge not installed";
|
|
9
|
+
},
|
|
10
|
+
checks: (_target, platform) => mobileToolDoctorChecks(platform === "ios" || platform === "android" ? platform : void 0),
|
|
11
|
+
environment: (target) => ({ mobilePerps: mobilePerpsEnvironment(target) }),
|
|
12
|
+
lines(target) {
|
|
13
|
+
const perps = mobilePerpsEnvironment(target);
|
|
14
|
+
return [
|
|
15
|
+
`mobile-perps: ${perps.status} (${perps.file}; MM_PERPS_ENABLED=${perps.perpsEnabled.value ?? "missing"}; OVERRIDE_REMOTE_FEATURE_FLAGS=${perps.remoteFeatureFlagOverride.value ?? "missing"})`
|
|
16
|
+
];
|
|
17
|
+
},
|
|
18
|
+
orphanDevServers: detectOrphanMetros,
|
|
19
|
+
captureProviders: ["simctl", "adb"],
|
|
20
|
+
fixes: [{ id: "mobile-perps-env", apply: (target) => ensureMobilePerpsEnvironment(target).length > 0 }],
|
|
21
|
+
devices: async () => (await import("./devices.js")).mobileDevices
|
|
22
|
+
};
|
|
23
|
+
async function mobileRuntimeDecision(target, options) {
|
|
24
|
+
const watcherPortRaw = optionString(options, "watcherPort") ?? process.env.WATCHER_PORT ?? process.env.METRO_PORT;
|
|
25
|
+
const watcherPort = watcherPortRaw === void 0 ? void 0 : parsePort(watcherPortRaw, "runtime-decision --watcher-port must be a port.");
|
|
26
|
+
const { decideMobileReadiness } = await import("./runtime-decision.js");
|
|
27
|
+
const report = await decideMobileReadiness(target, {
|
|
28
|
+
watcherPort,
|
|
29
|
+
metroLog: optionString(options, "metroLog"),
|
|
30
|
+
platform: optionString(options, "platform") ?? process.env.PLATFORM ?? process.env.RECIPE_HARNESS_PLATFORM,
|
|
31
|
+
record: optionFlag(options, "record"),
|
|
32
|
+
preflightMode: optionString(options, "preflightMode")
|
|
33
|
+
});
|
|
34
|
+
return { report, line: `${report.decision} ${report.reasonCode} \u2014 ${report.reasons[0] ?? ""}` };
|
|
35
|
+
}
|
|
36
|
+
export {
|
|
37
|
+
mobileReadiness,
|
|
38
|
+
mobileRuntimeDecision
|
|
39
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { EXIT, spawnScript } from "../../commands/shared.js";
|
|
3
|
+
import { runnerDir } from "../../paths.js";
|
|
4
|
+
function reloadMobile(target, json) {
|
|
5
|
+
const script = path.join(runnerDir, "adapters/mobile/reload-app.mjs");
|
|
6
|
+
const args = [];
|
|
7
|
+
if (process.env.WATCHER_PORT) args.push("--port", process.env.WATCHER_PORT);
|
|
8
|
+
if (json) args.push("--json");
|
|
9
|
+
const result = spawnScript(process.execPath, [script, ...args], target, json);
|
|
10
|
+
if (json) process.stdout.write(result.output);
|
|
11
|
+
return result.status === 0 ? EXIT.ok : EXIT.runtime;
|
|
12
|
+
}
|
|
13
|
+
export {
|
|
14
|
+
reloadMobile
|
|
15
|
+
};
|