@deeeed/metamask-harness 0.34.3 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.35.0 - 2026-08-12
6
+
7
+ ### Fixed
8
+
9
+ - Make Extension and Mobile launch verification fail when their in-app EVM network client cannot complete a live RPC, with exact Infura configuration and retry guidance.
10
+ - Make Extension doctor require checkout-pinned Playwright Chromium and report the exact install command when it is missing.
11
+ - Make doctor report the exact dependency-install command when `--fix` finds stale or missing dependencies instead of implying they were repaired.
12
+ - Make the setup prompt reuse configured skill sources and require consent before cloning or editing shell startup files.
13
+ - Keep installed overlays delegated to the source runner that installed them before falling back to a potentially stale global runner.
14
+
15
+ ## 0.34.4 - 2026-08-12
16
+
17
+ ### Changed
18
+
19
+ - Split the site quick start into one-time setup and task-focused workflow tabs.
20
+
21
+ ### Fixed
22
+
23
+ - Update the transitive `tar` dependency from 7.5.19 to 7.5.22.
24
+ - Update transitive `js-yaml`, `nanoid`, and `postcss` dependencies.
25
+
5
26
  ## 0.34.3 - 2026-08-12
6
27
 
7
28
  ### Added
@@ -88,11 +88,11 @@ install_v1_runner_assets() {
88
88
  if [ -n "$METAMASK_RUNNER_PROTOCOL_ROOT" ]; then
89
89
  printf 'export FARMSLOT_ROOT=${FARMSLOT_ROOT:-%s}\n' "$runner_protocol_root_q"
90
90
  fi
91
- # Resolve the runner at run time (override > global install > install-time
92
- # record); the recorded path is last resort so a moved checkout is not pinned.
91
+ # The recorded runner owns the installed overlay and must remain ahead of an
92
+ # unrelated global version. MM_HARNESS_BIN is the explicit override.
93
93
  printf '%s\n' 'if [ -n "${MM_HARNESS_BIN:-}" ] && [ -x "${MM_HARNESS_BIN}" ]; then exec "${MM_HARNESS_BIN}" "$@"; fi'
94
- printf '%s\n' 'if command -v mm-harness >/dev/null 2>&1; then exec mm-harness "$@"; fi'
95
94
  printf 'if [ -x %s ]; then exec %s "$@"; fi\n' "$runner_exec_q" "$runner_exec_q"
95
+ printf '%s\n' 'if command -v mm-harness >/dev/null 2>&1; then exec mm-harness "$@"; fi'
96
96
  printf '%s\n' 'echo "mm-harness not found. Next: npm i -g @deeeed/metamask-harness" >&2'
97
97
  printf '%s\n' 'exit 127'
98
98
  } > "$HARNESS_DIR/runner/bin/mm-harness"
@@ -69,17 +69,16 @@ fs.mkdirSync(path.join(harnessDir, 'runner/recipes'), { recursive: true });
69
69
  fs.rmSync(path.join(harnessDir, 'scripts'), { recursive: true, force: true });
70
70
  fs.mkdirSync(path.join(harnessDir, 'scripts/lib'), { recursive: true });
71
71
 
72
- // Resolve the runner at run time so a moved or shared checkout is never bound to
73
- // an install-time absolute path: explicit override, then a global install, then
74
- // the runner recorded at install time as a last resort; teach install on miss.
72
+ // Prefer the runner that installed the overlay so its runtime checks match the
73
+ // installed scripts. Explicit overrides still support controlled delegation.
75
74
  const pinnedBin = shellQuote(path.join(runnerDir, 'bin/mm-harness'));
76
75
  const delegate = [
77
76
  '#!/usr/bin/env bash',
78
77
  'set -euo pipefail',
79
78
  protocolRoot ? `export FARMSLOT_ROOT=\${FARMSLOT_ROOT:-${shellQuote(protocolRoot)}}` : null,
80
79
  'if [ -n "${MM_HARNESS_BIN:-}" ] && [ -x "${MM_HARNESS_BIN}" ]; then exec "${MM_HARNESS_BIN}" "$@"; fi',
81
- 'if command -v mm-harness >/dev/null 2>&1; then exec mm-harness "$@"; fi',
82
80
  `if [ -x ${pinnedBin} ]; then exec ${pinnedBin} "$@"; fi`,
81
+ 'if command -v mm-harness >/dev/null 2>&1; then exec mm-harness "$@"; fi',
83
82
  'echo "mm-harness not found. Next: npm i -g @deeeed/metamask-harness" >&2',
84
83
  'exit 127',
85
84
  ].filter(Boolean).join('\n') + '\n';
