@deeeed/metamask-harness 0.15.2 → 0.17.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 +23 -0
- package/README.md +12 -1
- package/dist/cli-commands.js +1 -1
- package/dist/cli.js +2 -0
- package/dist/command-contract.js +441 -0
- package/dist/command-journal.js +225 -0
- package/dist/commands/call.js +40 -17
- package/dist/commands/check.js +9 -3
- package/dist/commands/device-target.js +27 -12
- package/dist/commands/doctor.js +19 -6
- package/dist/commands/fixtures.js +1 -1
- package/dist/commands/last.js +52 -0
- package/dist/commands/launch/index.js +156 -59
- package/dist/commands/manifest.js +147 -9
- package/dist/commands/parse-args.js +2 -0
- package/dist/commands/provision.js +10 -3
- package/dist/commands/run-engine.js +34 -11
- package/dist/commands/run-report.js +12 -3
- package/dist/commands/run.js +194 -39
- package/dist/commands/shared.js +11 -1
- package/dist/commands/status.js +1 -1
- package/dist/commands/stop.js +7 -2
- package/dist/harness.js +16 -4
- package/dist/json-stream.js +57 -0
- package/dist/mm-harness-cli.js +114 -3
- package/dist/run-diagnostics.js +271 -0
- package/dist/runner.js +32 -1
- package/docs/CLI-ERGONOMICS-AUDIT.md +32 -0
- package/docs/CLI-ERGONOMICS-HUMAN-QA.md +104 -0
- package/docs/CLI-SPEC.md +63 -19
- package/docs/MENTAL-MODEL.md +2 -2
- package/docs/UX-PRINCIPLES.md +2 -0
- package/package.json +2 -1
|
@@ -15,7 +15,8 @@ import {
|
|
|
15
15
|
spawnScript,
|
|
16
16
|
str,
|
|
17
17
|
targetOf,
|
|
18
|
-
usageOut
|
|
18
|
+
usageOut,
|
|
19
|
+
writeInteractiveProgress
|
|
19
20
|
} from "../shared.js";
|
|
20
21
|
import {
|
|
21
22
|
RECOVERY_CODE,
|
|
@@ -33,6 +34,7 @@ import {
|
|
|
33
34
|
import { launchMobile } from "./mobile.js";
|
|
34
35
|
import { applyDeviceTargeting } from "../device-target.js";
|
|
35
36
|
import { acquireCheckoutLock } from "../../checkout-lock.js";
|
|
37
|
+
import { JsonStreamWriter } from "../../json-stream.js";
|
|
36
38
|
const LAUNCH_BOOLEANS = /* @__PURE__ */ new Set([
|
|
37
39
|
"build",
|
|
38
40
|
"watch",
|
|
@@ -40,30 +42,58 @@ const LAUNCH_BOOLEANS = /* @__PURE__ */ new Set([
|
|
|
40
42
|
"sidepanel",
|
|
41
43
|
"fullscreen",
|
|
42
44
|
"runway",
|
|
43
|
-
"json"
|
|
45
|
+
"json",
|
|
46
|
+
"jsonStream"
|
|
44
47
|
]);
|
|
45
48
|
const DEFAULT_EXTENSION_DAPP_URL = "https://metamask.github.io/test-dapp/";
|
|
46
49
|
async function handleLaunch(argv) {
|
|
47
50
|
const { options } = parseFlags(argv, LAUNCH_BOOLEANS);
|
|
48
51
|
const json = flag(options, "json");
|
|
52
|
+
const stream = new JsonStreamWriter("launch", flag(options, "jsonStream"));
|
|
53
|
+
const jsonOutput = json && !stream.enabled;
|
|
54
|
+
const restoreStdout = stream.isolateStdout();
|
|
49
55
|
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
56
|
try {
|
|
56
|
-
|
|
57
|
+
let exitCode;
|
|
58
|
+
if (!fs.existsSync(target)) {
|
|
59
|
+
exitCode = await handleLaunchLocked(argv, stream);
|
|
60
|
+
} else {
|
|
61
|
+
const lock = acquireCheckoutLock(target, "launch");
|
|
62
|
+
if ("message" in lock) {
|
|
63
|
+
const userAction = `wait for the current owner, or inspect ${lock.path} if its process has exited`;
|
|
64
|
+
stream.error({ code: "SANDBOX_BUSY", message: lock.message, userAction });
|
|
65
|
+
exitCode = checkoutBusyOut(jsonOutput, "launch", lock.message, lock.path);
|
|
66
|
+
} else {
|
|
67
|
+
try {
|
|
68
|
+
exitCode = await handleLaunchLocked(argv, stream);
|
|
69
|
+
} finally {
|
|
70
|
+
lock.release();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
stream.complete(exitCode === EXIT.ok ? "pass" : "fail", exitCode);
|
|
75
|
+
return exitCode;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
const exitCode = error !== null && typeof error === "object" && "exitCode" in error && typeof error.exitCode === "number" ? error.exitCode : EXIT.runtime;
|
|
78
|
+
stream.error({
|
|
79
|
+
code: exitCode === EXIT.usage ? "CLI_USAGE_ERROR" : "LAUNCH_FAILED",
|
|
80
|
+
message: error instanceof Error ? error.message : String(error),
|
|
81
|
+
userAction: `mm-harness doctor --target ${shellQuote(target)} --json`
|
|
82
|
+
});
|
|
83
|
+
stream.complete("fail", exitCode);
|
|
84
|
+
throw error;
|
|
57
85
|
} finally {
|
|
58
|
-
|
|
86
|
+
restoreStdout();
|
|
59
87
|
}
|
|
60
88
|
}
|
|
61
|
-
async function handleLaunchLocked(argv) {
|
|
89
|
+
async function handleLaunchLocked(argv, stream) {
|
|
62
90
|
const { positional, options } = parseFlags(argv, LAUNCH_BOOLEANS);
|
|
63
91
|
const json = flag(options, "json");
|
|
92
|
+
const jsonOutput = json && !stream.enabled;
|
|
93
|
+
const machine = json || stream.enabled;
|
|
64
94
|
const target = targetOf(options);
|
|
65
95
|
const heal = parseHeal(options, "auto");
|
|
66
|
-
if (typeof heal !== "string") return
|
|
96
|
+
if (typeof heal !== "string") return launchUsage(jsonOutput, stream, heal.error, "use --heal off|infra-only|auto");
|
|
67
97
|
const posToken = positional[0];
|
|
68
98
|
const mobileTargetToken = posToken === "ios" || posToken === "android" ? posToken : void 0;
|
|
69
99
|
const platformFlag = str(options, "platform");
|
|
@@ -72,15 +102,16 @@ async function handleLaunchLocked(argv) {
|
|
|
72
102
|
const adapterHint = mobileTarget ? "mobile" : void 0;
|
|
73
103
|
const adapter = resolveAdapter(options, target, adapterHint);
|
|
74
104
|
if (!adapter) {
|
|
75
|
-
return
|
|
105
|
+
return launchUsage(jsonOutput, stream, `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
|
|
76
106
|
}
|
|
107
|
+
stream.phase("resolve", { target, adapter, platform: mobileTarget ?? null });
|
|
77
108
|
if (adapter === "core") {
|
|
78
|
-
return
|
|
109
|
+
return launchUsage(jsonOutput, stream, "core is headless; there is nothing to launch.", "mm-harness verify");
|
|
79
110
|
}
|
|
80
111
|
if (adapter === "mobile" && !mobileTarget) {
|
|
81
|
-
return
|
|
82
|
-
|
|
83
|
-
|
|
112
|
+
return launchUsage(
|
|
113
|
+
jsonOutput,
|
|
114
|
+
stream,
|
|
84
115
|
"target is required for mobile.",
|
|
85
116
|
"mm-harness launch ios or mm-harness launch android"
|
|
86
117
|
);
|
|
@@ -90,15 +121,15 @@ async function handleLaunchLocked(argv) {
|
|
|
90
121
|
const wantWatch = flag(options, "watch");
|
|
91
122
|
const wantRunway = flag(options, "runway");
|
|
92
123
|
if (wantRunway && adapter !== "mobile") {
|
|
93
|
-
return
|
|
124
|
+
return launchUsage(jsonOutput, stream, "runway is mobile-only.", "drop --runway for the extension");
|
|
94
125
|
}
|
|
95
126
|
const displayMode = flag(options, "sidepanel") && !flag(options, "fullscreen") ? "sidepanel" : "fullscreen";
|
|
96
127
|
for (const portFlag of ["cdpPort", "watcherPort"]) {
|
|
97
128
|
const value = str(options, portFlag);
|
|
98
129
|
if (value !== void 0 && !/^\d+$/u.test(value)) {
|
|
99
|
-
return
|
|
100
|
-
|
|
101
|
-
|
|
130
|
+
return launchUsage(
|
|
131
|
+
jsonOutput,
|
|
132
|
+
stream,
|
|
102
133
|
`--${portFlag === "cdpPort" ? "cdp-port" : "watcher-port"} must be numeric (got: ${value}).`,
|
|
103
134
|
`pass a numeric port, e.g. --${portFlag === "cdpPort" ? "cdp-port 6663" : "watcher-port 8081"}`
|
|
104
135
|
);
|
|
@@ -106,7 +137,7 @@ async function handleLaunchLocked(argv) {
|
|
|
106
137
|
}
|
|
107
138
|
const envResult = applyLaunchEnvOverrides(options, adapter, mobileTarget, target);
|
|
108
139
|
if (envResult && "code" in envResult) {
|
|
109
|
-
return
|
|
140
|
+
return launchUsage(jsonOutput, stream, envResult.message, envResult.userAction);
|
|
110
141
|
}
|
|
111
142
|
const tier = wantBuild ? "build" : "quick";
|
|
112
143
|
if (adapter === "extension") {
|
|
@@ -115,44 +146,55 @@ async function handleLaunchLocked(argv) {
|
|
|
115
146
|
process.env.EXTENSION_START_URL = requestedUrl || DEFAULT_EXTENSION_DAPP_URL;
|
|
116
147
|
}
|
|
117
148
|
}
|
|
118
|
-
if (!
|
|
149
|
+
if (!machine && adapter === "extension") {
|
|
119
150
|
const modeNote = displayMode === "sidepanel" ? `sidepanel \xB7 dapp ${process.env.EXTENSION_START_URL ?? DEFAULT_EXTENSION_DAPP_URL}` : "fullscreen";
|
|
120
151
|
const workNote = wantWatch ? "watch only" : tier === "build" ? "clean build" : "quick reuse probe";
|
|
121
152
|
console.error(
|
|
122
153
|
`\u2192 extension launch \u2014 ${modeNote} \xB7 CDP :${process.env.CDP_PORT ?? "default"} \xB7 ${workNote}`
|
|
123
154
|
);
|
|
124
155
|
}
|
|
156
|
+
if (adapter === "mobile") {
|
|
157
|
+
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";
|
|
158
|
+
const watcherPort = str(options, "watcherPort") ?? process.env.WATCHER_PORT ?? "auto";
|
|
159
|
+
writeInteractiveProgress(
|
|
160
|
+
machine,
|
|
161
|
+
`\u2192 mobile launch \u2014 ${mobileTarget} \xB7 ${tier === "build" ? "native build" : "quick readiness"} \xB7 ${device} \xB7 Metro ${watcherPort === "auto" ? "auto" : `:${watcherPort}`}`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
125
164
|
const state = newHealState();
|
|
126
165
|
if (tier === "quick" && nativeInputsChanged(target, adapter)) {
|
|
127
|
-
return
|
|
128
|
-
|
|
129
|
-
|
|
166
|
+
return launchUsage(
|
|
167
|
+
jsonOutput,
|
|
168
|
+
stream,
|
|
130
169
|
"native build inputs changed since last build.",
|
|
131
170
|
`mm-harness launch ${adapter === "mobile" ? `${mobileTarget} ` : ""}--build`
|
|
132
171
|
);
|
|
133
172
|
}
|
|
134
|
-
|
|
173
|
+
stream.phase("install");
|
|
174
|
+
const ensured = await ensureOverlay(adapter, target, heal, state, machine);
|
|
135
175
|
if (!ensured.ok) {
|
|
136
|
-
return launchFail(
|
|
176
|
+
return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
|
|
137
177
|
code: "OVERLAY_INSTALL_FAILED",
|
|
138
178
|
message: ensured.error ?? "overlay install failed",
|
|
139
179
|
recoverable: false,
|
|
140
180
|
exitCode: EXIT.infra
|
|
141
181
|
});
|
|
142
182
|
}
|
|
183
|
+
stream.phase("healthcheck");
|
|
143
184
|
if (adapter === "extension") {
|
|
144
185
|
const willBuild = wantWatch || tier === "build" || !await extensionRuntimeReusable(target);
|
|
145
186
|
if (willBuild) {
|
|
146
187
|
const depsBlock = extensionDepsBlock(target);
|
|
147
188
|
if (depsBlock) {
|
|
148
189
|
if (heal === "off") {
|
|
149
|
-
return
|
|
190
|
+
return launchUsage(jsonOutput, stream, depsBlock.message, depsBlock.userAction);
|
|
150
191
|
}
|
|
151
192
|
const recoveryCode2 = "deps.installed";
|
|
152
193
|
state.attemptedRecoveries.push(recoveryCode2);
|
|
194
|
+
stream.phase("recover");
|
|
153
195
|
const repaired = await installExtensionDeps(target);
|
|
154
196
|
if (repaired.status !== 0 || extensionDepsBlock(target)) {
|
|
155
|
-
return launchFail(
|
|
197
|
+
return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
|
|
156
198
|
code: "DEPENDENCY_INSTALL_FAILED",
|
|
157
199
|
message: depsBlock.message,
|
|
158
200
|
recoverable: false,
|
|
@@ -166,12 +208,13 @@ async function handleLaunchLocked(argv) {
|
|
|
166
208
|
}
|
|
167
209
|
}
|
|
168
210
|
}
|
|
169
|
-
|
|
211
|
+
stream.phase("launch");
|
|
212
|
+
let attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, machine, displayMode);
|
|
170
213
|
if (attempt.status === 0) {
|
|
171
|
-
return await finishLaunch(
|
|
214
|
+
return await finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
|
|
172
215
|
}
|
|
173
216
|
if (heal === "off") {
|
|
174
|
-
return launchFail(
|
|
217
|
+
return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
|
|
175
218
|
code: "LAUNCH_FAILED",
|
|
176
219
|
message: `launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ""} failed (healing off)`,
|
|
177
220
|
recoverable: false,
|
|
@@ -181,12 +224,13 @@ async function handleLaunchLocked(argv) {
|
|
|
181
224
|
if (adapter === "extension" && tier === "quick" && extensionQuickReattachFailed(attempt.output)) {
|
|
182
225
|
const recoveryCode2 = "chrome.relaunched";
|
|
183
226
|
state.attemptedRecoveries.push(recoveryCode2);
|
|
184
|
-
|
|
227
|
+
stream.phase("recover");
|
|
228
|
+
const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, machine, displayMode);
|
|
185
229
|
if (rebuildAttempt.status === 0) {
|
|
186
230
|
state.recovered.push(recoveryCode2);
|
|
187
|
-
return await finishLaunch(
|
|
231
|
+
return await finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
|
|
188
232
|
}
|
|
189
|
-
return launchFail(
|
|
233
|
+
return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
|
|
190
234
|
code: "EXTENSION_RELAUNCH_FAILED",
|
|
191
235
|
message: "quick reattach failed, and clean relaunch failed too.",
|
|
192
236
|
recoverable: false,
|
|
@@ -195,7 +239,7 @@ async function handleLaunchLocked(argv) {
|
|
|
195
239
|
});
|
|
196
240
|
}
|
|
197
241
|
if (adapter === "mobile" && mobileProvisioningBlocked(attempt.output)) {
|
|
198
|
-
return launchFail(
|
|
242
|
+
return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
|
|
199
243
|
code: "MOBILE_PROVISION_REQUIRED",
|
|
200
244
|
message: `mobile ${mobileTarget ?? "runtime"} is not provisioned for this slot.`,
|
|
201
245
|
recoverable: false,
|
|
@@ -206,7 +250,7 @@ async function handleLaunchLocked(argv) {
|
|
|
206
250
|
}
|
|
207
251
|
const bound = checkHealBounds(target, attempt.output, state);
|
|
208
252
|
if (bound !== null) {
|
|
209
|
-
return launchFail(
|
|
253
|
+
return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
|
|
210
254
|
code: bound.code,
|
|
211
255
|
message: bound.message,
|
|
212
256
|
recoverable: false,
|
|
@@ -217,12 +261,13 @@ async function handleLaunchLocked(argv) {
|
|
|
217
261
|
}
|
|
218
262
|
const recoveryCode = RECOVERY_CODE[adapter];
|
|
219
263
|
state.attemptedRecoveries.push(recoveryCode);
|
|
220
|
-
|
|
264
|
+
stream.phase("recover");
|
|
265
|
+
attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, machine, displayMode);
|
|
221
266
|
if (attempt.status === 0) {
|
|
222
267
|
state.recovered.push(recoveryCode);
|
|
223
|
-
return await finishLaunch(
|
|
268
|
+
return await finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
|
|
224
269
|
}
|
|
225
|
-
return launchFail(
|
|
270
|
+
return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
|
|
226
271
|
code: "SAME_RECOVERY_TWICE",
|
|
227
272
|
message: `launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ""} failed again after ${recoveryCode} recovery \u2014 refusing to loop.`,
|
|
228
273
|
recoverable: false,
|
|
@@ -275,14 +320,16 @@ function applyLaunchEnvOverrides(options, adapter, mobileTarget, target) {
|
|
|
275
320
|
return {
|
|
276
321
|
ok: false,
|
|
277
322
|
code: "DEVICE_WRONG_PLATFORM",
|
|
278
|
-
message: `--device ${device} did not resolve to an Android device for launch android
|
|
323
|
+
message: `--device ${device} did not resolve to an Android device for launch android.`,
|
|
324
|
+
userAction: `mm-harness status --target ${shellQuote(target)} --all-devices --json`
|
|
279
325
|
};
|
|
280
326
|
}
|
|
281
327
|
if (mobileTarget === "ios" && device && !process.env.IOS_SIMULATOR) {
|
|
282
328
|
return {
|
|
283
329
|
ok: false,
|
|
284
330
|
code: "DEVICE_WRONG_PLATFORM",
|
|
285
|
-
message: `--device ${device} did not resolve to an iOS simulator for launch ios
|
|
331
|
+
message: `--device ${device} did not resolve to an iOS simulator for launch ios.`,
|
|
332
|
+
userAction: `mm-harness status --target ${shellQuote(target)} --all-devices --json`
|
|
286
333
|
};
|
|
287
334
|
}
|
|
288
335
|
}
|
|
@@ -328,26 +375,29 @@ async function executeComposition(adapter, mobileTarget, tier, wantWatch, target
|
|
|
328
375
|
}
|
|
329
376
|
return launchExtension(target, tier, wantWatch, displayMode);
|
|
330
377
|
}
|
|
331
|
-
async function finishLaunch(
|
|
378
|
+
async function finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, sidepanelRecoveryAllowed = true) {
|
|
332
379
|
if (adapter === "extension" && displayMode === "sidepanel") {
|
|
333
380
|
const sidepanelSh = path.join(runnerDir, "adapters/extension/sidepanel-toggle.sh");
|
|
334
381
|
const sidepanelArgs = ["open"];
|
|
335
382
|
if (process.env.CDP_PORT) sidepanelArgs.push("--cdp-port", process.env.CDP_PORT);
|
|
336
|
-
if (!
|
|
383
|
+
if (!machine) {
|
|
337
384
|
console.error(
|
|
338
385
|
`[sidepanel] opening panel beside ${process.env.EXTENSION_START_URL ?? DEFAULT_EXTENSION_DAPP_URL}`
|
|
339
386
|
);
|
|
340
387
|
}
|
|
341
|
-
const sidepanel = spawnScript(sidepanelSh, sidepanelArgs, target,
|
|
388
|
+
const sidepanel = spawnScript(sidepanelSh, sidepanelArgs, target, machine, { REPO: target });
|
|
342
389
|
if (sidepanel.status !== 0) {
|
|
343
390
|
const recoveryCode = "chrome.relaunched";
|
|
344
391
|
if (sidepanelRecoveryAllowed && tier === "quick" && extensionRuntimeBlocked(sidepanel.output) && !state.attemptedRecoveries.includes(recoveryCode)) {
|
|
345
392
|
state.attemptedRecoveries.push(recoveryCode);
|
|
346
|
-
|
|
393
|
+
stream.phase("recover");
|
|
394
|
+
const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, machine, displayMode);
|
|
347
395
|
if (rebuildAttempt.status === 0) {
|
|
348
396
|
state.recovered.push(recoveryCode);
|
|
349
397
|
return await finishLaunch(
|
|
350
|
-
|
|
398
|
+
jsonOutput,
|
|
399
|
+
machine,
|
|
400
|
+
stream,
|
|
351
401
|
adapter,
|
|
352
402
|
mobileTarget,
|
|
353
403
|
tier,
|
|
@@ -359,7 +409,7 @@ async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, targ
|
|
|
359
409
|
false
|
|
360
410
|
);
|
|
361
411
|
}
|
|
362
|
-
return launchFail(
|
|
412
|
+
return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
|
|
363
413
|
code: "EXTENSION_RELAUNCH_FAILED",
|
|
364
414
|
message: "sidepanel open found a blocked extension runtime, and clean relaunch failed too.",
|
|
365
415
|
recoverable: false,
|
|
@@ -367,7 +417,7 @@ async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, targ
|
|
|
367
417
|
originalError: rebuildAttempt.output.trim() || void 0
|
|
368
418
|
});
|
|
369
419
|
}
|
|
370
|
-
return launchFail(
|
|
420
|
+
return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
|
|
371
421
|
code: "SIDEPANEL_OPEN_FAILED",
|
|
372
422
|
message: "app launched but opening the side panel failed.",
|
|
373
423
|
recoverable: false,
|
|
@@ -378,15 +428,38 @@ async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, targ
|
|
|
378
428
|
}
|
|
379
429
|
}
|
|
380
430
|
if (wantVerify) {
|
|
431
|
+
stream.phase("verify");
|
|
381
432
|
const verifyArgs = ["verify", "--adapter", adapter, "--target", target];
|
|
382
433
|
if (adapter === "mobile" && mobileTarget) verifyArgs.push("--platform", mobileTarget);
|
|
383
|
-
if (
|
|
384
|
-
|
|
434
|
+
if (machine) verifyArgs.push("--json");
|
|
435
|
+
const exitCode = await handleHarness(verifyArgs);
|
|
436
|
+
if (stream.enabled) {
|
|
437
|
+
for (const mutation of state.mutations) stream.mutation(mutation);
|
|
438
|
+
for (const recovery of state.recovered) stream.recovery(recovery);
|
|
439
|
+
stream.complete(exitCode === EXIT.ok ? "pass" : "fail", exitCode, {
|
|
440
|
+
adapter,
|
|
441
|
+
platform: mobileTarget ?? (adapter === "extension" ? displayMode : null),
|
|
442
|
+
tier,
|
|
443
|
+
recovered: state.recovered,
|
|
444
|
+
mutations: state.mutations
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
return exitCode;
|
|
385
448
|
}
|
|
386
|
-
return launchPass(
|
|
449
|
+
return launchPass(jsonOutput, stream, adapter, mobileTarget, tier, displayMode, state);
|
|
387
450
|
}
|
|
388
|
-
function launchPass(json, adapter, mobileTarget, tier, displayMode, state) {
|
|
389
|
-
if (
|
|
451
|
+
function launchPass(json, stream, adapter, mobileTarget, tier, displayMode, state) {
|
|
452
|
+
if (stream.enabled) {
|
|
453
|
+
for (const mutation of state.mutations) stream.mutation(mutation);
|
|
454
|
+
for (const recovery of state.recovered) stream.recovery(recovery);
|
|
455
|
+
stream.complete("pass", EXIT.ok, {
|
|
456
|
+
adapter,
|
|
457
|
+
platform: mobileTarget ?? (adapter === "extension" ? displayMode : null),
|
|
458
|
+
tier,
|
|
459
|
+
recovered: state.recovered,
|
|
460
|
+
mutations: state.mutations
|
|
461
|
+
});
|
|
462
|
+
} else if (json) {
|
|
390
463
|
console.log(
|
|
391
464
|
JSON.stringify(
|
|
392
465
|
{
|
|
@@ -424,8 +497,32 @@ function launchDeviceLabel(mobileTarget) {
|
|
|
424
497
|
}
|
|
425
498
|
return process.env.IOS_SIMULATOR || process.env.ADB_SERIAL || "booted device";
|
|
426
499
|
}
|
|
427
|
-
function
|
|
428
|
-
|
|
500
|
+
function launchUsage(json, stream, message, userAction) {
|
|
501
|
+
stream.error({ code: "USAGE", message, userAction });
|
|
502
|
+
return usageOut(json, "launch", message, userAction);
|
|
503
|
+
}
|
|
504
|
+
function launchFail(json, stream, adapter, mobileTarget, tier, state, target, failure) {
|
|
505
|
+
const userAction = failure.userAction ?? `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
|
|
506
|
+
if (stream.enabled) {
|
|
507
|
+
for (const mutation of state.mutations) stream.mutation(mutation);
|
|
508
|
+
for (const recovery of state.recovered) stream.recovery(recovery);
|
|
509
|
+
stream.error({
|
|
510
|
+
code: failure.code,
|
|
511
|
+
message: failure.message,
|
|
512
|
+
retryable: failure.recoverable,
|
|
513
|
+
userAction,
|
|
514
|
+
originalError: failure.originalError ?? null
|
|
515
|
+
});
|
|
516
|
+
stream.complete("fail", failure.exitCode, {
|
|
517
|
+
adapter,
|
|
518
|
+
platform: mobileTarget ?? null,
|
|
519
|
+
tier,
|
|
520
|
+
recovered: state.recovered,
|
|
521
|
+
mutations: state.mutations,
|
|
522
|
+
recoverable: failure.recoverable,
|
|
523
|
+
attemptedRecoveries: state.attemptedRecoveries
|
|
524
|
+
});
|
|
525
|
+
} else if (json) {
|
|
429
526
|
console.log(
|
|
430
527
|
JSON.stringify(
|
|
431
528
|
{
|
|
@@ -445,7 +542,7 @@ function launchFail(json, adapter, mobileTarget, tier, state, failure) {
|
|
|
445
542
|
code: failure.code,
|
|
446
543
|
message: failure.message,
|
|
447
544
|
retryable: failure.recoverable,
|
|
448
|
-
userAction
|
|
545
|
+
userAction,
|
|
449
546
|
originalError: failure.originalError ?? null
|
|
450
547
|
}
|
|
451
548
|
},
|
|
@@ -458,8 +555,8 @@ function launchFail(json, adapter, mobileTarget, tier, state, failure) {
|
|
|
458
555
|
`\u2717 launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ""} failed
|
|
459
556
|
${failure.message}` + (failure.originalError ? `
|
|
460
557
|
--- original failure ---
|
|
461
|
-
${failure.originalError}` : "") +
|
|
462
|
-
Next: ${
|
|
558
|
+
${failure.originalError}` : "") + `
|
|
559
|
+
Next: ${userAction}`
|
|
463
560
|
);
|
|
464
561
|
}
|
|
465
562
|
return failure.exitCode;
|
|
@@ -16,42 +16,125 @@ async function handleManifest({ options }) {
|
|
|
16
16
|
else console.log(actionManifestPath);
|
|
17
17
|
return 0;
|
|
18
18
|
}
|
|
19
|
-
async function handleActions({ options }) {
|
|
19
|
+
async function handleActions({ options, positional }) {
|
|
20
20
|
const { adapter } = resolveAdapter(options);
|
|
21
21
|
const manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
|
|
22
22
|
await validateManifest(manifest);
|
|
23
23
|
const json = optionFlag(options, "json");
|
|
24
24
|
const action = optionString(options, "action");
|
|
25
|
+
const query = positional[0]?.trim();
|
|
26
|
+
const category = optionString(options, "category")?.toLowerCase();
|
|
27
|
+
const categoriesOnly = optionFlag(options, "categories");
|
|
25
28
|
const all = describeManifestActions(manifest);
|
|
26
|
-
const
|
|
29
|
+
const categories = summarizeActionCategories(all);
|
|
30
|
+
if (categoriesOnly && (action || category)) {
|
|
31
|
+
const message = "--categories cannot be combined with --action or --category.";
|
|
32
|
+
const userAction = `mm-harness actions --adapter ${adapter} --categories`;
|
|
33
|
+
if (json) {
|
|
34
|
+
console.log(JSON.stringify({ schemaVersion: 1, command: "actions", adapter, error: { code: "ACTION_FILTER_CONFLICT", message, userAction } }, null, 2));
|
|
35
|
+
} else {
|
|
36
|
+
console.error(`\u2717 mm-harness actions: ${message}
|
|
37
|
+
Next: ${userAction}`);
|
|
38
|
+
}
|
|
39
|
+
return EXIT.usage;
|
|
40
|
+
}
|
|
41
|
+
if (categoriesOnly) {
|
|
42
|
+
if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "actions", adapter, categories }, null, 2));
|
|
43
|
+
else for (const entry of categories) console.log(`${entry.name} (${entry.count})`);
|
|
44
|
+
return EXIT.ok;
|
|
45
|
+
}
|
|
46
|
+
const categoryActions = category ? all.filter((entry) => entry.category === category) : all;
|
|
47
|
+
if (category && categoryActions.length === 0) {
|
|
48
|
+
const message = `no action category matches "${category}" for the ${adapter} adapter.`;
|
|
49
|
+
const userAction = `mm-harness actions --adapter ${adapter} --categories`;
|
|
50
|
+
if (json) {
|
|
51
|
+
console.log(JSON.stringify({ schemaVersion: 1, command: "actions", adapter, category, availableCategories: categories, error: { code: "ACTION_CATEGORY_UNKNOWN", message, userAction } }, null, 2));
|
|
52
|
+
} else {
|
|
53
|
+
console.error(`\u2717 mm-harness actions: ${message}
|
|
54
|
+
Next: ${userAction}`);
|
|
55
|
+
}
|
|
56
|
+
return EXIT.usage;
|
|
57
|
+
}
|
|
58
|
+
const actions = action ? fuzzyResolveActions(categoryActions, action) : query ? searchActions(categoryActions, query) : categoryActions;
|
|
27
59
|
if (action && actions.length === 0) {
|
|
28
60
|
const message = `no action matches "${action}" for the ${adapter} adapter.`;
|
|
29
|
-
const userAction = `mm-harness actions --adapter ${adapter}
|
|
61
|
+
const userAction = `mm-harness actions --adapter ${adapter}`;
|
|
62
|
+
if (json) {
|
|
63
|
+
console.log(JSON.stringify({ schemaVersion: 1, command: "actions", adapter, action, category, error: { code: "ACTION_UNKNOWN", message, userAction } }, null, 2));
|
|
64
|
+
} else {
|
|
65
|
+
console.error(`\u2717 mm-harness actions: ${message}
|
|
66
|
+
Next: ${userAction}`);
|
|
67
|
+
}
|
|
68
|
+
return EXIT.usage;
|
|
69
|
+
}
|
|
70
|
+
if (query && actions.length === 0) {
|
|
71
|
+
const message = `no action matches search "${query}" for the ${adapter} adapter.`;
|
|
72
|
+
const userAction = `mm-harness actions --adapter ${adapter} --categories`;
|
|
30
73
|
if (json) {
|
|
31
|
-
console.log(JSON.stringify({
|
|
74
|
+
console.log(JSON.stringify({
|
|
75
|
+
schemaVersion: 1,
|
|
76
|
+
command: "actions",
|
|
77
|
+
adapter,
|
|
78
|
+
query,
|
|
79
|
+
category,
|
|
80
|
+
error: { code: "ACTION_SEARCH_EMPTY", message, userAction }
|
|
81
|
+
}, null, 2));
|
|
32
82
|
} else {
|
|
33
83
|
console.error(`\u2717 mm-harness actions: ${message}
|
|
34
84
|
Next: ${userAction}`);
|
|
35
85
|
}
|
|
36
86
|
return EXIT.usage;
|
|
37
87
|
}
|
|
88
|
+
const relatedActions = action && actions.length === 1 ? findRelatedActions(all, actions[0]) : void 0;
|
|
38
89
|
if (json) {
|
|
39
|
-
console.log(JSON.stringify({
|
|
90
|
+
console.log(JSON.stringify({
|
|
91
|
+
schemaVersion: 1,
|
|
92
|
+
command: "actions",
|
|
93
|
+
adapter,
|
|
94
|
+
...query ? { query } : {},
|
|
95
|
+
category,
|
|
96
|
+
actions,
|
|
97
|
+
...relatedActions ? { relatedActions } : {}
|
|
98
|
+
}, null, 2));
|
|
40
99
|
} else {
|
|
41
100
|
for (const entry of actions) {
|
|
42
101
|
const fields = entry.fields.length ? ` fields=${entry.fields.join(",")}` : "";
|
|
43
102
|
console.log(`${entry.name} (${entry.kind})${fields}${entry.description ? ` \u2014 ${entry.description}` : ""}`);
|
|
44
103
|
}
|
|
104
|
+
if (relatedActions) console.log(`Related: ${relatedActions.join(", ")}`);
|
|
45
105
|
}
|
|
46
106
|
return 0;
|
|
47
107
|
}
|
|
48
108
|
function fuzzyResolveActions(entries, query) {
|
|
49
109
|
const exactFull = entries.filter((e) => e.name === query);
|
|
50
110
|
if (exactFull.length > 0) return exactFull;
|
|
51
|
-
const
|
|
52
|
-
const exactSegment = entries.filter((e) =>
|
|
111
|
+
const finalSegment2 = (name) => name.split(".").pop() ?? name;
|
|
112
|
+
const exactSegment = entries.filter((e) => finalSegment2(e.name) === query);
|
|
53
113
|
if (exactSegment.length > 0) return exactSegment;
|
|
54
|
-
return entries.filter((e) =>
|
|
114
|
+
return entries.filter((e) => finalSegment2(e.name).includes(query));
|
|
115
|
+
}
|
|
116
|
+
function searchActions(entries, query) {
|
|
117
|
+
const terms = searchTerms(query);
|
|
118
|
+
if (terms.length === 0) return [];
|
|
119
|
+
return entries.map((entry) => {
|
|
120
|
+
const scores = terms.map((term) => actionSearchScore(entry, term));
|
|
121
|
+
return { entry, scores, score: scores.reduce((total, value) => total + value, 0) };
|
|
122
|
+
}).filter(({ score, scores }) => score > 0 && scores.every((value) => value > 0)).sort((left, right) => right.score - left.score || left.entry.name.localeCompare(right.entry.name)).map(({ entry }) => entry);
|
|
123
|
+
}
|
|
124
|
+
function findRelatedActions(entries, selected, limit = 5) {
|
|
125
|
+
const selectedNameTerms = searchTerms(finalSegment(selected.name).replaceAll("_", " "));
|
|
126
|
+
return entries.filter((entry) => entry.name !== selected.name).map((entry) => {
|
|
127
|
+
const nameTerms = new Set(searchTerms(finalSegment(entry.name).replaceAll("_", " ")));
|
|
128
|
+
const sharedScore = selectedNameTerms.filter((term) => nameTerms.has(term)).reduce((score2, term) => score2 + (GENERIC_OPERATION_TERMS.has(term) ? 5 : 30), 0);
|
|
129
|
+
const score = (entry.category === selected.category ? 100 : 0) + sharedScore;
|
|
130
|
+
return { name: entry.name, score };
|
|
131
|
+
}).filter(({ score }) => score > 0).sort((left, right) => right.score - left.score || left.name.localeCompare(right.name)).slice(0, limit).map(({ name }) => name);
|
|
132
|
+
}
|
|
133
|
+
const GENERIC_OPERATION_TERMS = /* @__PURE__ */ new Set(["assert", "call", "close", "ensure", "place", "read", "start", "teardown"]);
|
|
134
|
+
function summarizeActionCategories(actions) {
|
|
135
|
+
const counts = /* @__PURE__ */ new Map();
|
|
136
|
+
for (const action of actions) counts.set(action.category, (counts.get(action.category) ?? 0) + 1);
|
|
137
|
+
return [...counts.entries()].map(([name, count]) => ({ name, count })).sort((left, right) => left.name.localeCompare(right.name));
|
|
55
138
|
}
|
|
56
139
|
function describeManifestActions(manifest) {
|
|
57
140
|
const manifestRecord = isRecord(manifest) ? manifest : {};
|
|
@@ -80,15 +163,70 @@ function describeManifestAction(name, kind, metadata) {
|
|
|
80
163
|
return {
|
|
81
164
|
name,
|
|
82
165
|
kind,
|
|
166
|
+
category: actionCategory(name, record.category),
|
|
83
167
|
description: typeof record.description === "string" ? record.description : "",
|
|
84
168
|
fields: properties,
|
|
85
169
|
schema,
|
|
86
170
|
examples: record.examples
|
|
87
171
|
};
|
|
88
172
|
}
|
|
173
|
+
function actionCategory(name, configured) {
|
|
174
|
+
if (typeof configured === "string" && configured.trim()) return configured.trim().toLowerCase();
|
|
175
|
+
const segments = name.split(".");
|
|
176
|
+
if (segments[0] === "metamask" && segments.length > 2) return segments[1] ?? "metamask";
|
|
177
|
+
if (segments[0] === "app" || segments[0] === "cdp") return "runtime";
|
|
178
|
+
if (segments[0] === "ui") return "ui";
|
|
179
|
+
if (name.startsWith("assert_")) return "assertion";
|
|
180
|
+
if (name === "watch_logs" || name === "index_artifacts") return "evidence";
|
|
181
|
+
if (name === "command" || name === "wait" || name === "call" || name === "end") return "control";
|
|
182
|
+
return segments.length > 1 ? segments[0] || "utility" : "utility";
|
|
183
|
+
}
|
|
184
|
+
function actionSearchScore(entry, term) {
|
|
185
|
+
const name = entry.name.toLowerCase();
|
|
186
|
+
const segment = finalSegment(name);
|
|
187
|
+
const nameTerms = searchTerms(name.replaceAll(".", " ").replaceAll("_", " "));
|
|
188
|
+
const fields = entry.fields.map((field) => field.toLowerCase());
|
|
189
|
+
const description = entry.description.toLowerCase();
|
|
190
|
+
if (name === term) return 120;
|
|
191
|
+
if (segment === term) return 110;
|
|
192
|
+
if (entry.category === term) return 100;
|
|
193
|
+
if (fields.includes(term)) return 90;
|
|
194
|
+
if (nameTerms.includes(term)) return 80;
|
|
195
|
+
if (name.includes(term)) return 70;
|
|
196
|
+
if (fields.some((field) => field.includes(term))) return 60;
|
|
197
|
+
if (description.includes(term)) return 50;
|
|
198
|
+
if ([...nameTerms, ...fields].some((candidate) => fuzzyTermMatch(term, candidate))) return 40;
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
function fuzzyTermMatch(term, candidate) {
|
|
202
|
+
if (term.length < 4 || candidate.length < 4) return false;
|
|
203
|
+
const threshold = Math.max(1, Math.floor(Math.max(term.length, candidate.length) / 4));
|
|
204
|
+
return levenshtein(term, candidate) <= threshold;
|
|
205
|
+
}
|
|
206
|
+
function searchTerms(value) {
|
|
207
|
+
return value.toLowerCase().match(/[a-z0-9]+/gu) ?? [];
|
|
208
|
+
}
|
|
209
|
+
function finalSegment(name) {
|
|
210
|
+
return name.split(".").pop() ?? name;
|
|
211
|
+
}
|
|
212
|
+
function levenshtein(left, right) {
|
|
213
|
+
const prior = Array.from({ length: right.length + 1 }, (_, index) => index);
|
|
214
|
+
for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
|
|
215
|
+
const current = [leftIndex];
|
|
216
|
+
for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
|
|
217
|
+
const substitution = (prior[rightIndex - 1] ?? 0) + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1);
|
|
218
|
+
current[rightIndex] = Math.min((current[rightIndex - 1] ?? 0) + 1, (prior[rightIndex] ?? 0) + 1, substitution);
|
|
219
|
+
}
|
|
220
|
+
for (let index = 0; index < current.length; index += 1) prior[index] = current[index] ?? 0;
|
|
221
|
+
}
|
|
222
|
+
return prior[right.length] ?? right.length;
|
|
223
|
+
}
|
|
89
224
|
export {
|
|
90
225
|
describeManifestActions,
|
|
226
|
+
findRelatedActions,
|
|
91
227
|
fuzzyResolveActions,
|
|
92
228
|
handleActions,
|
|
93
|
-
handleManifest
|
|
229
|
+
handleManifest,
|
|
230
|
+
searchActions,
|
|
231
|
+
summarizeActionCategories
|
|
94
232
|
};
|
|
@@ -19,12 +19,14 @@ function parseArgs(argv, command) {
|
|
|
19
19
|
const options = {};
|
|
20
20
|
const booleanOptions = /* @__PURE__ */ new Set([
|
|
21
21
|
"json",
|
|
22
|
+
"jsonStream",
|
|
22
23
|
"launchExistingDist",
|
|
23
24
|
"startWatch",
|
|
24
25
|
"record",
|
|
25
26
|
"plan",
|
|
26
27
|
"list",
|
|
27
28
|
"raw",
|
|
29
|
+
"categories",
|
|
28
30
|
"fix",
|
|
29
31
|
"force",
|
|
30
32
|
"resolveOnly",
|