@deeeed/metamask-harness 0.14.0 → 0.14.2
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 +31 -0
- package/adapters/extension/inject.mjs +1 -0
- package/adapters/extension/launch-browser.cjs +15 -3
- package/adapters/extension/lib/extension-id.cjs +36 -0
- package/adapters/extension/live.sh +5 -3
- package/adapters/extension/readiness.mjs +48 -0
- package/adapters/extension/reattach.sh +163 -55
- package/adapters/extension/sidepanel-toggle.sh +96 -24
- package/adapters/extension/verify.sh +11 -0
- package/adapters/extension/wallet-fixture-state.cjs +6 -11
- package/adapters/mobile/open-device.sh +32 -5
- package/adapters/shared/resolve-slot-ports-core.mjs +14 -4
- package/dist/adapters/core/surface.js +1 -0
- package/dist/adapters/extension/runtime.js +14 -5
- package/dist/adapters/extension/surface.js +1 -0
- package/dist/adapters/mobile/provision.js +49 -4
- package/dist/adapters/mobile/surface.js +1 -0
- package/dist/adapters/slot-ports.js +11 -4
- package/dist/cli.js +4 -0
- package/dist/commands/call.js +19 -2
- package/dist/commands/check.js +326 -0
- package/dist/commands/core-readiness.js +75 -0
- package/dist/commands/device-target.js +113 -10
- package/dist/commands/doctor.js +30 -18
- package/dist/commands/launch/extension.js +105 -9
- package/dist/commands/launch/index.js +116 -15
- package/dist/commands/mobile-device-view.js +140 -0
- package/dist/commands/parse-args.js +4 -1
- package/dist/commands/recipe-quality.js +6 -2
- package/dist/commands/run-engine.js +28 -2
- package/dist/commands/run-report.js +115 -0
- package/dist/commands/run.js +113 -8
- package/dist/commands/shared.js +2 -1
- package/dist/commands/status-probe.js +9 -3
- package/dist/commands/status.js +40 -60
- package/dist/live-adapter-contract.js +5 -1
- package/dist/mm-harness-cli.js +61 -22
- package/docs/CLI-SPEC.md +25 -0
- package/library/actions/extension/platform/cdp.mjs +7 -4
- package/package.json +4 -4
|
@@ -4,7 +4,9 @@ import fs from "node:fs";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { depsCheck } from "@farmslot/recipe-harness/runtime/deps-readiness";
|
|
6
6
|
import { recipeHarnessPath, recipeRuntimeDir, runnerDir } from "../../paths.js";
|
|
7
|
+
import { extensionIdFromKey } from "../../adapters/extension/extension-id.js";
|
|
7
8
|
import { isExtensionDistStale } from "../../adapters/extension/runtime-decision.js";
|
|
9
|
+
import { checkExtensionRuntimeHealth } from "../../adapters/extension/runtime.js";
|
|
8
10
|
import { stopExtensionWatcher } from "../../adapters/slot-ports.js";
|
|
9
11
|
import { spawnScriptStreaming } from "../shared.js";
|
|
10
12
|
function extensionDepsBlock(target) {
|
|
@@ -22,7 +24,7 @@ function extensionDepsBlock(target) {
|
|
|
22
24
|
}
|
|
23
25
|
return null;
|
|
24
26
|
}
|
|
25
|
-
async function launchExtension(target, tier, wantWatch) {
|
|
27
|
+
async function launchExtension(target, tier, wantWatch, displayMode = "fullscreen") {
|
|
26
28
|
if (wantWatch) {
|
|
27
29
|
const startWatchSh = path.join(runnerDir, "adapters/extension/start-watch.sh");
|
|
28
30
|
const watchArgs = ["--target", target];
|
|
@@ -34,29 +36,53 @@ async function launchExtension(target, tier, wantWatch) {
|
|
|
34
36
|
return extensionRebuild(target);
|
|
35
37
|
}
|
|
36
38
|
if (await extensionRuntimeReusable(target)) {
|
|
37
|
-
return extensionReattach(target);
|
|
39
|
+
return extensionReattach(target, displayMode);
|
|
38
40
|
}
|
|
39
41
|
return extensionRebuild(target);
|
|
40
42
|
}
|
|
41
43
|
async function extensionRuntimeReusable(target) {
|
|
42
|
-
const watcherPort = process.env.WATCHER_PORT;
|
|
43
44
|
const cdpPort = process.env.CDP_PORT;
|
|
44
|
-
if (!
|
|
45
|
-
if (!
|
|
45
|
+
if (!cdpPort) return false;
|
|
46
|
+
if (!cdpOwnedByExpectedRuntime(cdpPort, target)) return false;
|
|
46
47
|
if (!await cdpVersionReachable(cdpPort)) return false;
|
|
48
|
+
if (!await cdpHasExpectedExtensionTarget(cdpPort, target)) return false;
|
|
49
|
+
if (!await cdpRuntimeHealthy(cdpPort, target)) return false;
|
|
47
50
|
if (isExtensionDistStale(target)) return false;
|
|
48
51
|
return true;
|
|
49
52
|
}
|
|
50
|
-
function
|
|
53
|
+
function expectedChromeProfile(target) {
|
|
54
|
+
const profileName = process.env.RECIPE_CHROME_PROFILE_NAME || "chrome-profile";
|
|
55
|
+
return path.resolve(process.env.CHROME_USER_DATA_DIR || path.join(target, recipeRuntimeDir(), profileName));
|
|
56
|
+
}
|
|
57
|
+
function expectedRuntimeDist(target) {
|
|
58
|
+
return path.resolve(path.join(target, recipeRuntimeDir(), process.env.RECIPE_RUNTIME_DIST_DIR || "runtime-dist"));
|
|
59
|
+
}
|
|
60
|
+
function cdpOwnedByExpectedRuntime(port, target) {
|
|
61
|
+
let pids = [];
|
|
51
62
|
try {
|
|
52
63
|
const out = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
|
|
53
64
|
encoding: "utf8",
|
|
54
65
|
stdio: ["ignore", "pipe", "ignore"]
|
|
55
66
|
});
|
|
56
|
-
|
|
67
|
+
pids = out.split(/\s+/u).filter((value) => /^\d+$/u.test(value));
|
|
57
68
|
} catch {
|
|
58
69
|
return false;
|
|
59
70
|
}
|
|
71
|
+
const profile = expectedChromeProfile(target);
|
|
72
|
+
const runtimeDist = expectedRuntimeDist(target);
|
|
73
|
+
for (const pid of pids) {
|
|
74
|
+
try {
|
|
75
|
+
const command = execFileSync("ps", ["-ww", "-o", "command=", "-p", pid], {
|
|
76
|
+
encoding: "utf8",
|
|
77
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
78
|
+
});
|
|
79
|
+
if (command.includes(`--user-data-dir=${profile}`) && (command.includes(`--load-extension=${runtimeDist}`) || command.includes(`--disable-extensions-except=${runtimeDist}`))) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
60
86
|
}
|
|
61
87
|
function cdpVersionReachable(port) {
|
|
62
88
|
return new Promise((resolve) => {
|
|
@@ -75,9 +101,79 @@ function cdpVersionReachable(port) {
|
|
|
75
101
|
request.on("error", () => resolve(false));
|
|
76
102
|
});
|
|
77
103
|
}
|
|
78
|
-
|
|
104
|
+
function expectedExtensionId(target) {
|
|
105
|
+
const runtimeDir = recipeRuntimeDir();
|
|
106
|
+
try {
|
|
107
|
+
const manifest = JSON.parse(
|
|
108
|
+
fs.readFileSync(path.join(target, runtimeDir, process.env.RECIPE_RUNTIME_DIST_DIR || "runtime-dist", "manifest.json"), "utf8")
|
|
109
|
+
);
|
|
110
|
+
if (typeof manifest.key === "string" && manifest.key) return extensionIdFromKey(manifest.key);
|
|
111
|
+
} catch {
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const marker = fs.readFileSync(path.join(target, runtimeDir, "extension.id"), "utf8").trim();
|
|
115
|
+
return /^[a-p]{32}$/u.test(marker) ? marker : null;
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function cdpHasExpectedExtensionTarget(port, target) {
|
|
121
|
+
return new Promise((resolve) => {
|
|
122
|
+
const request = http.get(
|
|
123
|
+
{ host: "127.0.0.1", port: Number(port), path: "/json/list", timeout: 2e3 },
|
|
124
|
+
(response) => {
|
|
125
|
+
let body = "";
|
|
126
|
+
response.setEncoding("utf8");
|
|
127
|
+
response.on("data", (chunk) => {
|
|
128
|
+
body += chunk;
|
|
129
|
+
});
|
|
130
|
+
response.on("end", () => {
|
|
131
|
+
if ((response.statusCode ?? 0) < 200 || (response.statusCode ?? 0) >= 300) {
|
|
132
|
+
resolve(false);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
let targets;
|
|
136
|
+
try {
|
|
137
|
+
targets = JSON.parse(body);
|
|
138
|
+
} catch {
|
|
139
|
+
resolve(false);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (!Array.isArray(targets)) {
|
|
143
|
+
resolve(false);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const expectedId = expectedExtensionId(target);
|
|
147
|
+
if (!expectedId) {
|
|
148
|
+
resolve(false);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
resolve(targets.some((entry) => {
|
|
152
|
+
if (!entry || typeof entry !== "object") return false;
|
|
153
|
+
const candidate = entry;
|
|
154
|
+
return typeof candidate.url === "string" && candidate.url.startsWith(`chrome-extension://${expectedId}/`);
|
|
155
|
+
}));
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
);
|
|
159
|
+
request.on("timeout", () => {
|
|
160
|
+
request.destroy();
|
|
161
|
+
resolve(false);
|
|
162
|
+
});
|
|
163
|
+
request.on("error", () => resolve(false));
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
async function cdpRuntimeHealthy(port, target) {
|
|
167
|
+
try {
|
|
168
|
+
const report = await checkExtensionRuntimeHealth(target, Number(port), { pageMode: "home-or-sidepanel" });
|
|
169
|
+
return report.status === "PASS";
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async function extensionReattach(target, displayMode = "fullscreen") {
|
|
79
175
|
const reattachScript = recipeHarnessPath(target, "extension", "scripts", "reattach.sh");
|
|
80
|
-
const reattachArgs = ["--target", target];
|
|
176
|
+
const reattachArgs = ["--target", target, "--display-mode", displayMode];
|
|
81
177
|
if (process.env.CDP_PORT) reattachArgs.push("--cdp-port", process.env.CDP_PORT);
|
|
82
178
|
if (process.env.WATCHER_PORT) reattachArgs.push("--watcher-port", process.env.WATCHER_PORT);
|
|
83
179
|
if (process.env.EXTENSION_START_URL) reattachArgs.push("--start-url", process.env.EXTENSION_START_URL);
|
|
@@ -35,6 +35,7 @@ const LAUNCH_BOOLEANS = /* @__PURE__ */ new Set([
|
|
|
35
35
|
"runway",
|
|
36
36
|
"json"
|
|
37
37
|
]);
|
|
38
|
+
const DEFAULT_EXTENSION_DAPP_URL = "https://metamask.github.io/test-dapp/";
|
|
38
39
|
async function handleLaunch(argv) {
|
|
39
40
|
const { positional, options } = parseFlags(argv, LAUNCH_BOOLEANS);
|
|
40
41
|
const json = flag(options, "json");
|
|
@@ -85,10 +86,20 @@ async function handleLaunch(argv) {
|
|
|
85
86
|
if (envResult && "code" in envResult) {
|
|
86
87
|
return usageOut(json, "launch", envResult.message, "mm-harness launch android --device <adb-serial|device-name>");
|
|
87
88
|
}
|
|
88
|
-
if (adapter === "extension" && typeof options["url"] === "string" && options["url"]) {
|
|
89
|
-
process.env.EXTENSION_START_URL = options["url"];
|
|
90
|
-
}
|
|
91
89
|
const tier = wantVerify ? "verify" : wantBuild ? "build" : "quick";
|
|
90
|
+
if (adapter === "extension") {
|
|
91
|
+
const requestedUrl = typeof options["url"] === "string" && options["url"] ? options["url"] : "";
|
|
92
|
+
if (requestedUrl || displayMode === "sidepanel") {
|
|
93
|
+
process.env.EXTENSION_START_URL = requestedUrl || DEFAULT_EXTENSION_DAPP_URL;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!json && adapter === "extension" && tier !== "verify") {
|
|
97
|
+
const modeNote = displayMode === "sidepanel" ? `sidepanel \xB7 dapp ${process.env.EXTENSION_START_URL ?? DEFAULT_EXTENSION_DAPP_URL}` : "fullscreen";
|
|
98
|
+
const workNote = wantWatch ? "watch only" : tier === "build" ? "clean build" : "quick reuse probe";
|
|
99
|
+
console.error(
|
|
100
|
+
`\u2192 extension launch \u2014 ${modeNote} \xB7 CDP :${process.env.CDP_PORT ?? "default"} \xB7 ${workNote}`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
92
103
|
const state = newHealState();
|
|
93
104
|
if (tier === "quick" && nativeInputsChanged(target, adapter)) {
|
|
94
105
|
return usageOut(
|
|
@@ -130,9 +141,9 @@ async function handleLaunch(argv) {
|
|
|
130
141
|
}
|
|
131
142
|
}
|
|
132
143
|
}
|
|
133
|
-
let attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, json);
|
|
144
|
+
let attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, json, displayMode);
|
|
134
145
|
if (attempt.status === 0) {
|
|
135
|
-
return finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state);
|
|
146
|
+
return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch);
|
|
136
147
|
}
|
|
137
148
|
if (heal === "off") {
|
|
138
149
|
return launchFail(json, adapter, mobileTarget, tier, state, {
|
|
@@ -142,6 +153,32 @@ async function handleLaunch(argv) {
|
|
|
142
153
|
exitCode: EXIT.infra
|
|
143
154
|
});
|
|
144
155
|
}
|
|
156
|
+
if (adapter === "extension" && tier === "quick" && extensionQuickReattachFailed(attempt.output)) {
|
|
157
|
+
const recoveryCode2 = "chrome.relaunched";
|
|
158
|
+
state.attemptedRecoveries.push(recoveryCode2);
|
|
159
|
+
const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, json, displayMode);
|
|
160
|
+
if (rebuildAttempt.status === 0) {
|
|
161
|
+
state.recovered.push(recoveryCode2);
|
|
162
|
+
return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch);
|
|
163
|
+
}
|
|
164
|
+
return launchFail(json, adapter, mobileTarget, tier, state, {
|
|
165
|
+
code: "EXTENSION_RELAUNCH_FAILED",
|
|
166
|
+
message: "quick reattach failed, and clean relaunch failed too.",
|
|
167
|
+
recoverable: false,
|
|
168
|
+
exitCode: EXIT.infra,
|
|
169
|
+
originalError: rebuildAttempt.output.trim() || void 0
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
if (adapter === "mobile" && mobileProvisioningBlocked(attempt.output)) {
|
|
173
|
+
return launchFail(json, adapter, mobileTarget, tier, state, {
|
|
174
|
+
code: "MOBILE_PROVISION_REQUIRED",
|
|
175
|
+
message: `mobile ${mobileTarget ?? "runtime"} is not provisioned for this slot.`,
|
|
176
|
+
recoverable: false,
|
|
177
|
+
userAction: mobileProvisionCommand(target, mobileTarget),
|
|
178
|
+
exitCode: EXIT.runtime,
|
|
179
|
+
originalError: attempt.output.trim() || void 0
|
|
180
|
+
});
|
|
181
|
+
}
|
|
145
182
|
const bound = checkHealBounds(target, attempt.output, state);
|
|
146
183
|
if (bound !== null) {
|
|
147
184
|
return launchFail(json, adapter, mobileTarget, tier, state, {
|
|
@@ -155,10 +192,10 @@ async function handleLaunch(argv) {
|
|
|
155
192
|
}
|
|
156
193
|
const recoveryCode = RECOVERY_CODE[adapter];
|
|
157
194
|
state.attemptedRecoveries.push(recoveryCode);
|
|
158
|
-
attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, json);
|
|
195
|
+
attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, json, displayMode);
|
|
159
196
|
if (attempt.status === 0) {
|
|
160
197
|
state.recovered.push(recoveryCode);
|
|
161
|
-
return finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state);
|
|
198
|
+
return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch);
|
|
162
199
|
}
|
|
163
200
|
return launchFail(json, adapter, mobileTarget, tier, state, {
|
|
164
201
|
code: "SAME_RECOVERY_TWICE",
|
|
@@ -168,11 +205,35 @@ async function handleLaunch(argv) {
|
|
|
168
205
|
originalError: attempt.output.trim() || void 0
|
|
169
206
|
});
|
|
170
207
|
}
|
|
208
|
+
function extensionQuickReattachFailed(output) {
|
|
209
|
+
return /\[reattach\]|extension quick reattach|mm-harness launch --build/u.test(output);
|
|
210
|
+
}
|
|
211
|
+
function extensionRuntimeBlocked(output) {
|
|
212
|
+
return /ERR_BLOCKED_BY_CLIENT|has been blocked by Chromium/u.test(output);
|
|
213
|
+
}
|
|
214
|
+
function mobileProvisioningBlocked(output) {
|
|
215
|
+
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);
|
|
216
|
+
}
|
|
217
|
+
function mobileProvisionCommand(target, mobileTarget) {
|
|
218
|
+
const platform = mobileTarget === "android" ? "android" : "ios";
|
|
219
|
+
const device = platform === "ios" ? process.env.IOS_SIMULATOR || process.env.SIM_UDID : process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || process.env.ANDROID_DEVICE;
|
|
220
|
+
const parts = ["mm-harness", "provision", "runway", platform, "--adapter", "mobile", "--target", shellQuote(target)];
|
|
221
|
+
if (device) parts.push("--device", shellQuote(device));
|
|
222
|
+
return parts.join(" ");
|
|
223
|
+
}
|
|
224
|
+
function shellQuote(value) {
|
|
225
|
+
if (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(value)) return value;
|
|
226
|
+
return `'${value.replace(/'/gu, `'"'"'`)}'`;
|
|
227
|
+
}
|
|
171
228
|
function applyLaunchEnvOverrides(options, adapter, mobileTarget, target) {
|
|
172
229
|
getAdapterSurface(adapter).resolveSlotPorts(target);
|
|
173
230
|
const device = str(options, "device");
|
|
174
|
-
if (
|
|
175
|
-
const
|
|
231
|
+
if (adapter === "mobile" && (device || mobileTarget === "ios" || mobileTarget === "android")) {
|
|
232
|
+
const targetingOptions = {
|
|
233
|
+
...options,
|
|
234
|
+
...mobileTarget && !device ? { platform: mobileTarget } : {}
|
|
235
|
+
};
|
|
236
|
+
const result = applyDeviceTargeting("launch", adapter, targetingOptions, { gate: false, rerun: "" });
|
|
176
237
|
if ("code" in result) {
|
|
177
238
|
if (mobileTarget === "ios" && result.code === "DEVICE_NOT_FOUND") {
|
|
178
239
|
process.env.IOS_SIMULATOR = device;
|
|
@@ -185,14 +246,14 @@ function applyLaunchEnvOverrides(options, adapter, mobileTarget, target) {
|
|
|
185
246
|
return result;
|
|
186
247
|
}
|
|
187
248
|
}
|
|
188
|
-
if (mobileTarget === "android" && !process.env.ADB_SERIAL && !process.env.ANDROID_SERIAL) {
|
|
249
|
+
if (mobileTarget === "android" && device && !process.env.ADB_SERIAL && !process.env.ANDROID_SERIAL) {
|
|
189
250
|
return {
|
|
190
251
|
ok: false,
|
|
191
252
|
code: "DEVICE_WRONG_PLATFORM",
|
|
192
253
|
message: `--device ${device} did not resolve to an Android device for launch android.`
|
|
193
254
|
};
|
|
194
255
|
}
|
|
195
|
-
if (mobileTarget === "ios" && !process.env.IOS_SIMULATOR) {
|
|
256
|
+
if (mobileTarget === "ios" && device && !process.env.IOS_SIMULATOR) {
|
|
196
257
|
return {
|
|
197
258
|
ok: false,
|
|
198
259
|
code: "DEVICE_WRONG_PLATFORM",
|
|
@@ -236,19 +297,50 @@ function nativeInputsChanged(target, adapter) {
|
|
|
236
297
|
return false;
|
|
237
298
|
}
|
|
238
299
|
}
|
|
239
|
-
async function executeComposition(adapter, mobileTarget, tier, wantWatch, target, json) {
|
|
300
|
+
async function executeComposition(adapter, mobileTarget, tier, wantWatch, target, json, displayMode = "fullscreen") {
|
|
240
301
|
if (adapter === "mobile") {
|
|
241
302
|
return launchMobile(target, mobileTarget, tier, json);
|
|
242
303
|
}
|
|
243
|
-
return launchExtension(target, tier, wantWatch);
|
|
304
|
+
return launchExtension(target, tier, wantWatch, displayMode);
|
|
244
305
|
}
|
|
245
|
-
function finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state) {
|
|
306
|
+
async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, sidepanelRecoveryAllowed = true) {
|
|
246
307
|
if (adapter === "extension" && displayMode === "sidepanel") {
|
|
247
308
|
const sidepanelSh = path.join(runnerDir, "adapters/extension/sidepanel-toggle.sh");
|
|
248
309
|
const sidepanelArgs = ["open"];
|
|
249
310
|
if (process.env.CDP_PORT) sidepanelArgs.push("--cdp-port", process.env.CDP_PORT);
|
|
311
|
+
if (!json) {
|
|
312
|
+
console.error(
|
|
313
|
+
`[sidepanel] opening panel beside ${process.env.EXTENSION_START_URL ?? DEFAULT_EXTENSION_DAPP_URL}`
|
|
314
|
+
);
|
|
315
|
+
}
|
|
250
316
|
const sidepanel = spawnScript(sidepanelSh, sidepanelArgs, target, json, { REPO: target });
|
|
251
317
|
if (sidepanel.status !== 0) {
|
|
318
|
+
const recoveryCode = "chrome.relaunched";
|
|
319
|
+
if (sidepanelRecoveryAllowed && tier === "quick" && extensionRuntimeBlocked(sidepanel.output) && !state.attemptedRecoveries.includes(recoveryCode)) {
|
|
320
|
+
state.attemptedRecoveries.push(recoveryCode);
|
|
321
|
+
const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, json, displayMode);
|
|
322
|
+
if (rebuildAttempt.status === 0) {
|
|
323
|
+
state.recovered.push(recoveryCode);
|
|
324
|
+
return await finishLaunch(
|
|
325
|
+
json,
|
|
326
|
+
adapter,
|
|
327
|
+
mobileTarget,
|
|
328
|
+
tier,
|
|
329
|
+
displayMode,
|
|
330
|
+
target,
|
|
331
|
+
state,
|
|
332
|
+
wantWatch,
|
|
333
|
+
false
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
return launchFail(json, adapter, mobileTarget, tier, state, {
|
|
337
|
+
code: "EXTENSION_RELAUNCH_FAILED",
|
|
338
|
+
message: "sidepanel open found a blocked extension runtime, and clean relaunch failed too.",
|
|
339
|
+
recoverable: false,
|
|
340
|
+
exitCode: EXIT.infra,
|
|
341
|
+
originalError: rebuildAttempt.output.trim() || void 0
|
|
342
|
+
});
|
|
343
|
+
}
|
|
252
344
|
return launchFail(json, adapter, mobileTarget, tier, state, {
|
|
253
345
|
code: "SIDEPANEL_OPEN_FAILED",
|
|
254
346
|
message: "app launched but opening the side panel failed.",
|
|
@@ -282,7 +374,7 @@ function launchPass(json, adapter, mobileTarget, tier, displayMode, state) {
|
|
|
282
374
|
)
|
|
283
375
|
);
|
|
284
376
|
} else {
|
|
285
|
-
const device = adapter === "mobile" ?
|
|
377
|
+
const device = adapter === "mobile" ? launchDeviceLabel(mobileTarget) : displayMode;
|
|
286
378
|
const tierNote = tier === "quick" ? "quick relaunch, no native build" : tier;
|
|
287
379
|
const devNote = process.env.MM_HARNESS_BIN ? ` ${color("dim", "[dev: MM_HARNESS_BIN]")}` : "";
|
|
288
380
|
console.error(
|
|
@@ -291,6 +383,15 @@ function launchPass(json, adapter, mobileTarget, tier, displayMode, state) {
|
|
|
291
383
|
}
|
|
292
384
|
return EXIT.ok;
|
|
293
385
|
}
|
|
386
|
+
function launchDeviceLabel(mobileTarget) {
|
|
387
|
+
if (mobileTarget === "android") {
|
|
388
|
+
return process.env.ANDROID_TARGET_DEVICE_NAME || process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || process.env.ANDROID_DEVICE || "android device";
|
|
389
|
+
}
|
|
390
|
+
if (mobileTarget === "ios") {
|
|
391
|
+
return process.env.IOS_SIMULATOR || process.env.SIM_UDID || "iOS simulator";
|
|
392
|
+
}
|
|
393
|
+
return process.env.IOS_SIMULATOR || process.env.ADB_SERIAL || "booted device";
|
|
394
|
+
}
|
|
294
395
|
function launchFail(json, adapter, mobileTarget, tier, state, failure) {
|
|
295
396
|
if (json) {
|
|
296
397
|
console.log(
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { listConnectedDevices } from "../devices.js";
|
|
3
|
+
import { renderDeviceList, scopedDevices } from "./device-target.js";
|
|
4
|
+
import { probeMobileLiveState } from "./status-probe.js";
|
|
5
|
+
function mobileDeviceView(allDevices) {
|
|
6
|
+
const allConnectedDevices = listConnectedDevices();
|
|
7
|
+
return {
|
|
8
|
+
allConnectedDevices,
|
|
9
|
+
devices: scopedDevices(allConnectedDevices, allDevices)
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
async function mobileDeviceLiveView(target, view) {
|
|
13
|
+
const allLiveMap = await probeMobileLiveState(target, view.allConnectedDevices);
|
|
14
|
+
const liveMap = scopedLiveMap(view.devices, allLiveMap);
|
|
15
|
+
const devicesWithLive = view.devices.map((device) => mergeDeviceLive(device, liveMap.get(device.id)));
|
|
16
|
+
const reachableDevices = detectAdditionalReachableDevices(view.devices, view.allConnectedDevices, allLiveMap);
|
|
17
|
+
return {
|
|
18
|
+
liveMap,
|
|
19
|
+
devicesWithLive,
|
|
20
|
+
additionalReachableDevices: reachableDevices,
|
|
21
|
+
androidPortHints: selectedAndroidPortHints(view.devices, liveMap)
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function scopedLiveMap(devices, allLiveMap) {
|
|
25
|
+
const liveMap = /* @__PURE__ */ new Map();
|
|
26
|
+
for (const device of devices) {
|
|
27
|
+
const live = allLiveMap.get(device.id);
|
|
28
|
+
if (live) liveMap.set(device.id, live);
|
|
29
|
+
}
|
|
30
|
+
return liveMap;
|
|
31
|
+
}
|
|
32
|
+
function detectAdditionalReachableDevices(scoped, allConnected, liveMap) {
|
|
33
|
+
const scopedIds = new Set(scoped.map((device) => device.id));
|
|
34
|
+
return allConnected.filter((device) => !scopedIds.has(device.id)).map((device) => {
|
|
35
|
+
const live = liveMap.get(device.id);
|
|
36
|
+
if (!live || live.liveState === "no-bridge") return null;
|
|
37
|
+
return mergeDeviceLive({ ...device, selected: false }, live);
|
|
38
|
+
}).filter((device) => device !== null);
|
|
39
|
+
}
|
|
40
|
+
function renderMobileDeviceList(view, out) {
|
|
41
|
+
renderDeviceList(view.devices, out);
|
|
42
|
+
}
|
|
43
|
+
function renderAdditionalReachableDevices(devices, out) {
|
|
44
|
+
console.log(`${out("warn", "additional reachable target:")} ${devices.length} device(s) answered on this Metro but are not the active target for this command`);
|
|
45
|
+
for (const device of devices) {
|
|
46
|
+
console.log(` ${out("warn", "\u25CB")} ${device.id}${device.name ? ` (${device.name})` : ""} ${out("dim", `[${device.state}] ${device.platform}`)}`);
|
|
47
|
+
const live = formatDeviceLive(device, out);
|
|
48
|
+
if (live) console.log(` ${live}`);
|
|
49
|
+
console.log(` ${out("dim", `Use --device ${device.id} to inspect/run/call this target intentionally.`)}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function renderMobileLiveBlock(devices, liveMap, out) {
|
|
53
|
+
console.log(`${out("label", "live:")}`);
|
|
54
|
+
for (const device of devices) {
|
|
55
|
+
const live = liveMap.get(device.id);
|
|
56
|
+
const prefix = ` ${device.platform} ${out("dim", device.id)}${device.name ? ` (${device.name})` : ""}`;
|
|
57
|
+
if (!live || live.liveState === "no-bridge") {
|
|
58
|
+
console.log(`${prefix}: ${out("dim", "(no-bridge)")}`);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const rendered = formatDeviceLive(mergeDeviceLive(device, live), out);
|
|
62
|
+
console.log(`${prefix}: ${rendered.replace(/^live: /u, "")}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function renderAndroidPortHints(hints, out) {
|
|
66
|
+
for (const hint of hints) {
|
|
67
|
+
const current = hint.currentPort ? `; current slot Metro is ${hint.currentPort}` : "";
|
|
68
|
+
console.log(`${out("warn", "android reverse:")} selected ${hint.deviceId} has reverse port(s) ${hint.reversePorts.join(", ")}${current}`);
|
|
69
|
+
console.log(` ${out("dim", `Next: ${hint.next} # rewrites adb reverse and reopens the dev client`)}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function nextForLive(fallback, liveMap) {
|
|
73
|
+
for (const live of liveMap.values()) {
|
|
74
|
+
if (live.liveState === void 0) return "mm-harness logs";
|
|
75
|
+
}
|
|
76
|
+
return fallback;
|
|
77
|
+
}
|
|
78
|
+
function mergeDeviceLive(device, live) {
|
|
79
|
+
if (!live) return device;
|
|
80
|
+
return {
|
|
81
|
+
...device,
|
|
82
|
+
fixtureStatus: live.fixtureStatus,
|
|
83
|
+
...live.liveState !== void 0 ? { liveState: live.liveState } : {},
|
|
84
|
+
...live.currentScreen !== void 0 ? { currentScreen: live.currentScreen } : {},
|
|
85
|
+
...live.walletState !== void 0 ? { walletState: live.walletState } : {},
|
|
86
|
+
...live.selectedAccount !== void 0 ? { selectedAccount: live.selectedAccount } : {}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function formatDeviceLive(device, out) {
|
|
90
|
+
if (device.liveState === "bridge-absent") {
|
|
91
|
+
return out("warn", "live: bridge-absent \u2014 app attached, build lacks __AGENTIC__; rebuild/reinstall a dev build");
|
|
92
|
+
}
|
|
93
|
+
const parts = [];
|
|
94
|
+
if (device.currentScreen !== void 0) parts.push(`screen=${out("cmd", device.currentScreen)}`);
|
|
95
|
+
if (device.walletState !== void 0) {
|
|
96
|
+
parts.push(`wallet=${out(device.walletState === "unlocked" ? "ok" : "warn", device.walletState)}`);
|
|
97
|
+
}
|
|
98
|
+
if (device.selectedAccount !== void 0) {
|
|
99
|
+
parts.push(`account=${device.selectedAccount.label} ${out("dim", `(${device.selectedAccount.address})`)}`);
|
|
100
|
+
}
|
|
101
|
+
if (device.fixtureStatus !== void 0) {
|
|
102
|
+
parts.push(`fixture=${out(device.fixtureStatus === "READY" ? "ok" : "warn", device.fixtureStatus)}`);
|
|
103
|
+
}
|
|
104
|
+
return parts.length > 0 ? `live: ${parts.join(" ")}` : "";
|
|
105
|
+
}
|
|
106
|
+
function selectedAndroidPortHints(devices, liveMap) {
|
|
107
|
+
const currentPort = process.env.WATCHER_PORT || process.env.METRO_PORT || void 0;
|
|
108
|
+
return devices.filter((device) => device.selected && device.platform === "android").filter((device) => liveMap.get(device.id)?.liveState === "no-bridge").map((device) => {
|
|
109
|
+
const reversePorts = androidReversePorts(device.id).filter((port) => currentPort === void 0 || port !== currentPort);
|
|
110
|
+
return {
|
|
111
|
+
deviceId: device.id,
|
|
112
|
+
...currentPort !== void 0 ? { currentPort } : {},
|
|
113
|
+
reversePorts,
|
|
114
|
+
next: `mm-harness launch android --device ${device.id}`
|
|
115
|
+
};
|
|
116
|
+
}).filter((hint) => hint.reversePorts.length > 0);
|
|
117
|
+
}
|
|
118
|
+
function androidReversePorts(serial) {
|
|
119
|
+
try {
|
|
120
|
+
const out = execFileSync("adb", ["-s", serial, "reverse", "--list"], {
|
|
121
|
+
encoding: "utf8",
|
|
122
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
123
|
+
timeout: 5e3
|
|
124
|
+
});
|
|
125
|
+
return [...out.matchAll(/tcp:(\d+)\s+tcp:(\d+)/gu)].map((match) => match[2]).filter((port, index, ports) => ports.indexOf(port) === index);
|
|
126
|
+
} catch {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export {
|
|
131
|
+
detectAdditionalReachableDevices,
|
|
132
|
+
mergeDeviceLive,
|
|
133
|
+
mobileDeviceLiveView,
|
|
134
|
+
mobileDeviceView,
|
|
135
|
+
nextForLive,
|
|
136
|
+
renderAdditionalReachableDevices,
|
|
137
|
+
renderAndroidPortHints,
|
|
138
|
+
renderMobileDeviceList,
|
|
139
|
+
renderMobileLiveBlock
|
|
140
|
+
};
|
|
@@ -30,6 +30,7 @@ function parseArgs(argv, command) {
|
|
|
30
30
|
"resolveOnly",
|
|
31
31
|
"expectLive",
|
|
32
32
|
"fast",
|
|
33
|
+
"allDevices",
|
|
33
34
|
"help"
|
|
34
35
|
]);
|
|
35
36
|
for (let i = 0; i < argv.length; i += 1) {
|
|
@@ -149,7 +150,9 @@ function runtimeOptionsFromCli(options) {
|
|
|
149
150
|
}
|
|
150
151
|
function resolveAdapter(options) {
|
|
151
152
|
const target = targetPath(options);
|
|
152
|
-
const
|
|
153
|
+
const adapterOptionValue = optionString(options, "adapter");
|
|
154
|
+
const platformOptionValue = optionString(options, "platform");
|
|
155
|
+
const explicit = adapterOptionValue ?? (platformOptionValue === "ios" || platformOptionValue === "android" ? "mobile" : platformOptionValue);
|
|
153
156
|
const adapter = explicit ?? detectAdapter(target);
|
|
154
157
|
if (!adapter) {
|
|
155
158
|
throw usageError(`could not detect the MetaMask repo type for ${target}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
import { EXIT, usageOut } from "./shared.js";
|
|
7
7
|
import { optionFlag, optionString, parseArgs } from "./parse-args.js";
|
|
8
8
|
const BUILD_USAGE = "mm-harness recipe-quality build --input <compact.json> --output <path> [--json]";
|
|
9
|
+
const SHORTHAND_USAGE = "mm-harness recipe-quality build --input <compact.json> --output <path> [--json]\n Shorthand: mm-harness recipe-quality --input <compact.json> --output <path> [--json]\n Note: this CLI builds the recipe-quality artifact from an existing compact verdict; recipe critique is performed by the fs-recipe-quality skill.";
|
|
9
10
|
function errorMessage(error) {
|
|
10
11
|
return error instanceof Error ? error.message : String(error);
|
|
11
12
|
}
|
|
@@ -36,9 +37,12 @@ async function handleRecipeQuality(argv) {
|
|
|
36
37
|
const { positional, options } = parseArgs(argv, "recipe-quality");
|
|
37
38
|
const json = optionFlag(options, "json");
|
|
38
39
|
const action = positional[0];
|
|
40
|
+
if (!action && (optionString(options, "input") || optionString(options, "output"))) {
|
|
41
|
+
return buildArtifact(options, json);
|
|
42
|
+
}
|
|
39
43
|
if (action !== "build") {
|
|
40
|
-
const message = action ? `unknown action '${action}'` : "missing action";
|
|
41
|
-
return usageOut(json, "recipe-quality", message,
|
|
44
|
+
const message = action ? `unknown action '${action}'` : "missing action: use build, or pass --input and --output";
|
|
45
|
+
return usageOut(json, "recipe-quality", message, SHORTHAND_USAGE);
|
|
42
46
|
}
|
|
43
47
|
return buildArtifact(options, json);
|
|
44
48
|
}
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
actionManifestPathOption,
|
|
26
26
|
optionString,
|
|
27
27
|
isRecord,
|
|
28
|
+
shellQuoteArg,
|
|
28
29
|
usageError
|
|
29
30
|
} from "./parse-args.js";
|
|
30
31
|
async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManifestPath, runtimeOptions = {}) {
|
|
@@ -119,7 +120,8 @@ async function prepareRuntimeIfNeeded(adapter, projectRoot, runtimeOptions) {
|
|
|
119
120
|
cdpPort: runtimeOptions.cdpPort,
|
|
120
121
|
slot: runtimeOptions.slot,
|
|
121
122
|
launchExistingDist: runtimeOptions.launchExistingDist === true,
|
|
122
|
-
validationRuntimeDir: runtimeOptions.validationRuntimeDir
|
|
123
|
+
validationRuntimeDir: runtimeOptions.validationRuntimeDir,
|
|
124
|
+
pageMode: "home-or-sidepanel"
|
|
123
125
|
});
|
|
124
126
|
}
|
|
125
127
|
function restoreEnv(key, value) {
|
|
@@ -260,7 +262,13 @@ async function validateRunRecipeStatic(recipeArg, adapter, options) {
|
|
|
260
262
|
...librarySources ? { librarySources } : {}
|
|
261
263
|
};
|
|
262
264
|
if ("notFound" in resolved) {
|
|
263
|
-
return {
|
|
265
|
+
return {
|
|
266
|
+
...empty,
|
|
267
|
+
usageError: {
|
|
268
|
+
code: "RECIPE_NOT_FOUND",
|
|
269
|
+
message: resolved.notFound + await actionInsteadOfRecipeHint(recipeArg, adapter, options)
|
|
270
|
+
}
|
|
271
|
+
};
|
|
264
272
|
}
|
|
265
273
|
let recipe;
|
|
266
274
|
try {
|
|
@@ -301,6 +309,24 @@ async function validateRunRecipeStatic(recipeArg, adapter, options) {
|
|
|
301
309
|
...librarySources ? { librarySources } : {}
|
|
302
310
|
};
|
|
303
311
|
}
|
|
312
|
+
async function actionInsteadOfRecipeHint(recipeArg, adapter, options) {
|
|
313
|
+
if (recipeArg.includes("/") || recipeArg.includes(path.sep)) return "";
|
|
314
|
+
try {
|
|
315
|
+
const manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
|
|
316
|
+
const { getRecipeActionManifestActionNames } = await importRecipeProtocol();
|
|
317
|
+
const actions = getRecipeActionManifestActionNames(manifest);
|
|
318
|
+
const matches = actions.includes(recipeArg) ? [recipeArg] : actions.filter((name) => name.split(".").pop() === recipeArg);
|
|
319
|
+
if (matches.length !== 1) return "";
|
|
320
|
+
const device = optionString(options, "device");
|
|
321
|
+
const actionManifest = optionString(options, "actionManifest");
|
|
322
|
+
const parts = ["mm-harness", "call", matches[0]];
|
|
323
|
+
if (device) parts.push("--device", shellQuoteArg(device));
|
|
324
|
+
if (actionManifest) parts.push("--action-manifest", shellQuoteArg(actionManifest));
|
|
325
|
+
return ` This is an action, not a recipe. Use: ${parts.join(" ")}.`;
|
|
326
|
+
} catch {
|
|
327
|
+
return "";
|
|
328
|
+
}
|
|
329
|
+
}
|
|
304
330
|
async function resolveMetaMaskLibrarySources(libraryEntry) {
|
|
305
331
|
const harness = await importRecipeHarness();
|
|
306
332
|
if (typeof harness.resolveRecipeLibrarySources !== "function") {
|