@@ -44,7 +44,7 @@ for rel in "$HARNESS_REL" "$HARNESS_REL/runner" "$HARNESS_REL/scripts"; do
44
44
  done
45
45
  done
46
46
 
47
- rm -rf "$HARNESS_DIR/runner" "$HARNESS_DIR/scripts"
47
+ rm -rf "$HARNESS_DIR/runner" "$HARNESS_DIR/scripts" "$HARNESS_DIR/bridge-runtime"
48
48
  mkdir -p "$HARNESS_DIR/runner/bin" "$HARNESS_DIR/runner/manifests" "$HARNESS_DIR/runner/recipes" "$HARNESS_DIR/scripts/lib"
49
49
 
50
50
  RUNNER_EXEC_Q="$(printf '%q' "$RUNNER_DIR/bin/mm-harness")"
@@ -62,8 +62,8 @@ PROTOCOL_ROOT_Q="$(printf '%q' "$PROTOCOL_ROOT")"
62
62
  printf 'export METAMASK_RUNNER_PROTOCOL_ROOT=${METAMASK_RUNNER_PROTOCOL_ROOT:-%s}\n' "$PROTOCOL_ROOT_Q"
63
63
  fi
64
64
  printf '%s\n' 'if [ -n "${MM_HARNESS_BIN:-}" ] && [ -x "$MM_HARNESS_BIN" ]; then exec "$MM_HARNESS_BIN" "$@"; fi'
65
- printf '%s\n' 'if command -v mm-harness >/dev/null 2>&1; then exec mm-harness "$@"; fi'
66
65
  printf 'if [ -x %s ]; then exec %s "$@"; fi\n' "$RUNNER_EXEC_Q" "$RUNNER_EXEC_Q"
66
+ printf '%s\n' 'if command -v mm-harness >/dev/null 2>&1; then exec mm-harness "$@"; fi'
67
67
  printf '%s\n' 'echo "mm-harness not found. Next: npm i -g @deeeed/metamask-harness@latest" >&2' 'exit 127'
68
68
  } > "$HARNESS_DIR/runner/bin/mm-harness"
69
69
  chmod +x "$HARNESS_DIR/runner/bin/mm-harness"
@@ -77,6 +77,7 @@ rsync -a --delete "$RUNNER_DIR/library/recipes/" "$HARNESS_DIR/runner/recipes/"
77
77
  rm -rf "$HARNESS_DIR/runner/flows"
78
78
  rm -f "$HARNESS_DIR/runner/library.json"
79
79
  cp "$RUNNER_DIR/adapters/mobile/verify.sh" "$HARNESS_DIR/scripts/verify.sh"
80
+ cp -R "$RUNNER_DIR/adapters/mobile/bridge-runtime" "$HARNESS_DIR/bridge-runtime"
80
81
  cp "$RUNNER_DIR/adapters/shared/harness-path.sh" "$HARNESS_DIR/scripts/lib/harness-path.sh"
81
82
  cp "$RUNNER_DIR/adapters/shared/path-defaults.json" "$HARNESS_DIR/scripts/lib/path-defaults.json"
82
83
  chmod -R u+rwX,go+rX "$HARNESS_DIR"
