@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +43 -4
  2. package/adapters/extension/inject.mjs +1 -0
  3. package/adapters/extension/launch-browser.cjs +15 -3
  4. package/adapters/extension/lib/extension-id.cjs +36 -0
  5. package/adapters/extension/live.sh +5 -3
  6. package/adapters/extension/readiness.mjs +48 -0
  7. package/adapters/extension/reattach.sh +163 -55
  8. package/adapters/extension/sidepanel-toggle.sh +96 -24
  9. package/adapters/extension/verify.sh +15 -4
  10. package/adapters/extension/wallet-fixture-state.cjs +6 -11
  11. package/adapters/mobile/open-device.sh +32 -5
  12. package/adapters/mobile/start-metro.sh +15 -1
  13. package/adapters/mobile/verify.sh +50 -13
  14. package/adapters/shared/log-tui.mjs +7 -3
  15. package/adapters/shared/resolve-slot-ports-core.mjs +14 -4
  16. package/dist/adapters/core/surface.js +1 -0
  17. package/dist/adapters/extension/runtime.js +14 -5
  18. package/dist/adapters/extension/surface.js +1 -0
  19. package/dist/adapters/mobile/provision.js +49 -4
  20. package/dist/adapters/mobile/surface.js +1 -0
  21. package/dist/adapters/slot-ports.js +11 -4
  22. package/dist/cli-commands.js +5 -4
  23. package/dist/cli.js +4 -0
  24. package/dist/commands/call.js +79 -6
  25. package/dist/commands/check.js +326 -0
  26. package/dist/commands/core-readiness.js +75 -0
  27. package/dist/commands/device-target.js +123 -13
  28. package/dist/commands/doctor.js +30 -18
  29. package/dist/commands/fixtures.js +2 -1
  30. package/dist/commands/launch/extension.js +105 -9
  31. package/dist/commands/launch/index.js +116 -15
  32. package/dist/commands/mobile-device-view.js +140 -0
  33. package/dist/commands/parse-args.js +4 -1
  34. package/dist/commands/recipe-quality.js +6 -2
  35. package/dist/commands/run-engine.js +90 -5
  36. package/dist/commands/run-report.js +115 -0
  37. package/dist/commands/run.js +115 -8
  38. package/dist/commands/self-test.js +1 -1
  39. package/dist/commands/shared.js +2 -1
  40. package/dist/commands/status-probe.js +9 -3
  41. package/dist/commands/status.js +45 -50
  42. package/dist/live-adapter-contract.js +5 -1
  43. package/dist/mm-harness-cli.js +68 -27
  44. package/dist/recipe-files.js +14 -0
  45. package/docs/CLI-SPEC.md +25 -0
  46. package/docs/architecture.md +2 -2
  47. package/docs/live-adapter-contract.md +1 -1
  48. package/docs/recipe-libraries.md +21 -18
  49. package/library/actions/extension/platform/cdp.mjs +7 -4
  50. package/library/recipes/{app-lifecycle-android-smoke.mobile.recipe.json → app/lifecycle.android-smoke.mobile.recipe.json} +1 -1
  51. package/library/recipes/{perps-performance.mobile.recipe.json → perps/performance.mobile.recipe.json} +1 -1
  52. package/library/recipes/perps/smoke.core.recipe.json +39 -0
  53. package/library/recipes/perps/smoke.extension.recipe.json +51 -0
  54. package/library/recipes/perps/smoke.mobile.recipe.json +51 -0
  55. package/library/recipes/{action-validation.extension.recipe.json → runner/action-validation.extension.recipe.json} +7 -7
  56. package/library/recipes/{action-validation.mobile.recipe.json → runner/action-validation.mobile.recipe.json} +7 -7
  57. package/package.json +1 -1
  58. /package/library/recipes/{perps-lifecycle.recipe.json → perps/lifecycle.recipe.json} +0 -0
  59. /package/library/recipes/{order-lifecycle.core.recipe.json → perps/order-lifecycle.core.recipe.json} +0 -0
  60. /package/library/recipes/{perps-performance-background-resume.mobile.recipe.json → perps/performance.background-resume.mobile.recipe.json} +0 -0
  61. /package/library/recipes/{perps-performance-cold-start.mobile.recipe.json → perps/performance.cold-start.mobile.recipe.json} +0 -0
  62. /package/library/recipes/{perps-performance-warm-start.mobile.recipe.json → perps/performance.warm-start.mobile.recipe.json} +0 -0
  63. /package/library/recipes/{read-markets.core.recipe.json → perps/read-markets.core.recipe.json} +0 -0
  64. /package/library/recipes/{trading-lifecycle.core.recipe.json → perps/trading-lifecycle.core.recipe.json} +0 -0
  65. /package/library/recipes/{smoke.extension.recipe.json → runner/smoke.extension.recipe.json} +0 -0
  66. /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
- return process.env.ADB_SERIAL === device.id || process.env.ANDROID_SERIAL === device.id;
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
- setAndroidDeviceEnv(device, androidById.name);
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
- setIosDeviceEnv(device, iosById.name);
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
- const matched = byName[0];
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
- renderDeviceList
242
+ mobilePlatformPreference,
243
+ renderDeviceList,
244
+ scopedDevices
135
245
  };
@@ -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 { listConnectedDevices } from "../devices.js";
11
- import { applyDeviceTargeting, deviceSelected, renderDeviceList } from "./device-target.js";
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 explicitAdapter = optionString(options, "adapter") ?? optionString(options, "platform");
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 devices = adapter === "mobile" ? enumerateSelectedDevices() : [];
72
- if (expectLive) return emitExpectLive(adapter, target, runtime, result, orphanMetros, capture, devices, json);
73
- if (json) console.log(JSON.stringify({ ...result, runtime, orphanMetros, capture, devices }, null, 2));
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") renderDeviceList(devices, out);
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 enumerateSelectedDevices() {
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 (!watcherPort || !cdpPort) return false;
45
- if (!portHasListener(watcherPort)) return false;
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 portHasListener(port) {
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
- return out.split(/\s+/u).some((value) => /^\d+$/u.test(value));
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
- async function extensionReattach(target) {
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);