@deeeed/metamask-harness 0.13.0 → 0.14.1
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 +43 -4
- 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 +15 -4
- package/adapters/extension/wallet-fixture-state.cjs +6 -11
- package/adapters/mobile/open-device.sh +32 -5
- package/adapters/mobile/start-metro.sh +15 -1
- package/adapters/mobile/verify.sh +50 -13
- package/adapters/shared/log-tui.mjs +7 -3
- 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-commands.js +5 -4
- package/dist/cli.js +4 -0
- package/dist/commands/call.js +79 -6
- package/dist/commands/check.js +326 -0
- package/dist/commands/core-readiness.js +75 -0
- package/dist/commands/device-target.js +123 -13
- package/dist/commands/doctor.js +30 -18
- package/dist/commands/fixtures.js +2 -1
- 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 +90 -5
- package/dist/commands/run-report.js +115 -0
- package/dist/commands/run.js +115 -8
- package/dist/commands/self-test.js +1 -1
- package/dist/commands/shared.js +2 -1
- package/dist/commands/status-probe.js +9 -3
- package/dist/commands/status.js +45 -50
- package/dist/live-adapter-contract.js +5 -1
- package/dist/mm-harness-cli.js +68 -27
- package/dist/recipe-files.js +14 -0
- package/docs/CLI-SPEC.md +25 -0
- package/docs/architecture.md +2 -2
- package/docs/live-adapter-contract.md +1 -1
- package/docs/recipe-libraries.md +21 -18
- package/library/actions/extension/platform/cdp.mjs +7 -4
- package/library/recipes/{app-lifecycle-android-smoke.mobile.recipe.json → app/lifecycle.android-smoke.mobile.recipe.json} +1 -1
- package/library/recipes/{perps-performance.mobile.recipe.json → perps/performance.mobile.recipe.json} +1 -1
- package/library/recipes/perps/smoke.core.recipe.json +39 -0
- package/library/recipes/perps/smoke.extension.recipe.json +51 -0
- package/library/recipes/perps/smoke.mobile.recipe.json +51 -0
- package/library/recipes/{action-validation.extension.recipe.json → runner/action-validation.extension.recipe.json} +7 -7
- package/library/recipes/{action-validation.mobile.recipe.json → runner/action-validation.mobile.recipe.json} +7 -7
- package/package.json +1 -1
- /package/library/recipes/{perps-lifecycle.recipe.json → perps/lifecycle.recipe.json} +0 -0
- /package/library/recipes/{order-lifecycle.core.recipe.json → perps/order-lifecycle.core.recipe.json} +0 -0
- /package/library/recipes/{perps-performance-background-resume.mobile.recipe.json → perps/performance.background-resume.mobile.recipe.json} +0 -0
- /package/library/recipes/{perps-performance-cold-start.mobile.recipe.json → perps/performance.cold-start.mobile.recipe.json} +0 -0
- /package/library/recipes/{perps-performance-warm-start.mobile.recipe.json → perps/performance.warm-start.mobile.recipe.json} +0 -0
- /package/library/recipes/{read-markets.core.recipe.json → perps/read-markets.core.recipe.json} +0 -0
- /package/library/recipes/{trading-lifecycle.core.recipe.json → perps/trading-lifecycle.core.recipe.json} +0 -0
- /package/library/recipes/{smoke.extension.recipe.json → runner/smoke.extension.recipe.json} +0 -0
- /package/library/recipes/{smoke.mobile.recipe.json → runner/smoke.mobile.recipe.json} +0 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const requireFromHarness = createRequire(import.meta.url);
|
|
5
|
+
const CORE_RUNTIME_DEPS = ["immer"];
|
|
6
|
+
function coreDependencyBlock(target) {
|
|
7
|
+
const resolved = path.resolve(target);
|
|
8
|
+
const nodeLinker = readYarnNodeLinker(resolved);
|
|
9
|
+
const installAction = `cd ${shellQuote(resolved)} && yarn install --immutable`;
|
|
10
|
+
if (nodeLinker === "pnp") {
|
|
11
|
+
if (!fs.existsSync(path.join(resolved, ".pnp.cjs"))) {
|
|
12
|
+
return {
|
|
13
|
+
code: "CORE_DEPS_MISSING",
|
|
14
|
+
message: "core dependencies are not installed for Yarn PnP (.pnp.cjs is missing).",
|
|
15
|
+
userAction: installAction
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
if (!fs.existsSync(path.join(resolved, "node_modules"))) {
|
|
21
|
+
return {
|
|
22
|
+
code: "CORE_DEPS_MISSING",
|
|
23
|
+
message: nodeLinker === "node-modules" ? "core dependencies are not installed (nodeLinker requires node_modules, but node_modules is missing)." : "core dependencies are not installed (node_modules is missing).",
|
|
24
|
+
userAction: installAction
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
if (!fs.existsSync(path.join(resolved, "node_modules/.bin/tsx"))) {
|
|
28
|
+
return {
|
|
29
|
+
code: "CORE_DEPS_INCOMPLETE",
|
|
30
|
+
message: "core dependencies are incomplete (node_modules/.bin/tsx is missing).",
|
|
31
|
+
userAction: installAction
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
for (const dependency of CORE_RUNTIME_DEPS) {
|
|
35
|
+
if (!canResolveFromTarget(dependency, resolved)) {
|
|
36
|
+
return {
|
|
37
|
+
code: "CORE_DEPS_INCOMPLETE",
|
|
38
|
+
message: `core dependencies are incomplete (cannot resolve ${dependency} from the target checkout).`,
|
|
39
|
+
userAction: installAction
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
function corePnpNodeOptions(target, current) {
|
|
46
|
+
if (readYarnNodeLinker(target) !== "pnp") return current;
|
|
47
|
+
const pnp = path.join(path.resolve(target), ".pnp.cjs");
|
|
48
|
+
if (!fs.existsSync(pnp)) return current;
|
|
49
|
+
const pnpOption = `--require ${pnp}`;
|
|
50
|
+
return current ? `${current} ${pnpOption}` : pnpOption;
|
|
51
|
+
}
|
|
52
|
+
function canResolveFromTarget(specifier, target) {
|
|
53
|
+
try {
|
|
54
|
+
requireFromHarness.resolve(specifier, { paths: [target] });
|
|
55
|
+
return true;
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function readYarnNodeLinker(target) {
|
|
61
|
+
try {
|
|
62
|
+
const yarnrc = fs.readFileSync(path.join(target, ".yarnrc.yml"), "utf8");
|
|
63
|
+
const match = /^nodeLinker:\s*["']?([^"'\s#]+)["']?/mu.exec(yarnrc);
|
|
64
|
+
return match?.[1];
|
|
65
|
+
} catch {
|
|
66
|
+
return void 0;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function shellQuote(value) {
|
|
70
|
+
return `'${value.replace(/'/gu, `'\\''`)}'`;
|
|
71
|
+
}
|
|
72
|
+
export {
|
|
73
|
+
coreDependencyBlock,
|
|
74
|
+
corePnpNodeOptions
|
|
75
|
+
};
|
|
@@ -49,7 +49,13 @@ function renderDeviceList(devices, out) {
|
|
|
49
49
|
}
|
|
50
50
|
function deviceSelected(device) {
|
|
51
51
|
if (device.platform === "android") {
|
|
52
|
-
|
|
52
|
+
if (process.env.ADB_SERIAL === device.id || process.env.ANDROID_SERIAL === device.id) return true;
|
|
53
|
+
const pinName = process.env.ANDROID_TARGET_DEVICE_NAME || process.env.ANDROID_DEVICE || "";
|
|
54
|
+
if (!pinName) return false;
|
|
55
|
+
if (pinName === device.id) return true;
|
|
56
|
+
const name = device.name ? normalizeDeviceName(device.name) : "";
|
|
57
|
+
const normalizedPin = normalizeDeviceName(pinName);
|
|
58
|
+
return Boolean(name && (normalizedPin === name || normalizedPin.startsWith(`${name} -`)));
|
|
53
59
|
}
|
|
54
60
|
return process.env.IOS_SIMULATOR === device.id || process.env.SIM_UDID === device.id || process.env.IOS_SIMULATOR === device.name;
|
|
55
61
|
}
|
|
@@ -58,8 +64,20 @@ function isTargetable(device) {
|
|
|
58
64
|
if (device.platform === "ios") return device.state === "Booted";
|
|
59
65
|
return false;
|
|
60
66
|
}
|
|
67
|
+
function mobilePlatformPreference(options) {
|
|
68
|
+
const platform = optionString(options, "platform");
|
|
69
|
+
return platform === "ios" || platform === "android" ? platform : void 0;
|
|
70
|
+
}
|
|
71
|
+
function configuredPinForPlatform(platform) {
|
|
72
|
+
if (platform === "ios") return process.env.IOS_SIMULATOR || process.env.SIM_UDID || void 0;
|
|
73
|
+
return process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || process.env.ANDROID_TARGET_DEVICE_NAME || process.env.ANDROID_DEVICE || void 0;
|
|
74
|
+
}
|
|
75
|
+
function oppositePlatform(platform) {
|
|
76
|
+
return platform === "ios" ? "android" : "ios";
|
|
77
|
+
}
|
|
61
78
|
function applyDeviceTargeting(command, adapter, options, opts) {
|
|
62
79
|
const device = optionString(options, "device");
|
|
80
|
+
const platformPreference = mobilePlatformPreference(options);
|
|
63
81
|
if (adapter !== "mobile") {
|
|
64
82
|
if (device !== void 0) {
|
|
65
83
|
return {
|
|
@@ -73,15 +91,26 @@ function applyDeviceTargeting(command, adapter, options, opts) {
|
|
|
73
91
|
}
|
|
74
92
|
if (device !== void 0) {
|
|
75
93
|
const connected2 = listConnectedDevices();
|
|
94
|
+
const setResolvedDevice = (matched) => {
|
|
95
|
+
if (platformPreference && matched.platform !== platformPreference) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
code: "DEVICE_PLATFORM_CONFLICT",
|
|
99
|
+
message: `--device ${device} resolves to ${matched.platform}, but --platform ${platformPreference} was requested.
|
|
100
|
+
To use this device intentionally, drop --platform ${platformPreference} or choose a ${platformPreference} device.`
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (matched.platform === "android") setAndroidDeviceEnv(matched.id, matched.name);
|
|
104
|
+
else setIosDeviceEnv(matched.id, matched.name);
|
|
105
|
+
return { ok: true };
|
|
106
|
+
};
|
|
76
107
|
const androidById = connected2.find((d) => d.platform === "android" && d.id === device);
|
|
77
108
|
if (androidById) {
|
|
78
|
-
|
|
79
|
-
return { ok: true };
|
|
109
|
+
return setResolvedDevice(androidById);
|
|
80
110
|
}
|
|
81
111
|
const iosById = connected2.find((d) => d.platform === "ios" && d.id === device);
|
|
82
112
|
if (iosById) {
|
|
83
|
-
|
|
84
|
-
return { ok: true };
|
|
113
|
+
return setResolvedDevice(iosById);
|
|
85
114
|
}
|
|
86
115
|
const normalizedDevice = normalizeDeviceName(device);
|
|
87
116
|
const byName = connected2.filter((d) => {
|
|
@@ -90,10 +119,7 @@ function applyDeviceTargeting(command, adapter, options, opts) {
|
|
|
90
119
|
return name === device || normalizedName === normalizedDevice || d.platform === "android" && normalizedDevice.startsWith(`${normalizedName} -`);
|
|
91
120
|
});
|
|
92
121
|
if (byName.length === 1) {
|
|
93
|
-
|
|
94
|
-
if (matched.platform === "android") setAndroidDeviceEnv(matched.id, matched.name);
|
|
95
|
-
else setIosDeviceEnv(matched.id, matched.name);
|
|
96
|
-
return { ok: true };
|
|
122
|
+
return setResolvedDevice(byName[0]);
|
|
97
123
|
}
|
|
98
124
|
if (byName.length > 1) {
|
|
99
125
|
return {
|
|
@@ -111,9 +137,85 @@ function applyDeviceTargeting(command, adapter, options, opts) {
|
|
|
111
137
|
${formatConnectedDevices(connected2)}` : ` No devices are currently connected (adb devices / booted iOS simulators).`)
|
|
112
138
|
};
|
|
113
139
|
}
|
|
114
|
-
if (!opts.gate) return { ok: true };
|
|
115
140
|
const connected = listConnectedDevices();
|
|
116
141
|
const targetable = connected.filter(isTargetable);
|
|
142
|
+
if (platformPreference) {
|
|
143
|
+
const selectedTargetable2 = targetable.filter(deviceSelected);
|
|
144
|
+
const platformTargetable = targetable.filter((d) => d.platform === platformPreference);
|
|
145
|
+
const selectedPlatformTargetable = platformTargetable.filter(deviceSelected);
|
|
146
|
+
const configuredPin = configuredPinForPlatform(platformPreference);
|
|
147
|
+
const oppositePin = configuredPinForPlatform(oppositePlatform(platformPreference));
|
|
148
|
+
if (selectedPlatformTargetable.length === 1) {
|
|
149
|
+
const selected = selectedPlatformTargetable[0];
|
|
150
|
+
if (selected.platform === "android") setAndroidDeviceEnv(selected.id, selected.name);
|
|
151
|
+
else setIosDeviceEnv(selected.id, selected.name);
|
|
152
|
+
return { ok: true };
|
|
153
|
+
}
|
|
154
|
+
if (selectedTargetable2.length > 0) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
code: "DEVICE_PLATFORM_CONFLICT",
|
|
158
|
+
message: `--platform ${platformPreference} conflicts with the current slot-selected device.
|
|
159
|
+
Selected devices:
|
|
160
|
+
${formatConnectedDevices(selectedTargetable2)}
|
|
161
|
+
To use a different connected device intentionally, pass --device <id>.`
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (!configuredPin && oppositePin) {
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
code: "DEVICE_PLATFORM_CONFLICT",
|
|
168
|
+
message: `--platform ${platformPreference} conflicts with the configured slot target.
|
|
169
|
+
Configured ${oppositePlatform(platformPreference)} target: ${oppositePin}
|
|
170
|
+
To use a different connected device intentionally, pass --device <id>.`
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (configuredPin) {
|
|
174
|
+
if (command === "launch" || !opts.gate) return { ok: true };
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
code: "DEVICE_PLATFORM_PIN_NOT_READY",
|
|
178
|
+
message: `--platform ${platformPreference} matches the configured slot target, but that target is not ready.
|
|
179
|
+
Configured target: ${configuredPin}
|
|
180
|
+
Connected devices:
|
|
181
|
+
${connected.length > 0 ? formatConnectedDevices(connected) : " - none"}
|
|
182
|
+
Next: run mm-harness launch ${platformPreference}, or pass --device <id> to intentionally use another target.`
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
if (platformTargetable.length === 1) {
|
|
186
|
+
const selected = platformTargetable[0];
|
|
187
|
+
if (selected.platform === "android") setAndroidDeviceEnv(selected.id, selected.name);
|
|
188
|
+
else setIosDeviceEnv(selected.id, selected.name);
|
|
189
|
+
return { ok: true };
|
|
190
|
+
}
|
|
191
|
+
if (platformTargetable.length === 0) {
|
|
192
|
+
if (command === "launch" && platformPreference === "ios") return { ok: true };
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
code: "DEVICE_PLATFORM_NOT_FOUND",
|
|
196
|
+
message: `--platform ${platformPreference} did not match any ready connected device.
|
|
197
|
+
` + (connected.length > 0 ? ` Connected devices:
|
|
198
|
+
${formatConnectedDevices(connected)}` : ` No devices are currently connected (adb devices / booted iOS simulators).`)
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
ok: false,
|
|
203
|
+
code: "DEVICE_PLATFORM_AMBIGUOUS",
|
|
204
|
+
message: `${platformTargetable.length} ${platformPreference} devices available \u2014 ${command} needs exactly one target.
|
|
205
|
+
Connected devices:
|
|
206
|
+
${formatConnectedDevices(connected)}
|
|
207
|
+
Add --device <id> to disambiguate, e.g.:${platformTargetable.map((d) => `
|
|
208
|
+
--device ${d.id}`).join("")}`
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const selectedTargetable = targetable.filter(deviceSelected);
|
|
212
|
+
if (selectedTargetable.length === 1) {
|
|
213
|
+
const selected = selectedTargetable[0];
|
|
214
|
+
if (selected.platform === "android") setAndroidDeviceEnv(selected.id, selected.name);
|
|
215
|
+
else setIosDeviceEnv(selected.id, selected.name);
|
|
216
|
+
return { ok: true };
|
|
217
|
+
}
|
|
218
|
+
if (!opts.gate) return { ok: true };
|
|
117
219
|
if (targetable.length > 1) {
|
|
118
220
|
return {
|
|
119
221
|
ok: false,
|
|
@@ -121,15 +223,23 @@ ${formatConnectedDevices(connected2)}` : ` No devices are currently connected (
|
|
|
121
223
|
message: `${targetable.length} mobile devices available \u2014 ${command} needs exactly one target.
|
|
122
224
|
Connected devices:
|
|
123
225
|
${formatConnectedDevices(connected)}
|
|
124
|
-
Add --device <id> to disambiguate, e.g.:${targetable.map((d) => `
|
|
125
|
-
--device ${d.id}`).join("")}`
|
|
226
|
+
` + (selectedTargetable.length > 1 ? ` The current slot context selects more than one target; fix the slot device pins or add --device <id>.` : ` Add --device <id> to disambiguate, e.g.:${targetable.map((d) => `
|
|
227
|
+
--device ${d.id}`).join("")}`)
|
|
126
228
|
};
|
|
127
229
|
}
|
|
128
230
|
return { ok: true };
|
|
129
231
|
}
|
|
232
|
+
function scopedDevices(devices, allDevices = false) {
|
|
233
|
+
const withSelection = devices.map((device) => ({ ...device, selected: deviceSelected(device) }));
|
|
234
|
+
if (allDevices) return withSelection;
|
|
235
|
+
const selected = withSelection.filter((device) => device.selected);
|
|
236
|
+
return selected.length > 0 ? selected : withSelection;
|
|
237
|
+
}
|
|
130
238
|
export {
|
|
131
239
|
applyDeviceTargeting,
|
|
132
240
|
deviceSelected,
|
|
133
241
|
formatConnectedDevices,
|
|
134
|
-
|
|
242
|
+
mobilePlatformPreference,
|
|
243
|
+
renderDeviceList,
|
|
244
|
+
scopedDevices
|
|
135
245
|
};
|
package/dist/commands/doctor.js
CHANGED
|
@@ -7,8 +7,14 @@ import { assertAdapter, runnerDir } from "../paths.js";
|
|
|
7
7
|
import { getAdapterSurface } from "../adapters/surface.js";
|
|
8
8
|
import { loadActionManifest, validateManifest } from "../manifest.js";
|
|
9
9
|
import { ensureOverlay, newHealState, recipeRunning } from "../heal-bounds.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { applyDeviceTargeting } from "./device-target.js";
|
|
11
|
+
import {
|
|
12
|
+
mobileDeviceView,
|
|
13
|
+
mobileDeviceLiveView,
|
|
14
|
+
renderAdditionalReachableDevices,
|
|
15
|
+
renderMobileDeviceList,
|
|
16
|
+
renderMobileLiveBlock
|
|
17
|
+
} from "./mobile-device-view.js";
|
|
12
18
|
import { ADAPTER_DETECT_NEXT, EXIT, usageOut } from "./shared.js";
|
|
13
19
|
import {
|
|
14
20
|
actionManifestPathOption,
|
|
@@ -35,18 +41,14 @@ async function handleDoctor({ options }) {
|
|
|
35
41
|
const target = targetPath(options);
|
|
36
42
|
const json = optionFlag(options, "json");
|
|
37
43
|
const expectLive = optionFlag(options, "expectLive");
|
|
38
|
-
const
|
|
44
|
+
const allDevices = optionFlag(options, "allDevices");
|
|
45
|
+
const platformOption = optionString(options, "platform");
|
|
46
|
+
const explicitAdapter = optionString(options, "adapter") ?? (platformOption === "ios" || platformOption === "android" ? "mobile" : platformOption);
|
|
39
47
|
const adapter = explicitAdapter ?? detectAdapter(target);
|
|
40
48
|
if (!adapter) {
|
|
41
49
|
return usageOut(json, "doctor", `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
|
|
42
50
|
}
|
|
43
51
|
assertAdapter(adapter);
|
|
44
|
-
const dtResult = applyDeviceTargeting("doctor", adapter, options, { gate: false, rerun: "" });
|
|
45
|
-
if ("code" in dtResult) {
|
|
46
|
-
if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "doctor", adapter, target, error: { code: dtResult.code, message: dtResult.message } }, null, 2));
|
|
47
|
-
else console.error(`\u2717 doctor: ${dtResult.message}`);
|
|
48
|
-
return EXIT.usage;
|
|
49
|
-
}
|
|
50
52
|
const actionManifestPath = actionManifestPathOption(options, adapter);
|
|
51
53
|
const manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
|
|
52
54
|
const manifestValidation = await validateManifest(manifest);
|
|
@@ -62,15 +64,24 @@ async function handleDoctor({ options }) {
|
|
|
62
64
|
try {
|
|
63
65
|
const surface = getAdapterSurface(adapter);
|
|
64
66
|
surface.resolveSlotPorts(target);
|
|
67
|
+
const dtResult = applyDeviceTargeting("doctor", adapter, options, { gate: false, rerun: "" });
|
|
68
|
+
if ("code" in dtResult) {
|
|
69
|
+
if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "doctor", adapter, target, error: { code: dtResult.code, message: dtResult.message } }, null, 2));
|
|
70
|
+
else console.error(`\u2717 doctor: ${dtResult.message}`);
|
|
71
|
+
return EXIT.usage;
|
|
72
|
+
}
|
|
65
73
|
applyDoctorRuntimePorts(options);
|
|
66
74
|
runtime = await surface.runtimeStatus(target);
|
|
67
75
|
} catch {
|
|
68
76
|
}
|
|
69
77
|
const orphanMetros = adapter === "mobile" ? detectOrphanMetros(target, process.env.WATCHER_PORT) : [];
|
|
70
78
|
const capture = captureHelperHealth();
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
79
|
+
const deviceView = adapter === "mobile" ? mobileDeviceView(allDevices) : null;
|
|
80
|
+
const liveView = deviceView ? await mobileDeviceLiveView(target, deviceView) : null;
|
|
81
|
+
const devices = liveView?.devicesWithLive ?? deviceView?.devices ?? [];
|
|
82
|
+
const additionalReachableDevices = liveView?.additionalReachableDevices ?? [];
|
|
83
|
+
if (expectLive) return emitExpectLive(adapter, target, runtime, result, orphanMetros, capture, devices, additionalReachableDevices, json);
|
|
84
|
+
if (json) console.log(JSON.stringify({ ...result, runtime, orphanMetros, capture, devices, additionalReachableDevices }, null, 2));
|
|
74
85
|
else {
|
|
75
86
|
const out = (style, text) => color(style, text, { stream: process.stdout });
|
|
76
87
|
const stateStyle = (value, good) => value === good ? "ok" : "warn";
|
|
@@ -100,17 +111,18 @@ async function handleDoctor({ options }) {
|
|
|
100
111
|
console.log(` ${out("dim", "Next: grant Screen Recording (System Settings \u2192 Privacy & Security \u2192 Screen Recording), or run: capture-helper doctor --open-permissions")}`);
|
|
101
112
|
}
|
|
102
113
|
}
|
|
103
|
-
if (adapter === "mobile")
|
|
114
|
+
if (adapter === "mobile") {
|
|
115
|
+
if (deviceView) renderMobileDeviceList(deviceView, out);
|
|
116
|
+
if (additionalReachableDevices.length > 0) renderAdditionalReachableDevices(additionalReachableDevices, out);
|
|
117
|
+
if (liveView) renderMobileLiveBlock(deviceView?.devices ?? [], liveView.liveMap, out);
|
|
118
|
+
}
|
|
104
119
|
}
|
|
105
120
|
return result.status === "pass" ? 0 : 1;
|
|
106
121
|
}
|
|
107
|
-
function
|
|
108
|
-
return listConnectedDevices().map((device) => ({ ...device, selected: deviceSelected(device) }));
|
|
109
|
-
}
|
|
110
|
-
function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture, devices, json) {
|
|
122
|
+
function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture, devices, additionalReachableDevices, json) {
|
|
111
123
|
const live = runtime?.decision === "ready";
|
|
112
124
|
if (json) {
|
|
113
|
-
console.log(JSON.stringify({ ...result, runtime, orphanMetros, capture, devices }, null, 2));
|
|
125
|
+
console.log(JSON.stringify({ ...result, runtime, orphanMetros, capture, devices, additionalReachableDevices }, null, 2));
|
|
114
126
|
return live ? EXIT.ok : EXIT.runtime;
|
|
115
127
|
}
|
|
116
128
|
const out = (style, text) => color(style, text, { stream: process.stdout });
|
|
@@ -39,13 +39,14 @@ async function handleFixtures(argv, deps) {
|
|
|
39
39
|
if (!adapter) {
|
|
40
40
|
return usageOut(json, "fixtures", `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
|
|
41
41
|
}
|
|
42
|
+
const surface = getAdapterSurface(adapter);
|
|
43
|
+
surface.resolveSlotPorts(target);
|
|
42
44
|
const dtResult = applyDeviceTargeting("fixtures", adapter, options, { gate: false, rerun: "" });
|
|
43
45
|
if ("code" in dtResult) {
|
|
44
46
|
return usageOut(json, "fixtures", dtResult.message, "mm-harness fixtures set --adapter mobile --device <adb-serial|simulator-udid>");
|
|
45
47
|
}
|
|
46
48
|
if (sub === "generate") return fixturesGenerate(adapter, target, options, json);
|
|
47
49
|
if (sub === "finalize") return fixturesFinalize(adapter, target, options, json);
|
|
48
|
-
const surface = getAdapterSurface(adapter);
|
|
49
50
|
if (surface.headless) return usageOut(json, "fixtures", "core is headless; it has no wallet fixture.", surface.hints.launch);
|
|
50
51
|
const canonicalFixture = walletFixturePath(target);
|
|
51
52
|
const retryHint = `${surface.hints.relaunch} # relaunch, then retry: mm-harness fixtures set`;
|
|
@@ -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);
|