@@ -114,7 +115,7 @@ fs.writeFileSync(manifestPath, JSON.stringify({
114
115
  actionManifestPath: `${harnessRel}/action-manifest.json`,
115
116
  runnerEntrypoint: `${harnessRel}/runner/bin/mm-harness`,
116
117
  runtimeHelpers: { verify: `${harnessRel}/scripts/verify.sh` },
117
- harnessInstalledPaths: [`${harnessRel}/runner`, `${harnessRel}/scripts`, `${harnessRel}/action-manifest.json`],
118
+ harnessInstalledPaths: [`${harnessRel}/runner`, `${harnessRel}/scripts`, `${harnessRel}/bridge-runtime`, `${harnessRel}/action-manifest.json`],
118
119
  productOwnedPaths: ['app/dev-tools/AgenticService', 'app/core/NavigationService/NavigationService.ts'],
119
120
  cleanupCommand,
120
121
  productDiffExcludes: [`:(exclude)${harnessRoot}`],
@@ -325,6 +325,35 @@ JSON
325
325
  ) > "$log_path" 2>&1
326
326
  }
327
327
 
328
+ evm_rpc_ready() {
329
+ local log_path="$1"
330
+ local bridge="$HARNESS_DIR/bridge-runtime/cdp-bridge.cjs"
331
+ local expression
332
+ expression="(async function(){try{var engine=globalThis.Engine;var controller=engine&&engine.context&&engine.context.NetworkController;if(!controller||typeof controller.getSelectedNetworkClient!=='function')throw new Error('NetworkController selected client is unavailable');var client=controller.getSelectedNetworkClient();if(!client||!client.provider||typeof client.provider.request!=='function')throw new Error('selected EVM provider is unavailable');var result=await client.provider.request({method:'eth_blockNumber'});if(typeof result!=='string'||!/^0x[0-9a-f]+$/i.test(result))throw new Error('eth_blockNumber returned an invalid result');return {ok:true,result:result};}catch(error){return {ok:false,error:error&&(error.message||String(error))};}})()"
333
+ [ -f "$bridge" ] || {
334
+ printf 'Mobile bridge is missing at %s\n' "$bridge" > "$log_path"
335
+ return 1
336
+ }
337
+ if ! run_with_timeout "$log_path" 25 env \
338
+ APP_ROOT="$TARGET" \
339
+ WATCHER_PORT="$port" \
340
+ IOS_SIMULATOR="${IOS_SIMULATOR:-$ios_simulator_resolved}" \
341
+ ADB_SERIAL="${ADB_SERIAL:-$adb_serial_resolved}" \
342
+ ANDROID_SERIAL="${ANDROID_SERIAL:-$adb_serial_resolved}" \
343
+ node "$bridge" eval-async "$expression"; then
344
+ return 1
345
+ fi
346
+ node - "$log_path" <<'NODE'
347
+ const fs = require('fs');
348
+ try {
349
+ const result = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
350
+ process.exit(result.ok === true ? 0 : 1);
351
+ } catch {
352
+ process.exit(1);
353
+ }
354
+ NODE
355
+ }
356
+
328
357
  ensure_live_runtime() {
329
358
  local device_target="$1"
330
359
  local attempt
@@ -419,6 +448,14 @@ JSON
419
448
  add_note "Runner v1 live bridge smoke failed; inspect logs/runner-live-smoke.log and runner-live-smoke/trace.json."
420
449
  status="fail"
421
450
  fi
451
+
452
+ if evm_rpc_ready "$ARTIFACTS/logs/evm-rpc-readiness.log"; then
453
+ checks+=("{\"name\":\"live EVM RPC readiness\",\"status\":\"pass\"}")
454
+ else
455
+ checks+=("{\"name\":\"live EVM RPC readiness\",\"status\":\"fail\",\"detail\":\"see logs/evm-rpc-readiness.log\"}")
456
+ add_note "EVM RPC readiness probe failed. Infura configured ≠ RPC reachable. Next: confirm $TARGET/.js.env has a real MM_INFURA_PROJECT_ID (not 00000000000), then rerun mm-harness launch $PLATFORM --verify --target '$TARGET'."
457
+ status="fail"
458
+ fi
422
459
  fi
423
460
 
424
461
  RECIPE_HARNESS_PREFLIGHT_MODE="$PREFLIGHT_MODE" RECIPE_HARNESS_ROOT_EXCLUDE="$HARNESS_ROOT" node - "$ARTIFACTS" "$TARGET" "$status" "${checks[@]}" <<'NODE'
@@ -153,12 +153,26 @@ async function checkExtensionRuntimeHealth(projectRoot, cdpPort, options = {}) {
153
153
  if (runtime.backgroundUnresponsive === true) {
154
154
  findings.push("Extension UI reports background connection unresponsive.");
155
155
  }
156
+ if (runtime.ethereumConnectionUnavailable === true) {
157
+ findings.push('Extension UI reports "Unable to connect to Ethereum".');
158
+ }
159
+ if (runtime.evmRpcProbeOk !== true) {
160
+ const probeError = String(runtime.evmRpcProbeError ?? "unknown error").replace(/[.\s]+$/u, "");
161
+ findings.push(
162
+ `EVM RPC readiness probe failed: ${probeError}. Infura configured \u2260 RPC reachable.`
163
+ );
164
+ }
156
165
  if (runtime.hasSubmitRequest === true && runtime.backgroundProbeOk !== true) {
157
166
  warnings.push(`Perps background read probe failed: ${runtime.backgroundProbeError ?? "unknown error"}.`);
158
167
  }
159
168
  const extensionId = safeExtensionId(target);
169
+ const evmRpcUnreachable = runtime.ethereumConnectionUnavailable === true || runtime.evmRpcProbeOk !== true;
160
170
  return {
161
171
  status: findings.length === 0 ? "PASS" : "FAIL",
172
+ ...evmRpcUnreachable ? {
173
+ errorCode: "EVM_RPC_UNREACHABLE",
174
+ userAction: evmRpcRecoveryAction(projectRoot)
175
+ } : {},
162
176
  warnings,
163
177
  cdpPort,
164
178
  targetUrl: target.url,
@@ -219,21 +233,42 @@ async function evaluateHealth(session, timeoutMs) {
219
233
  const storeState = hooks.store?.getState?.() || {};
220
234
  const manager = hooks.getPerpsStreamManager?.();
221
235
  const accountCache = manager?.account?.cache;
236
+ const networkConnectionBanner = storeState.metamask?.networkConnectionBanner;
237
+ const networkClientId = storeState.metamask?.selectedNetworkClientId || networkConnectionBanner?.networkClientId;
222
238
  return Promise.race([
223
239
  (async () => {
224
- let backgroundProbeOk = false;
225
- let backgroundProbeError = null;
226
- if (typeof hooks.submitRequestToBackground === 'function') {
227
- try {
228
- const accountState = await hooks.submitRequestToBackground('perpsGetAccountState', []);
229
- backgroundProbeOk = Boolean(accountState && typeof accountState === 'object');
230
- if (!backgroundProbeOk) backgroundProbeError = 'perpsGetAccountState returned an empty result';
231
- } catch (error) {
232
- backgroundProbeError = String(error?.message || error);
233
- }
234
- } else {
235
- backgroundProbeError = 'submitRequestToBackground is not a function';
236
- }
240
+ const [backgroundProbe, evmRpcProbe] = await Promise.all([
241
+ (async () => {
242
+ if (typeof hooks.submitRequestToBackground !== 'function') {
243
+ return { ok: false, error: 'submitRequestToBackground is not a function' };
244
+ }
245
+ try {
246
+ const accountState = await hooks.submitRequestToBackground('perpsGetAccountState', []);
247
+ const ok = Boolean(accountState && typeof accountState === 'object');
248
+ return { ok, error: ok ? null : 'perpsGetAccountState returned an empty result' };
249
+ } catch (error) {
250
+ return { ok: false, error: String(error?.message || error) };
251
+ }
252
+ })(),
253
+ (async () => {
254
+ if (typeof hooks.submitRequestToBackground !== 'function') {
255
+ return { ok: false, error: 'submitRequestToBackground is not a function' };
256
+ }
257
+ if (typeof networkClientId !== 'string' || networkClientId.length === 0) {
258
+ return { ok: false, error: 'selected EVM network client is unavailable' };
259
+ }
260
+ try {
261
+ const code = await hooks.submitRequestToBackground('getCode', [
262
+ '0x0000000000000000000000000000000000000000',
263
+ networkClientId,
264
+ ]);
265
+ const ok = typeof code === 'string' && code.startsWith('0x');
266
+ return { ok, error: ok ? null : 'background EVM RPC returned an invalid result' };
267
+ } catch (error) {
268
+ return { ok: false, error: String(error?.message || error) };
269
+ }
270
+ })(),
271
+ ]);
237
272
  return {
238
273
  href: location.href,
239
274
  title: document.title,
@@ -242,14 +277,17 @@ async function evaluateHealth(session, timeoutMs) {
242
277
  hasStore: Boolean(hooks.store),
243
278
  hasPerpsStreamManager: typeof hooks.getPerpsStreamManager === 'function',
244
279
  backgroundUnresponsive: bodyText.includes('Background connection unresponsive') || bodyText.includes('MetaMask had trouble starting'),
280
+ ethereumConnectionUnavailable: bodyText.includes('Unable to connect to Ethereum'),
245
281
  activeProvider: storeState.metamask?.activeProvider || null,
246
282
  isTestnet: Boolean(storeState.metamask?.isTestnet),
247
283
  perpsManagerInitialized: Boolean(manager?.isInitialized?.()),
248
284
  positionsCacheIsArray: Array.isArray(manager?.positions?.cache),
249
285
  ordersCacheIsArray: Array.isArray(manager?.orders?.cache),
250
286
  accountCachePresent: Boolean(accountCache && typeof accountCache === 'object'),
251
- backgroundProbeOk,
252
- backgroundProbeError,
287
+ backgroundProbeOk: backgroundProbe.ok,
288
+ backgroundProbeError: backgroundProbe.error,
289
+ evmRpcProbeOk: evmRpcProbe.ok,
290
+ evmRpcProbeError: evmRpcProbe.error,
253
291
  };
254
292
  })(),
255
293
  new Promise((resolve) => setTimeout(() => resolve({
@@ -260,8 +298,11 @@ async function evaluateHealth(session, timeoutMs) {
260
298
  hasStore: Boolean(hooks.store),
261
299
  hasPerpsStreamManager: typeof hooks.getPerpsStreamManager === 'function',
262
300
  backgroundUnresponsive: bodyText.includes('Background connection unresponsive') || bodyText.includes('MetaMask had trouble starting'),
301
+ ethereumConnectionUnavailable: bodyText.includes('Unable to connect to Ethereum'),
263
302
  backgroundProbeOk: false,
264
303
  backgroundProbeError: 'perpsGetAccountState timed out after ${backgroundProbeTimeoutMs}ms',
304
+ evmRpcProbeOk: false,
305
+ evmRpcProbeError: 'background EVM RPC timed out after ${backgroundProbeTimeoutMs}ms',
265
306
  }), ${backgroundProbeTimeoutMs})),
266
307
  ]);
267
308
  })()`,
@@ -293,6 +334,9 @@ function compositorRecoveryAction(projectRoot) {
293
334
  const recovery = `mm-harness launch --verify --target ${JSON.stringify(projectRoot)}`;
294
335
  return process.platform === "darwin" ? `Unlock the macOS session, then rerun: ${recovery}` : `Restore an active display compositor, then rerun: ${recovery}`;
295
336
  }
337
+ function evmRpcRecoveryAction(projectRoot) {
338
+ return `Confirm ${shellQuote(path.join(projectRoot, ".metamaskrc"))} has a real INFURA_PROJECT_ID (not 00000000000), then rerun: mm-harness launch --verify --target ${shellQuote(projectRoot)}`;
339
+ }
296
340
  function resolveCdpPort(rawPort, slot) {
297
341
  const raw = rawPort ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT ?? slot?.cdpPort;
298
342
  const port = Number(raw);
@@ -759,6 +803,7 @@ export {
759
803
  assertHealthyExtensionRuntime,
760
804
  checkExtensionRuntimeHealth,
761
805
  compositorRecoveryAction,
806
+ evmRpcRecoveryAction,
762
807
  extensionBackgroundProbeTimeoutMs,
763
808
  formatHealthFailure,
764
809
  prepareExtensionRuntime
@@ -14,6 +14,10 @@ function watcherStatus(buildLog) {
14
14
  if (buildLog === "no-watch") return "down";
15
15
  return buildLog;
16
16
  }
17
+ function extensionRuntimeNextAction(reasonCode, target) {
18
+ const resolved = shellQuote(path.resolve(target));
19
+ return reasonCode === "deps-missing" || reasonCode === "deps-stale" ? `cd ${resolved} && yarn install --immutable` : `mm-harness launch --adapter extension --target ${resolved}`;
20
+ }
17
21
  const extensionSurface = {
18
22
  adapter: "extension",
19
23
  headless: false,
@@ -42,7 +46,7 @@ const extensionSurface = {
42
46
  decision: report.decision,
43
47
  reasonCode: report.reasonCode,
44
48
  reasons: report.reasons,
45
- nextAction: report.decision === "ready" ? void 0 : `mm-harness launch --adapter extension --target ${shellQuote(path.resolve(target))}`,
49
+ nextAction: report.decision === "ready" ? void 0 : extensionRuntimeNextAction(report.reasonCode, target),
46
50
  deps: report.checks.deps.status,
47
51
  devServer: {
48
52
  label: "webpack",
@@ -96,5 +100,6 @@ const extensionSurface = {
96
100
  }
97
101
  };
98
102
  export {
103
+ extensionRuntimeNextAction,
99
104
  extensionSurface
100
105
  };
@@ -21,20 +21,28 @@ const ENV_INPUTS = [".js.env", ".env", ".env.local"];
21
21
  const BASELINE_FILE = "mobile-source-baseline.json";
22
22
  function mobileSourceFingerprint(target) {
23
23
  const hash = createHash("sha256");
24
- hash.update(git(target, ["rev-parse", "HEAD"]));
25
- hash.update(git(target, ["diff", "--no-ext-diff", "--binary", "HEAD", "--", ...SOURCE_PATHS]));
26
- const untracked = git(target, [
27
- "ls-files",
28
- "--others",
29
- "--exclude-standard",
30
- "-z",
31
- "--",
32
- ...SOURCE_PATHS
33
- ]).toString("utf8").split("\0").filter(Boolean).sort();
34
- for (const relative of untracked) {
35
- hash.update(`untracked\0${relative}\0`);
36
- hash.update(fs.readFileSync(path.join(target, relative)));
37
- hash.update("\0");
24
+ const head = tryGit(target, ["rev-parse", "HEAD"]);
25
+ if (head === null) {
26
+ hash.update("non-git\0");
27
+ for (const relative of SOURCE_PATHS) {
28
+ hashPath(hash, target, relative);
29
+ }
30
+ } else {
31
+ hash.update(head);
32
+ hash.update(git(target, ["diff", "--no-ext-diff", "--binary", "HEAD", "--", ...SOURCE_PATHS]));
33
+ const untracked = git(target, [
34
+ "ls-files",
35
+ "--others",
36
+ "--exclude-standard",
37
+ "-z",
38
+ "--",
39
+ ...SOURCE_PATHS
40
+ ]).toString("utf8").split("\0").filter(Boolean).sort();
41
+ for (const relative of untracked) {
42
+ hash.update(`untracked\0${relative}\0`);
43
+ hash.update(fs.readFileSync(path.join(target, relative)));
44
+ hash.update("\0");
45
+ }
38
46
  }
39
47
  for (const relative of ENV_INPUTS) {
40
48
  const absolute = path.join(target, relative);
@@ -84,6 +92,35 @@ function git(target, args) {
84
92
  stdio: ["ignore", "pipe", "ignore"]
85
93
  });
86
94
  }
95
+ function tryGit(target, args) {
96
+ try {
97
+ return git(target, args);
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+ function hashPath(hash, root, relative) {
103
+ const absolute = path.join(root, relative);
104
+ if (!fs.existsSync(absolute)) {
105
+ hash.update(`absent\0${relative}\0`);
106
+ return;
107
+ }
108
+ const stat = fs.lstatSync(absolute);
109
+ if (stat.isDirectory()) {
110
+ hash.update(`directory\0${relative}\0`);
111
+ for (const child of fs.readdirSync(absolute).sort()) {
112
+ hashPath(hash, root, path.join(relative, child));
113
+ }
114
+ return;
115
+ }
116
+ if (stat.isSymbolicLink()) {
117
+ hash.update(`symlink\0${relative}\0${fs.readlinkSync(absolute)}\0`);
118
+ return;
119
+ }
120
+ hash.update(`file\0${relative}\0`);
121
+ hash.update(fs.readFileSync(absolute));
122
+ hash.update("\0");
123
+ }
87
124
  function readBaseline(target) {
88
125
  try {
89
126
  const parsed = JSON.parse(
@@ -5,6 +5,10 @@ import { shellQuote } from "../../commands/parse-args.js";
5
5
  import { resolveMobileSlotPorts } from "../slot-ports.js";
6
6
  import { mobileRuntimeStatus } from "./prepare.js";
7
7
  import { hasRunwayProvisionBaseline, provisionRunwayMobile } from "./provision.js";
8
+ function mobileRuntimeNextAction(reasonCode, target, platform) {
9
+ const resolved = shellQuote(path.resolve(target));
10
+ return reasonCode === "deps-missing" || reasonCode === "deps-partial" || reasonCode === "deps-stale" ? `cd ${resolved} && yarn setup` : `mm-harness launch ${platform} --adapter mobile --target ${resolved}`;
11
+ }
8
12
  const mobileSurface = {
9
13
  adapter: "mobile",
10
14
  headless: false,
@@ -21,7 +25,7 @@ const mobileSurface = {
21
25
  decision: depsPending ? "launch" : report.decision,
22
26
  reasonCode: depsPending ? "app-installed-deps-pending" : report.reasonCode,
23
27
  reasons: depsPending ? ["Runway app is installed; JavaScript dependencies are pending until dispatch-time launch."] : report.reasons,
24
- nextAction: report.decision === "ready" && !depsPending ? void 0 : `mm-harness launch ${platform} --adapter mobile --target ${shellQuote(path.resolve(target))}`,
28
+ nextAction: report.decision === "ready" && !depsPending ? void 0 : mobileRuntimeNextAction(report.reasonCode, target, platform),
25
29
  deps: runwayProvisioned && report.checks?.deps?.status !== "current" ? "pending" : report.checks?.deps?.status,
26
30
  devServer: { label: "metro", status: report.checks?.metro?.status ?? "unprobed" }
27
31
  };
@@ -58,5 +62,6 @@ const mobileSurface = {
58
62
  }
59
63
  };
60
64
  export {
65
+ mobileRuntimeNextAction,
61
66
  mobileSurface
62
67
  };
package/dist/doctor.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs";
2
+ import { createRequire } from "node:module";
2
3
  import path from "node:path";
3
4
  import { color } from "./cli-color.js";
4
5
  import {
@@ -148,7 +149,8 @@ function createDoctorReport(adapter, target, manifestValidation, actionManifestP
148
149
  required: false,
149
150
  message: mode === "unsupported/no bridge" ? `No ${adapter} bridge is available for this checkout.` : `${adapter} compatibility mode: ${mode}.`
150
151
  },
151
- ...adapter === "mobile" ? mobileToolDoctorChecks(mobilePlatform) : []
152
+ ...adapter === "mobile" ? mobileToolDoctorChecks(mobilePlatform) : [],
153
+ ...adapter === "extension" ? [extensionBrowserDoctorCheck(target)] : []
152
154
  ];
153
155
  const requiredChecks = requiredDoctorCheckSummary(checks);
154
156
  return {
@@ -169,6 +171,32 @@ function createDoctorReport(adapter, target, manifestValidation, actionManifestP
169
171
  manifestValidation: manifestValidation.summary
170
172
  };
171
173
  }
174
+ function extensionBrowserDoctorCheck(target) {
175
+ const requireFromTarget = createRequire(path.join(path.resolve(target), "package.json"));
176
+ for (const packageName of ["@playwright/test", "playwright"]) {
177
+ try {
178
+ const playwright = requireFromTarget(packageName);
179
+ const executable = playwright.chromium?.executablePath?.();
180
+ if (executable && fs.existsSync(executable)) {
181
+ return {
182
+ id: "playwright-chromium",
183
+ status: "pass",
184
+ required: true,
185
+ message: "Checkout-pinned Playwright Chromium is installed.",
186
+ detail: executable
187
+ };
188
+ }
189
+ } catch {
190
+ }
191
+ }
192
+ return {
193
+ id: "playwright-chromium",
194
+ status: "fail",
195
+ required: true,
196
+ message: "Checkout-pinned Playwright Chromium is not installed.",
197
+ userAction: "yarn playwright install chromium"
198
+ };
199
+ }
172
200
  function mobileToolDoctorChecks(platform) {
173
201
  const specs = platform === "android" ? [{ tool: "adb", platform: "Android" }] : platform === "ios" ? [{ tool: "idb", platform: "iOS" }] : [
174
202
  { tool: "adb", platform: "Android" },
@@ -250,6 +278,7 @@ function readJsonObject(file) {
250
278
  export {
251
279
  compatibilityMode,
252
280
  createDoctorReport,
281
+ extensionBrowserDoctorCheck,
253
282
  fixtureFileSummary,
254
283
  fixtureSummary,
255
284
  mobileToolDoctorChecks,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.34.3",
3
+ "version": "0.35.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -146,6 +146,7 @@ const REVEAL = String.raw`
146
146
  const d = document.getElementById(l.getAttribute('aria-controls'));
147
147
  if (d) d.hidden = false;
148
148
  });
149
+ document.querySelectorAll('[role="tabpanel"][hidden]').forEach((p) => { p.hidden = false; });
149
150
  return true;
150
151
  })()
151
152
  `;
@@ -331,6 +332,9 @@ async function auditPage(browserEndpoint, url) {
331
332
  async function checkBehavior(browserEndpoint, port, pages) {
332
333
  const base = `http://127.0.0.1:${port}/`;
333
334
  await withPage(browserEndpoint, `${base}index.html`, async (page) => {
335
+ // Step 1 is the hero call to action; it was a collapsible before, so accept
336
+ // either shape. Step 2 is a tabbed set of prompts, one per kind of work —
337
+ // every one of them has to survive a copy, including the hidden panels.
334
338
  const copied = await page.evaluate(String.raw`
335
339
  (async () => {
336
340
  Object.defineProperty(navigator, 'clipboard', {
@@ -342,22 +346,45 @@ async function checkBehavior(browserEndpoint, port, pages) {
342
346
  },
343
347
  },
344
348
  });
345
- // The onboarding prompt is the hero call to action; it was a collapsible
346
- // before, so accept either shape.
347
- const button = document.querySelector('.cmd-hero .copy, .prompt .copy');
348
- if (!button) throw new Error('copy button was not initialized');
349
- button.click();
350
- await new Promise((resolve) => setTimeout(resolve, 25));
351
- return window.__siteCopied;
349
+ async function grab(button) {
350
+ if (!button) throw new Error('copy button was not initialized');
351
+ window.__siteCopied = null;
352
+ button.click();
353
+ await new Promise((resolve) => setTimeout(resolve, 25));
354
+ return window.__siteCopied;
355
+ }
356
+ const out = { setup: await grab(document.querySelector('.cmd-hero .copy, .prompt .copy')), work: [] };
357
+ for (const tab of document.querySelectorAll('[role="tab"]')) {
358
+ tab.click();
359
+ const panel = document.getElementById(tab.getAttribute('aria-controls'));
360
+ if (!panel || panel.hidden) throw new Error('tab ' + tab.id + ' did not reveal its panel');
361
+ // Panels ship visible so a dead module degrades to readable prompts
362
+ // rather than dead controls — which also means "revealed" alone
363
+ // proves nothing. Selecting one has to hide the others.
364
+ const shown = [...document.querySelectorAll('[role="tabpanel"]')].filter((p) => !p.hidden);
365
+ if (shown.length !== 1) {
366
+ throw new Error('tab ' + tab.id + ' left ' + shown.length + ' panels visible');
367
+ }
368
+ out.work.push(await grab(panel.querySelector('.cmd .copy')));
369
+ }
370
+ return out;
352
371
  })()
353
372
  `);
354
- // Both halves of the onboarding have to survive a copy: set up the harness
355
- // and the skills, then drive real work through one.
356
- const expected = ['npm i -g @deeeed/metamask-harness@latest', 'yarn skills', '/mms-recipe-cook'];
357
- const missing = expected.filter((line) => !copied?.includes(line));
373
+ // Setting up installs the harness and the skills; the work prompts invoke
374
+ // them by their exact installed names.
375
+ const expected = [
376
+ ['npm i -g @deeeed/metamask-harness@latest', copied.setup],
377
+ ['yarn skills', copied.setup],
378
+ ['/mms-recipe-pr-qa-review', copied.work.join('\n')],
379
+ ['/mms-recipe-cook', copied.work.join('\n')],
380
+ ];
381
+ const missing = expected.filter(([line, text]) => !text?.includes(line)).map(([line]) => line);
358
382
  if (missing.length) {
359
383
  throw new Error(`copy prompt omitted ${missing.map((m) => `"${m}"`).join(', ')}`);
360
384
  }
385
+ if (copied.work.length < 3) {
386
+ throw new Error(`expected three work prompts, copied ${copied.work.length}`);
387
+ }
361
388
 
362
389
  // The landing page is the lobby: one prompt, one way onward, nothing else.
363
390
  const lobby = await page.evaluate(String.raw`
@@ -5,7 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Architecture — how the pieces fit</title>
7
7
  <meta name="description" content="Skills, mm-harness, team recipe libraries, product repos: what lives where, the three workflows, and the mistakes newcomers make.">
8
- <link rel="stylesheet" href="assets/style.css">
8
+ <link rel="icon" href="assets/metamask-fox.svg" type="image/svg+xml">
9
+ <link rel="stylesheet" href="assets/style.css?v=4">
9
10
  </head>
10
11
  <body data-progress-page="architecture">
11
12
  <a class="skip" href="#stack">Skip to the stack map</a>
@@ -13,18 +14,16 @@
13
14
  <header class="topbar">
14
15
  <div class="wrap topbar-inner">
15
16
  <a class="brand" href="index.html">
16
- <span class="brand-mark" aria-hidden="true"></span>
17
+ <img class="brand-mark" src="assets/metamask-fox.svg" alt="" width="22" height="22">
17
18
  <span class="brand-name">recipes</span>
18
19
  </a>
19
20
  <nav class="nav" aria-label="Main">
20
21
  <a class="nav-cta" href="index.html#prompt">Quick start</a>
21
22
  <a href="how-it-works.html">How it works</a>
22
- <a href="recipes.html">Recipe anatomy</a>
23
- <a href="perps.html">Perps</a>
23
+ <a href="perps.html">Team / Perps</a>
24
24
  <a href="cheatsheet.html">Cheatsheet</a>
25
25
  <a href="architecture.html" aria-current="page">Architecture</a>
26
26
  <a href="tutorials/index.html">Tutorials</a>
27
- <a href="reviewers.html">For Reviewers</a>
28
27
  </nav>
29
28
  </div>
30
29
  </header>
@@ -471,8 +470,16 @@
471
470
  </ol>
472
471
 
473
472
  <hr class="sep">
473
+ <h2>Which repo is which</h2>
474
+ <p>
475
+ The layers above are the design. The <a href="ecosystem.html">Ecosystem map</a> is the concrete
476
+ version: the six repositories and packages behind a run, each with its URL, and which way the
477
+ dependencies point.
478
+ </p>
479
+
474
480
  <div class="btn-row">
475
- <a class="btn btn-primary" href="how-it-works.html#steps">Do the walkthrough →</a>
481
+ <a class="btn btn-primary" href="ecosystem.html">See the repo map →</a>
482
+ <a class="btn btn-ghost" href="how-it-works.html#steps">Do the walkthrough</a>
476
483
  <a class="btn btn-ghost" href="reviewers.html">Read an evidence bundle</a>
477
484
  </div>
478
485
  </section>
@@ -485,6 +492,6 @@
485
492
  </div>
486
493
  </footer>
487
494
 
488
- <script type="module" src="assets/progress.mjs"></script>
495
+ <script type="module" src="assets/progress.mjs?v=4"></script>
489
496
  </body>
490
497
  </html>