@deeeed/metamask-harness 0.14.7 → 0.15.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 +81 -6
- package/adapters/core/cleanup.sh +0 -0
- package/adapters/core/inject.sh +0 -0
- package/adapters/extension/cleanup.mjs +0 -0
- package/adapters/extension/ensure-browser.sh +4 -2
- package/adapters/extension/inject.mjs +0 -0
- package/adapters/extension/launch-browser.cjs +73 -34
- package/adapters/extension/launch.sh +0 -0
- package/adapters/extension/live.sh +21 -10
- package/adapters/extension/readiness.mjs +0 -0
- package/adapters/extension/reattach.sh +3 -3
- package/adapters/extension/refresh-build.sh +0 -0
- package/adapters/extension/seed-fixture.sh +0 -0
- package/adapters/extension/sidepanel-toggle.sh +10 -4
- package/adapters/extension/snapshot-dist.sh +0 -0
- package/adapters/extension/start-watch.sh +2 -2
- package/adapters/extension/stop-viewers.sh +0 -0
- package/adapters/extension/verify.sh +19 -2
- package/adapters/extension/wallet-fixture-state.cjs +14 -1
- package/adapters/manifest.json +19 -3
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +0 -0
- package/adapters/mobile/bridge-runtime/setup-wallet.sh +6 -4
- package/adapters/mobile/cleanup.sh +0 -0
- package/adapters/mobile/inject.sh +0 -0
- package/adapters/mobile/launch-metro.cjs +74 -0
- package/adapters/mobile/lib/metro-listener.sh +0 -0
- package/adapters/mobile/lib/tmux-viewer.sh +4 -4
- package/adapters/mobile/open-device.sh +3 -1
- package/adapters/mobile/prewarm-bundle.sh +0 -0
- package/adapters/mobile/start-metro.sh +12 -18
- package/adapters/mobile/stop-metro.sh +1 -0
- package/adapters/mobile/verify.sh +5 -5
- package/adapters/mobile/wait-for-bridge.sh +0 -0
- package/adapters/mobile/yarn-setup.sh +0 -0
- package/adapters/shared/activate-repo-node.sh +0 -0
- package/adapters/shared/activate-repo-ruby.sh +0 -0
- package/adapters/shared/cli-ux.sh +0 -0
- package/adapters/shared/ensure-runner-deps.sh +0 -0
- package/adapters/shared/harness-path.sh +0 -0
- package/adapters/shared/hash-helpers.sh +0 -0
- package/adapters/shared/install-repo-deps.sh +29 -0
- package/adapters/shared/json-field.sh +0 -0
- package/adapters/shared/open-debug.mjs +35 -4
- package/adapters/shared/open-log-window.sh +1 -1
- package/adapters/shared/reap-checkout-metros.sh +0 -0
- package/adapters/shared/resolve-farmslot-ports.mjs +0 -0
- package/adapters/shared/resolve-farmslot-ports.sh +0 -0
- package/adapters/shared/resolve-slot-ports-core.mjs +72 -1
- package/adapters/shared/resolve-slot-ports.mjs +0 -0
- package/adapters/shared/resolve-slot-ports.sh +21 -17
- package/adapters/shared/sync-wallet-fixture.sh +0 -0
- package/adapters/shared/tmux-session.sh +0 -5
- package/adapters/shared/tmux-viewer.sh +7 -2
- package/dist/adapters/core/surface.js +3 -0
- package/dist/adapters/extension/ensure-ready.js +0 -7
- package/dist/adapters/extension/runtime-decision.js +93 -3
- package/dist/adapters/extension/surface.js +17 -6
- package/dist/adapters/mobile/metro-env.js +67 -0
- package/dist/adapters/mobile/perps-env.js +101 -0
- package/dist/adapters/mobile/prepare.js +32 -14
- package/dist/adapters/mobile/runtime-decision.js +21 -2
- package/dist/adapters/mobile/source-freshness.js +116 -0
- package/dist/adapters/mobile/surface.js +3 -0
- package/dist/adapters/resolve-slot-ports.js +4 -0
- package/dist/adapters/slot-ports.js +131 -49
- package/dist/adapters.js +7 -2
- package/dist/checkout-lock.js +72 -0
- package/dist/cli.js +2 -0
- package/dist/commands/call.js +91 -54
- package/dist/commands/check.js +266 -46
- package/dist/commands/checklist.js +136 -0
- package/dist/commands/debug.js +21 -2
- package/dist/commands/doctor.js +187 -12
- package/dist/commands/fixtures.js +177 -42
- package/dist/commands/launch/extension.js +6 -1
- package/dist/commands/launch/index.js +54 -22
- package/dist/commands/logs.js +24 -4
- package/dist/commands/run-engine.js +223 -7
- package/dist/commands/run.js +67 -50
- package/dist/commands/shared.js +86 -5
- package/dist/commands/stop.js +1 -2
- package/dist/doctor.js +39 -17
- package/dist/harness.js +5 -2
- package/dist/heal-bounds.js +1 -1
- package/dist/mm-harness-cli.js +37 -9
- package/dist/runner.js +35 -5
- package/dist/runtime-context.js +228 -0
- package/docs/CLI-SPEC.md +15 -11
- package/docs/MENTAL-MODEL.md +6 -6
- package/library/actions/extension/perps/perps.mjs +29 -8
- package/library/actions/extension/platform/cdp.mjs +128 -28
- package/library/actions/extension/ui/navigate.mjs +1 -1
- package/library/actions/mobile/perps/perps.mjs +13 -1
- package/library/actions/mobile/platform/bridge.mjs +302 -29
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +7 -2
- package/library/manifests/extension.action-manifest.json +32 -12
- package/library/manifests/mobile.action-manifest.json +25 -3
- package/package.json +5 -5
- package/scripts/completions.sh +7 -4
- package/scripts/install-completions.sh +0 -0
|
@@ -7,6 +7,7 @@ import { recipeRuntimePath, runnerDir } from "../../paths.js";
|
|
|
7
7
|
import { getAdapterSurface } from "../../adapters/surface.js";
|
|
8
8
|
import {
|
|
9
9
|
ADAPTER_DETECT_NEXT,
|
|
10
|
+
checkoutBusyOut,
|
|
10
11
|
EXIT,
|
|
11
12
|
flag,
|
|
12
13
|
parseFlags,
|
|
@@ -23,9 +24,15 @@ import {
|
|
|
23
24
|
newHealState,
|
|
24
25
|
parseHeal
|
|
25
26
|
} from "../../heal-bounds.js";
|
|
26
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
extensionDepsBlock,
|
|
29
|
+
extensionRuntimeReusable,
|
|
30
|
+
installExtensionDeps,
|
|
31
|
+
launchExtension
|
|
32
|
+
} from "./extension.js";
|
|
27
33
|
import { launchMobile } from "./mobile.js";
|
|
28
34
|
import { applyDeviceTargeting } from "../device-target.js";
|
|
35
|
+
import { acquireCheckoutLock } from "../../checkout-lock.js";
|
|
29
36
|
const LAUNCH_BOOLEANS = /* @__PURE__ */ new Set([
|
|
30
37
|
"build",
|
|
31
38
|
"watch",
|
|
@@ -37,6 +44,21 @@ const LAUNCH_BOOLEANS = /* @__PURE__ */ new Set([
|
|
|
37
44
|
]);
|
|
38
45
|
const DEFAULT_EXTENSION_DAPP_URL = "https://metamask.github.io/test-dapp/";
|
|
39
46
|
async function handleLaunch(argv) {
|
|
47
|
+
const { options } = parseFlags(argv, LAUNCH_BOOLEANS);
|
|
48
|
+
const json = flag(options, "json");
|
|
49
|
+
const target = targetOf(options);
|
|
50
|
+
if (!fs.existsSync(target)) return handleLaunchLocked(argv);
|
|
51
|
+
const lock = acquireCheckoutLock(target, "launch");
|
|
52
|
+
if ("message" in lock) {
|
|
53
|
+
return checkoutBusyOut(json, "launch", lock.message, lock.path);
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
return await handleLaunchLocked(argv);
|
|
57
|
+
} finally {
|
|
58
|
+
lock.release();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function handleLaunchLocked(argv) {
|
|
40
62
|
const { positional, options } = parseFlags(argv, LAUNCH_BOOLEANS);
|
|
41
63
|
const json = flag(options, "json");
|
|
42
64
|
const target = targetOf(options);
|
|
@@ -86,14 +108,14 @@ async function handleLaunch(argv) {
|
|
|
86
108
|
if (envResult && "code" in envResult) {
|
|
87
109
|
return usageOut(json, "launch", envResult.message, "mm-harness launch android --device <adb-serial|device-name>");
|
|
88
110
|
}
|
|
89
|
-
const tier =
|
|
111
|
+
const tier = wantBuild ? "build" : "quick";
|
|
90
112
|
if (adapter === "extension") {
|
|
91
113
|
const requestedUrl = typeof options["url"] === "string" && options["url"] ? options["url"] : "";
|
|
92
114
|
if (requestedUrl || displayMode === "sidepanel") {
|
|
93
115
|
process.env.EXTENSION_START_URL = requestedUrl || DEFAULT_EXTENSION_DAPP_URL;
|
|
94
116
|
}
|
|
95
117
|
}
|
|
96
|
-
if (!json && adapter === "extension"
|
|
118
|
+
if (!json && adapter === "extension") {
|
|
97
119
|
const modeNote = displayMode === "sidepanel" ? `sidepanel \xB7 dapp ${process.env.EXTENSION_START_URL ?? DEFAULT_EXTENSION_DAPP_URL}` : "fullscreen";
|
|
98
120
|
const workNote = wantWatch ? "watch only" : tier === "build" ? "clean build" : "quick reuse probe";
|
|
99
121
|
console.error(
|
|
@@ -118,32 +140,35 @@ async function handleLaunch(argv) {
|
|
|
118
140
|
exitCode: EXIT.infra
|
|
119
141
|
});
|
|
120
142
|
}
|
|
121
|
-
if (tier === "verify") {
|
|
122
|
-
const verifyCdpPort = Number(process.env.CDP_PORT ?? "");
|
|
123
|
-
if (adapter === "extension" && Number.isInteger(verifyCdpPort) && verifyCdpPort > 0) {
|
|
124
|
-
const { ensureExtensionReady } = await import("../../adapters/extension/ensure-ready.js");
|
|
125
|
-
const convergence = await ensureExtensionReady(target, { cdpPort: verifyCdpPort });
|
|
126
|
-
console.error(
|
|
127
|
-
`[launch] home-tab convergence: ${convergence.action} (${convergence.homeTabs.before}\u2192${convergence.homeTabs.after} tabs, closed ${convergence.homeTabs.closed})${convergence.ready ? "" : ` \u2014 ${convergence.reasonCode}`}`
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
const liveArgs = ["live", "--adapter", adapter, "--target", target];
|
|
131
|
-
if (adapter === "mobile" && mobileTarget) liveArgs.push("--platform", mobileTarget);
|
|
132
|
-
if (json) liveArgs.push("--json");
|
|
133
|
-
return handleHarness(liveArgs);
|
|
134
|
-
}
|
|
135
143
|
if (adapter === "extension") {
|
|
136
144
|
const willBuild = wantWatch || tier === "build" || !await extensionRuntimeReusable(target);
|
|
137
145
|
if (willBuild) {
|
|
138
146
|
const depsBlock = extensionDepsBlock(target);
|
|
139
147
|
if (depsBlock) {
|
|
140
|
-
|
|
148
|
+
if (heal === "off") {
|
|
149
|
+
return usageOut(json, "launch", depsBlock.message, depsBlock.userAction);
|
|
150
|
+
}
|
|
151
|
+
const recoveryCode2 = "deps.installed";
|
|
152
|
+
state.attemptedRecoveries.push(recoveryCode2);
|
|
153
|
+
const repaired = await installExtensionDeps(target);
|
|
154
|
+
if (repaired.status !== 0 || extensionDepsBlock(target)) {
|
|
155
|
+
return launchFail(json, adapter, mobileTarget, tier, state, {
|
|
156
|
+
code: "DEPENDENCY_INSTALL_FAILED",
|
|
157
|
+
message: depsBlock.message,
|
|
158
|
+
recoverable: false,
|
|
159
|
+
userAction: "install the checkout-pinned Node version, then re-run mm-harness launch",
|
|
160
|
+
originalError: repaired.output,
|
|
161
|
+
exitCode: EXIT.infra
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
state.recovered.push(recoveryCode2);
|
|
165
|
+
state.mutations.push({ type: "dependencies", action: "installed", path: target });
|
|
141
166
|
}
|
|
142
167
|
}
|
|
143
168
|
}
|
|
144
169
|
let attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, json, displayMode);
|
|
145
170
|
if (attempt.status === 0) {
|
|
146
|
-
return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch);
|
|
171
|
+
return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
|
|
147
172
|
}
|
|
148
173
|
if (heal === "off") {
|
|
149
174
|
return launchFail(json, adapter, mobileTarget, tier, state, {
|
|
@@ -159,7 +184,7 @@ async function handleLaunch(argv) {
|
|
|
159
184
|
const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, json, displayMode);
|
|
160
185
|
if (rebuildAttempt.status === 0) {
|
|
161
186
|
state.recovered.push(recoveryCode2);
|
|
162
|
-
return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch);
|
|
187
|
+
return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
|
|
163
188
|
}
|
|
164
189
|
return launchFail(json, adapter, mobileTarget, tier, state, {
|
|
165
190
|
code: "EXTENSION_RELAUNCH_FAILED",
|
|
@@ -195,7 +220,7 @@ async function handleLaunch(argv) {
|
|
|
195
220
|
attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, json, displayMode);
|
|
196
221
|
if (attempt.status === 0) {
|
|
197
222
|
state.recovered.push(recoveryCode);
|
|
198
|
-
return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch);
|
|
223
|
+
return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
|
|
199
224
|
}
|
|
200
225
|
return launchFail(json, adapter, mobileTarget, tier, state, {
|
|
201
226
|
code: "SAME_RECOVERY_TWICE",
|
|
@@ -303,7 +328,7 @@ async function executeComposition(adapter, mobileTarget, tier, wantWatch, target
|
|
|
303
328
|
}
|
|
304
329
|
return launchExtension(target, tier, wantWatch, displayMode);
|
|
305
330
|
}
|
|
306
|
-
async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, sidepanelRecoveryAllowed = true) {
|
|
331
|
+
async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, sidepanelRecoveryAllowed = true) {
|
|
307
332
|
if (adapter === "extension" && displayMode === "sidepanel") {
|
|
308
333
|
const sidepanelSh = path.join(runnerDir, "adapters/extension/sidepanel-toggle.sh");
|
|
309
334
|
const sidepanelArgs = ["open"];
|
|
@@ -330,6 +355,7 @@ async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, targ
|
|
|
330
355
|
target,
|
|
331
356
|
state,
|
|
332
357
|
wantWatch,
|
|
358
|
+
wantVerify,
|
|
333
359
|
false
|
|
334
360
|
);
|
|
335
361
|
}
|
|
@@ -351,6 +377,12 @@ async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, targ
|
|
|
351
377
|
});
|
|
352
378
|
}
|
|
353
379
|
}
|
|
380
|
+
if (wantVerify) {
|
|
381
|
+
const verifyArgs = ["verify", "--adapter", adapter, "--target", target];
|
|
382
|
+
if (adapter === "mobile" && mobileTarget) verifyArgs.push("--platform", mobileTarget);
|
|
383
|
+
if (json) verifyArgs.push("--json");
|
|
384
|
+
return handleHarness(verifyArgs);
|
|
385
|
+
}
|
|
354
386
|
return launchPass(json, adapter, mobileTarget, tier, displayMode, state);
|
|
355
387
|
}
|
|
356
388
|
function launchPass(json, adapter, mobileTarget, tier, displayMode, state) {
|
package/dist/commands/logs.js
CHANGED
|
@@ -17,10 +17,11 @@ async function handleLogs(argv) {
|
|
|
17
17
|
return usageOut(json, "logs", "core is headless; it has no dev server logs.", surface.hints.launch);
|
|
18
18
|
}
|
|
19
19
|
const logSources = surface.logSources(target);
|
|
20
|
+
const appLogSource = surface.appLogSource(target);
|
|
20
21
|
const sourceLabels = logSources.map((entry) => entry.label);
|
|
21
22
|
const defaultSource = sourceLabels[0];
|
|
22
23
|
const source = str(options, "source") ?? defaultSource;
|
|
23
|
-
const validSources = [...sourceLabels, "app"];
|
|
24
|
+
const validSources = [...sourceLabels, ...appLogSource ? ["app"] : []];
|
|
24
25
|
if (!validSources.includes(source)) {
|
|
25
26
|
return usageOut(json, "logs", `--source must be one of: ${validSources.join(", ")}.`, `mm-harness logs --source ${defaultSource}`);
|
|
26
27
|
}
|
|
@@ -31,11 +32,30 @@ async function handleLogs(argv) {
|
|
|
31
32
|
}
|
|
32
33
|
process.env.RECIPE_LOG_EVENTS = events;
|
|
33
34
|
}
|
|
34
|
-
const requested = logSources.find((entry) => entry.label === source);
|
|
35
|
-
const ordered = requested ? [requested, ...logSources.filter((entry) => entry !== requested)] : logSources;
|
|
35
|
+
const requested = source === "app" ? appLogSource : logSources.find((entry) => entry.label === source);
|
|
36
|
+
const ordered = source === "app" ? requested ? [requested] : [] : requested ? [requested, ...logSources.filter((entry) => entry !== requested)] : logSources;
|
|
36
37
|
const logFile = ordered.find((entry) => fs.existsSync(entry.path))?.path;
|
|
37
38
|
if (!logFile) {
|
|
38
|
-
|
|
39
|
+
if (source === "app" && appLogSource) {
|
|
40
|
+
const message = `application console unavailable for this checkout (${path.basename(appLogSource.path)} is absent).`;
|
|
41
|
+
const userAction = surface.hints.launch;
|
|
42
|
+
if (json) {
|
|
43
|
+
console.log(JSON.stringify({
|
|
44
|
+
schemaVersion: 1,
|
|
45
|
+
command: "logs",
|
|
46
|
+
adapter,
|
|
47
|
+
source,
|
|
48
|
+
status: "fail",
|
|
49
|
+
error: { code: "APP_LOG_UNAVAILABLE", message, userAction },
|
|
50
|
+
exitCode: EXIT.runtime
|
|
51
|
+
}, null, 2));
|
|
52
|
+
} else {
|
|
53
|
+
console.error(`\u2717 logs: ${message}`);
|
|
54
|
+
console.error(` Next: ${userAction}`);
|
|
55
|
+
}
|
|
56
|
+
return EXIT.runtime;
|
|
57
|
+
}
|
|
58
|
+
const names = source === "app" && appLogSource ? path.basename(appLogSource.path) : logSources.map((entry) => path.basename(entry.path)).join(" / ");
|
|
39
59
|
return usageOut(
|
|
40
60
|
json,
|
|
41
61
|
"logs",
|
|
@@ -298,6 +298,27 @@ async function validateRunRecipeStatic(recipeArg, adapter, options) {
|
|
|
298
298
|
}
|
|
299
299
|
const validation = manifestOk ? await validateRecipeAdapterAware(recipe, manifest, librarySources) : { status: "invalid", findings: [], summary: { errors: 1, warnings: 0 } };
|
|
300
300
|
findings.push(...validation.findings);
|
|
301
|
+
if (isRecord(recipe)) {
|
|
302
|
+
const validate = isRecord(recipe.validate) ? recipe.validate : void 0;
|
|
303
|
+
const workflow = validate && isRecord(validate.workflow) ? validate.workflow : void 0;
|
|
304
|
+
if (workflow?.startState != null) {
|
|
305
|
+
findings.push({
|
|
306
|
+
severity: "error",
|
|
307
|
+
code: "recipe.start-state-placement",
|
|
308
|
+
path: "validate.workflow.startState",
|
|
309
|
+
message: "startState must be declared at the recipe root, not validate.workflow.startState"
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
const startState = isRecord(recipe.startState) ? recipe.startState : void 0;
|
|
313
|
+
if (adapter === "extension" && startState?.action === "metamask.perps.start_state" && startState.page === false) {
|
|
314
|
+
findings.push({
|
|
315
|
+
severity: "error",
|
|
316
|
+
code: "recipe.extension-perps-start-page",
|
|
317
|
+
path: "startState.page",
|
|
318
|
+
message: "Extension metamask.perps.start_state must enter Perps; omit page or provide a string target instead of false"
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
301
322
|
const errorCount = findings.filter((finding) => finding.severity === "error").length;
|
|
302
323
|
return {
|
|
303
324
|
recipe,
|
|
@@ -305,7 +326,7 @@ async function validateRunRecipeStatic(recipeArg, adapter, options) {
|
|
|
305
326
|
findings,
|
|
306
327
|
errorCount,
|
|
307
328
|
manifestOk,
|
|
308
|
-
schemaValid: validation.status === "valid",
|
|
329
|
+
schemaValid: validation.status === "valid" && errorCount === 0,
|
|
309
330
|
...librarySources ? { librarySources } : {}
|
|
310
331
|
};
|
|
311
332
|
}
|
|
@@ -369,11 +390,12 @@ async function runOneNode(adapter, action, args, target, actionManifest) {
|
|
|
369
390
|
fs.writeFileSync(recipeFile, `${JSON.stringify(recipe, null, 2)}
|
|
370
391
|
`);
|
|
371
392
|
const result = await runRecipe(adapter, recipeFile, path.join(scratch, "artifacts"), target, actionManifest, {
|
|
372
|
-
librarySources: await resolveMetaMaskLibrarySources(void 0)
|
|
393
|
+
librarySources: await resolveMetaMaskLibrarySources(void 0),
|
|
394
|
+
stdoutIsMachineContract: true
|
|
373
395
|
});
|
|
374
396
|
return { status: result.status === "pass" ? "pass" : "fail" };
|
|
375
397
|
}
|
|
376
|
-
async function prepareHeal(adapter, target, options, json) {
|
|
398
|
+
async function prepareHeal(adapter, target, options, json, prepareOptions = {}) {
|
|
377
399
|
if (recipeRunning(target)) {
|
|
378
400
|
const msg = "a recipe is currently running \u2014 refusing to start while another recipe executes.";
|
|
379
401
|
if (json) {
|
|
@@ -407,13 +429,203 @@ async function prepareHeal(adapter, target, options, json) {
|
|
|
407
429
|
}
|
|
408
430
|
return EXIT.infra;
|
|
409
431
|
}
|
|
432
|
+
if (adapter === "extension") {
|
|
433
|
+
const previousCdpPort = process.env.CDP_PORT;
|
|
434
|
+
const previousRecipeCdpPort = process.env.RECIPE_CDP_PORT;
|
|
435
|
+
const previousWatcherPort = process.env.WATCHER_PORT;
|
|
436
|
+
const explicitCdpPort = optionString(options, "cdpPort");
|
|
437
|
+
const explicitWatcherPort = optionString(options, "watcherPort") ?? optionString(options, "metroPort");
|
|
438
|
+
if (explicitCdpPort) {
|
|
439
|
+
process.env.CDP_PORT = explicitCdpPort;
|
|
440
|
+
process.env.RECIPE_CDP_PORT = explicitCdpPort;
|
|
441
|
+
}
|
|
442
|
+
if (explicitWatcherPort) process.env.WATCHER_PORT = explicitWatcherPort;
|
|
443
|
+
try {
|
|
444
|
+
const current = await ensureExtensionProofRuntime(target, heal, state, json);
|
|
445
|
+
if (current !== null) return current;
|
|
446
|
+
} finally {
|
|
447
|
+
restoreEnv("CDP_PORT", previousCdpPort);
|
|
448
|
+
restoreEnv("RECIPE_CDP_PORT", previousRecipeCdpPort);
|
|
449
|
+
restoreEnv("WATCHER_PORT", previousWatcherPort);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (adapter === "mobile" && prepareOptions.skipMobileSourceFreshness !== true) {
|
|
453
|
+
const current = await ensureMobileProofRuntime(target, heal, state, json);
|
|
454
|
+
if (current !== null) return current;
|
|
455
|
+
}
|
|
410
456
|
return { state, heal };
|
|
411
457
|
}
|
|
458
|
+
async function ensureMobileProofRuntime(target, heal, state, json, deps = {}) {
|
|
459
|
+
const source = await import("../adapters/mobile/source-freshness.js");
|
|
460
|
+
const check = deps.check ?? (() => source.mobileSourceCheck(target));
|
|
461
|
+
const record = deps.record ?? ((fingerprint) => source.recordMobileSourceBaseline(target, fingerprint));
|
|
462
|
+
const initial = check();
|
|
463
|
+
if (initial.status === "unavailable") {
|
|
464
|
+
if (json) {
|
|
465
|
+
console.log(JSON.stringify({
|
|
466
|
+
schemaVersion: 1,
|
|
467
|
+
status: "fail",
|
|
468
|
+
recoverable: false,
|
|
469
|
+
error: initial.error
|
|
470
|
+
}, null, 2));
|
|
471
|
+
} else {
|
|
472
|
+
console.error(`\u2717 ${initial.error.message}`);
|
|
473
|
+
console.error(` Next: ${initial.error.userAction}`);
|
|
474
|
+
}
|
|
475
|
+
return EXIT.usage;
|
|
476
|
+
}
|
|
477
|
+
if (initial.status === "current") return null;
|
|
478
|
+
const targetArg = JSON.stringify(path.resolve(target));
|
|
479
|
+
const userAction = `mm-harness call app.lifecycle --arg command=restart --adapter mobile --target ${targetArg}`;
|
|
480
|
+
if (heal === "off") {
|
|
481
|
+
const message = `Mobile loaded source is not current (${initial.status}).`;
|
|
482
|
+
if (json) {
|
|
483
|
+
console.log(JSON.stringify({
|
|
484
|
+
schemaVersion: 1,
|
|
485
|
+
status: "fail",
|
|
486
|
+
recoverable: true,
|
|
487
|
+
mutations: state.mutations,
|
|
488
|
+
error: { code: "MOBILE_SOURCE_NOT_LOADED", message, userAction }
|
|
489
|
+
}, null, 2));
|
|
490
|
+
} else {
|
|
491
|
+
console.error(`\u2717 ${message}`);
|
|
492
|
+
console.error(` Next: ${userAction}`);
|
|
493
|
+
}
|
|
494
|
+
return EXIT.infra;
|
|
495
|
+
}
|
|
496
|
+
const recoveryCode = "mobile.source_reloaded";
|
|
497
|
+
state.attemptedRecoveries.push(recoveryCode);
|
|
498
|
+
if (!json) console.error(`\u2192 Mobile proof preflight: source ${initial.status}; restarting the app before execution`);
|
|
499
|
+
const restart = deps.restart ?? (async () => {
|
|
500
|
+
const result2 = await runOneNode(
|
|
501
|
+
"mobile",
|
|
502
|
+
"app.lifecycle",
|
|
503
|
+
{ command: "restart" },
|
|
504
|
+
target,
|
|
505
|
+
void 0
|
|
506
|
+
);
|
|
507
|
+
return { status: result2.status === "pass" ? 0 : EXIT.runtime, output: result2.status };
|
|
508
|
+
});
|
|
509
|
+
const result = await restart();
|
|
510
|
+
const recorded = result.status === 0 && record(initial.fingerprint);
|
|
511
|
+
const verified = recorded ? check() : initial;
|
|
512
|
+
if (result.status !== 0 || !recorded || verified.status !== "current") {
|
|
513
|
+
const message = "Mobile source restart did not produce a current loaded-source baseline.";
|
|
514
|
+
if (json) {
|
|
515
|
+
console.log(JSON.stringify({
|
|
516
|
+
schemaVersion: 1,
|
|
517
|
+
status: "fail",
|
|
518
|
+
recoverable: false,
|
|
519
|
+
attemptedRecoveries: state.attemptedRecoveries,
|
|
520
|
+
error: { code: "MOBILE_SOURCE_RELOAD_FAILED", message, userAction, originalError: result.output }
|
|
521
|
+
}, null, 2));
|
|
522
|
+
} else {
|
|
523
|
+
console.error(`\u2717 ${message}`);
|
|
524
|
+
console.error(` Next: ${userAction}`);
|
|
525
|
+
}
|
|
526
|
+
return EXIT.infra;
|
|
527
|
+
}
|
|
528
|
+
state.recovered.push(recoveryCode);
|
|
529
|
+
state.mutations.push({ type: "runtime", action: "restarted", reasonCode: `source-${initial.status}` });
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
async function ensureExtensionProofRuntime(target, heal, state, json, deps = {}) {
|
|
533
|
+
const decide = deps.decide ?? (async () => {
|
|
534
|
+
const { decideExtensionReadiness } = await import("../adapters/extension/runtime-decision.js");
|
|
535
|
+
const port = Number(process.env.CDP_PORT);
|
|
536
|
+
return decideExtensionReadiness(target, {
|
|
537
|
+
...Number.isInteger(port) && port > 0 ? { cdpPort: port } : {},
|
|
538
|
+
pageMode: "home-or-sidepanel"
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
const initial = await decide();
|
|
542
|
+
if (initial.decision === "ready") return null;
|
|
543
|
+
if (initial.reasonCode === "runtime-dist-check-failed") {
|
|
544
|
+
const message = `Extension proof runtime freshness could not be checked: ${initial.reasons[0] ?? initial.decision}`;
|
|
545
|
+
const userAction2 = `mm-harness doctor --adapter extension --target ${JSON.stringify(path.resolve(target))}`;
|
|
546
|
+
if (json) {
|
|
547
|
+
console.log(JSON.stringify({
|
|
548
|
+
schemaVersion: 1,
|
|
549
|
+
status: "fail",
|
|
550
|
+
recoverable: false,
|
|
551
|
+
error: { code: "EXTENSION_RUNTIME_CHECK_FAILED", message, userAction: userAction2 }
|
|
552
|
+
}, null, 2));
|
|
553
|
+
} else {
|
|
554
|
+
console.error(`\u2717 ${message}`);
|
|
555
|
+
console.error(` Next: ${userAction2}`);
|
|
556
|
+
}
|
|
557
|
+
return EXIT.infra;
|
|
558
|
+
}
|
|
559
|
+
if (!["dist-stale", "dist-missing", "runtime-dist-stale", "runtime-dist-missing"].includes(initial.reasonCode)) {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
const tier = initial.decision === "build" ? "build" : "quick";
|
|
563
|
+
const canHeal = heal === "auto" || heal === "infra-only" && tier === "quick";
|
|
564
|
+
const targetArg = JSON.stringify(path.resolve(target));
|
|
565
|
+
const userAction = tier === "build" ? `mm-harness launch --build --verify --adapter extension --target ${targetArg}` : `mm-harness launch --verify --adapter extension --target ${targetArg}`;
|
|
566
|
+
if (!canHeal) {
|
|
567
|
+
const message = `Extension proof runtime is not current (${initial.reasonCode}): ${initial.reasons[0] ?? initial.decision}`;
|
|
568
|
+
if (json) {
|
|
569
|
+
console.log(JSON.stringify({
|
|
570
|
+
schemaVersion: 1,
|
|
571
|
+
status: "fail",
|
|
572
|
+
recoverable: true,
|
|
573
|
+
mutations: state.mutations,
|
|
574
|
+
error: { code: "EXTENSION_RUNTIME_NOT_CURRENT", message, userAction }
|
|
575
|
+
}, null, 2));
|
|
576
|
+
} else {
|
|
577
|
+
console.error(`\u2717 ${message}`);
|
|
578
|
+
console.error(` Next: ${userAction}`);
|
|
579
|
+
}
|
|
580
|
+
return EXIT.infra;
|
|
581
|
+
}
|
|
582
|
+
const recoveryCode = tier === "build" ? "extension.runtime_rebuilt" : "extension.runtime_reloaded";
|
|
583
|
+
state.attemptedRecoveries.push(recoveryCode);
|
|
584
|
+
if (!json) console.error(`\u2192 Extension proof preflight: ${initial.reasonCode}; ${tier === "build" ? "rebuilding" : "reloading"} before execution`);
|
|
585
|
+
const launch = deps.launch ?? (async (requestedTier) => {
|
|
586
|
+
const { launchExtension } = await import("./launch/extension.js");
|
|
587
|
+
return launchExtension(target, requestedTier, false);
|
|
588
|
+
});
|
|
589
|
+
const result = await launch(tier);
|
|
590
|
+
const verified = result.status === 0 ? await decide() : initial;
|
|
591
|
+
if (result.status !== 0 || verified.decision !== "ready") {
|
|
592
|
+
const message = `Extension runtime recovery did not produce a current healthy runtime (${verified.reasonCode}).`;
|
|
593
|
+
if (json) {
|
|
594
|
+
console.log(JSON.stringify({
|
|
595
|
+
schemaVersion: 1,
|
|
596
|
+
status: "fail",
|
|
597
|
+
recoverable: false,
|
|
598
|
+
attemptedRecoveries: state.attemptedRecoveries,
|
|
599
|
+
error: { code: "EXTENSION_RUNTIME_RECOVERY_FAILED", message, userAction, originalError: result.output }
|
|
600
|
+
}, null, 2));
|
|
601
|
+
} else {
|
|
602
|
+
console.error(`\u2717 ${message}`);
|
|
603
|
+
console.error(` Next: ${userAction}`);
|
|
604
|
+
}
|
|
605
|
+
return EXIT.infra;
|
|
606
|
+
}
|
|
607
|
+
state.recovered.push(recoveryCode);
|
|
608
|
+
state.mutations.push({ type: "runtime", action: tier === "build" ? "rebuilt" : "reloaded", reasonCode: initial.reasonCode });
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
412
611
|
const RUN_RECOVERY_CODE = {
|
|
413
|
-
mobile: "
|
|
414
|
-
extension: "
|
|
415
|
-
core: "
|
|
612
|
+
mobile: "mobile.runtime_recovered",
|
|
613
|
+
extension: "extension.runtime_recovered",
|
|
614
|
+
core: "run.retried"
|
|
416
615
|
};
|
|
616
|
+
async function recoverRunInfra(adapter, target, json) {
|
|
617
|
+
getAdapterSurface(adapter).resolveSlotPorts(target);
|
|
618
|
+
if (adapter === "extension") {
|
|
619
|
+
const { launchExtension } = await import("./launch/extension.js");
|
|
620
|
+
return launchExtension(target, "quick", false);
|
|
621
|
+
}
|
|
622
|
+
if (adapter === "mobile") {
|
|
623
|
+
const { launchMobile } = await import("./launch/mobile.js");
|
|
624
|
+
const platform = process.env.PLATFORM === "android" ? "android" : "ios";
|
|
625
|
+
return launchMobile(target, platform, "quick", json);
|
|
626
|
+
}
|
|
627
|
+
return { status: 0, output: "headless run retry" };
|
|
628
|
+
}
|
|
417
629
|
function readRunFailureText(result) {
|
|
418
630
|
try {
|
|
419
631
|
const trace = JSON.parse(fs.readFileSync(result.tracePath, "utf8"));
|
|
@@ -423,7 +635,7 @@ function readRunFailureText(result) {
|
|
|
423
635
|
return "";
|
|
424
636
|
}
|
|
425
637
|
}
|
|
426
|
-
async function executeWithHealBounds(exec, adapter, target, heal, state) {
|
|
638
|
+
async function executeWithHealBounds(exec, adapter, target, heal, state, recover = () => recoverRunInfra(adapter, target, false)) {
|
|
427
639
|
let result = await exec();
|
|
428
640
|
for (; ; ) {
|
|
429
641
|
if (result.status === "pass") return { result, violation: null };
|
|
@@ -432,6 +644,7 @@ async function executeWithHealBounds(exec, adapter, target, heal, state) {
|
|
|
432
644
|
if (heal === "off") return { result, violation: null };
|
|
433
645
|
const recoveryCode = RUN_RECOVERY_CODE[adapter];
|
|
434
646
|
state.attemptedRecoveries.push(recoveryCode);
|
|
647
|
+
await recover();
|
|
435
648
|
result = await exec();
|
|
436
649
|
if (result.status === "pass") {
|
|
437
650
|
state.recovered.push(recoveryCode);
|
|
@@ -488,8 +701,11 @@ function countRecipeNodes(recipe) {
|
|
|
488
701
|
export {
|
|
489
702
|
countRecipeNodes,
|
|
490
703
|
emitHealViolation,
|
|
704
|
+
ensureExtensionProofRuntime,
|
|
705
|
+
ensureMobileProofRuntime,
|
|
491
706
|
executeWithHealBounds,
|
|
492
707
|
prepareHeal,
|
|
708
|
+
recoverRunInfra,
|
|
493
709
|
resolveMetaMaskLibrarySources,
|
|
494
710
|
resolveRunRecipeArg,
|
|
495
711
|
runOneNode,
|
package/dist/commands/run.js
CHANGED
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { walletFixturePath } from "../paths.js";
|
|
4
4
|
import { color } from "../cli-color.js";
|
|
5
5
|
import { recipeRunning } from "../heal-bounds.js";
|
|
6
|
-
import { EXIT } from "./shared.js";
|
|
6
|
+
import { checkoutBusyOut, EXIT, usageOut } from "./shared.js";
|
|
7
7
|
import {
|
|
8
8
|
optionFlag,
|
|
9
9
|
optionString,
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
emitHealViolation,
|
|
19
19
|
executeWithHealBounds,
|
|
20
20
|
prepareHeal,
|
|
21
|
+
recoverRunInfra,
|
|
21
22
|
runRecipe,
|
|
22
23
|
validateRunRecipeStatic
|
|
23
24
|
} from "./run-engine.js";
|
|
@@ -26,6 +27,7 @@ import { applyDeviceTargeting } from "./device-target.js";
|
|
|
26
27
|
import { getAdapterSurface } from "../adapters/surface.js";
|
|
27
28
|
import { coreDependencyBlock } from "./core-readiness.js";
|
|
28
29
|
import { writeRunReport } from "./run-report.js";
|
|
30
|
+
import { acquireCheckoutLock } from "../checkout-lock.js";
|
|
29
31
|
async function handleRun({ positional, options }) {
|
|
30
32
|
if (optionFlag(options, "list")) return handleListExecutables("run", options);
|
|
31
33
|
const targetRecipe = positional[0];
|
|
@@ -33,6 +35,14 @@ async function handleRun({ positional, options }) {
|
|
|
33
35
|
if (optionFlag(options, "plan")) return handleRunPlan(targetRecipe, options);
|
|
34
36
|
const { adapter, target } = resolveAdapter(options);
|
|
35
37
|
const json = optionFlag(options, "json");
|
|
38
|
+
if (!fs.existsSync(target)) {
|
|
39
|
+
return usageOut(
|
|
40
|
+
json,
|
|
41
|
+
"run",
|
|
42
|
+
`target does not exist: ${target}`,
|
|
43
|
+
"pass --target <metamask-checkout> pointing to an existing checkout"
|
|
44
|
+
);
|
|
45
|
+
}
|
|
36
46
|
getAdapterSurface(adapter).resolveSlotPorts(target);
|
|
37
47
|
const dtResult = applyDeviceTargeting("run", adapter, options, { gate: true, rerun: "" });
|
|
38
48
|
if ("code" in dtResult) {
|
|
@@ -54,56 +64,63 @@ async function handleRun({ positional, options }) {
|
|
|
54
64
|
return emitRunUsageError(json, adapter, validated.recipeFile, depsBlock.code, depsBlock.message, depsBlock.userAction);
|
|
55
65
|
}
|
|
56
66
|
const artifactsDir = requiredOption(options, "artifactsDir", "run requires --artifacts-dir <dir>.");
|
|
57
|
-
const
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (json) {
|
|
79
|
-
console.log(
|
|
80
|
-
JSON.stringify(
|
|
81
|
-
{
|
|
82
|
-
schemaVersion: 1,
|
|
83
|
-
command: "run",
|
|
84
|
-
adapter,
|
|
85
|
-
status: result.status,
|
|
86
|
-
exitCode,
|
|
87
|
-
recovered: state.recovered,
|
|
88
|
-
mutations: state.mutations,
|
|
89
|
-
reportPath: report.path,
|
|
90
|
-
result
|
|
91
|
-
},
|
|
92
|
-
null,
|
|
93
|
-
2
|
|
94
|
-
)
|
|
67
|
+
const lock = acquireCheckoutLock(target, "run");
|
|
68
|
+
if ("message" in lock) return checkoutBusyOut(json, "run", lock.message, lock.path);
|
|
69
|
+
try {
|
|
70
|
+
const prepared = await prepareHeal(adapter, target, options, json);
|
|
71
|
+
if (typeof prepared === "number") return prepared;
|
|
72
|
+
const { state, heal } = prepared;
|
|
73
|
+
const librarySources = validated.librarySources;
|
|
74
|
+
const runtimeOptions = {
|
|
75
|
+
...runtimeOptionsFromCli(options),
|
|
76
|
+
...librarySources ? { librarySources } : {},
|
|
77
|
+
stdoutIsMachineContract: json
|
|
78
|
+
};
|
|
79
|
+
const { result, violation } = await executeWithHealBounds(
|
|
80
|
+
// validated.recipeFile, not the raw arg: the arg may be a library recipe NAME
|
|
81
|
+
// that only the resolver knows how to turn into a file.
|
|
82
|
+
() => runRecipe(adapter, validated.recipeFile, artifactsDir, target, optionString(options, "actionManifest"), runtimeOptions),
|
|
83
|
+
adapter,
|
|
84
|
+
target,
|
|
85
|
+
heal,
|
|
86
|
+
state,
|
|
87
|
+
() => recoverRunInfra(adapter, target, json)
|
|
95
88
|
);
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
if (
|
|
100
|
-
console.log(
|
|
101
|
-
|
|
89
|
+
if (violation !== null) return emitHealViolation(json, "run", result, violation, state, adapter);
|
|
90
|
+
const report = writeRunReport(result);
|
|
91
|
+
const exitCode = result.status === "pass" ? EXIT.ok : EXIT.runtime;
|
|
92
|
+
if (json) {
|
|
93
|
+
console.log(
|
|
94
|
+
JSON.stringify(
|
|
95
|
+
{
|
|
96
|
+
schemaVersion: 1,
|
|
97
|
+
command: "run",
|
|
98
|
+
adapter,
|
|
99
|
+
status: result.status,
|
|
100
|
+
exitCode,
|
|
101
|
+
recovered: state.recovered,
|
|
102
|
+
mutations: state.mutations,
|
|
103
|
+
reportPath: report.path,
|
|
104
|
+
result
|
|
105
|
+
},
|
|
106
|
+
null,
|
|
107
|
+
2
|
|
108
|
+
)
|
|
109
|
+
);
|
|
110
|
+
} else {
|
|
111
|
+
const out = (style, text) => color(style, text, { stream: process.stdout });
|
|
112
|
+
console.log(`${out(result.status === "pass" ? "ok" : "err", result.status.toUpperCase())} ${out("bold", "recipe run")} ${out("dim", `[${adapter}]`)}`);
|
|
113
|
+
if (report.preview.length > 0) {
|
|
114
|
+
console.log(out("label", "summary:"));
|
|
115
|
+
for (const line of report.preview) console.log(` ${formatPreviewLine(line, out)}`);
|
|
116
|
+
}
|
|
117
|
+
console.log(`${out("label", "report:")} ${out("path", report.path)}`);
|
|
118
|
+
console.log(`${out("label", "artifacts:")} ${out("path", result.artifactManifestPath)}`);
|
|
102
119
|
}
|
|
103
|
-
|
|
104
|
-
|
|
120
|
+
return exitCode;
|
|
121
|
+
} finally {
|
|
122
|
+
lock.release();
|
|
105
123
|
}
|
|
106
|
-
return exitCode;
|
|
107
124
|
}
|
|
108
125
|
function formatPreviewLine(line, out) {
|
|
109
126
|
const match = /^(PASS|FAIL)\s+(.+)$/u.exec(line);
|
|
@@ -271,8 +288,8 @@ function emitPlanUsageError(json, adapter, recipeFile, code, message) {
|
|
|
271
288
|
function runArgLooksLikeRecipeFile(value) {
|
|
272
289
|
return path.isAbsolute(value) || value.includes("/") || value.includes(path.sep) || value.endsWith(".json") || fs.existsSync(path.resolve(value));
|
|
273
290
|
}
|
|
274
|
-
function emitRunRecipeRunning(json) {
|
|
275
|
-
const message = "a recipe is currently running \u2014 refusing to start while another recipe executes.";
|
|
291
|
+
function emitRunRecipeRunning(json, detail) {
|
|
292
|
+
const message = detail ?? "a recipe is currently running \u2014 refusing to start while another recipe executes.";
|
|
276
293
|
if (json) {
|
|
277
294
|
console.log(JSON.stringify({ schemaVersion: 1, status: "fail", recoverable: false, error: { code: "RECIPE_RUNNING", message } }, null, 2));
|
|
278
295
|
} else {
|