@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
@@ -36,6 +36,11 @@ fi
36
36
  shift || true
37
37
 
38
38
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
39
+ # shellcheck disable=SC1091
40
+ for _hp in "$SCRIPT_DIR/lib/harness-path.sh" "$SCRIPT_DIR/../../adapters/shared/harness-path.sh" "$SCRIPT_DIR/../shared/harness-path.sh"; do
41
+ [ -f "$_hp" ] && { . "$_hp"; break; }
42
+ done
43
+ unset _hp
39
44
  REPO="${REPO:-}"
40
45
  if [ -z "$REPO" ]; then
41
46
  d="$SCRIPT_DIR"
@@ -52,6 +57,12 @@ if [ -z "$REPO" ]; then
52
57
  exit 1
53
58
  fi
54
59
  cd "$REPO"
60
+ if command -v recipe_runtime_dir >/dev/null 2>&1; then
61
+ RUNTIME_DIR="$(recipe_runtime_dir)"
62
+ else
63
+ RUNTIME_DIR="${RECIPE_RUNTIME_DIR:-temp/recipe/runtime}"
64
+ fi
65
+ RUNTIME_DIST_DIR="${RECIPE_RUNTIME_DIST_DIR:-runtime-dist}"
55
66
 
56
67
  CDP_PORT="${CDP_PORT:-}"
57
68
  EXT_ID="${EXT_ID:-}"
@@ -94,14 +105,33 @@ find_sidepanel_id() {
94
105
  }
95
106
 
96
107
  resolve_ext_id() {
97
- if [ -n "$EXT_ID" ]; then
108
+ if [[ "$EXT_ID" =~ ^[a-p]{32}$ ]]; then
98
109
  printf '%s\n' "$EXT_ID"
99
110
  return
100
111
  fi
101
- for idf in "$AGENT_DIR/extension.id" "$REPO/temp/recipe/runtime/extension.id"; do
112
+
113
+ local manifest_id
114
+ manifest_id="$(
115
+ SCRIPT_DIR="$SCRIPT_DIR" REPO="$REPO" RUNTIME_DIR="$RUNTIME_DIR" RUNTIME_DIST_DIR="$RUNTIME_DIST_DIR" node <<'NODE' 2>/dev/null || true
116
+ const path = require('node:path');
117
+ const { extensionIdFromManifestFile } = require(path.join(process.env.SCRIPT_DIR, 'lib/extension-id.cjs'));
118
+ const id = extensionIdFromManifestFile(path.join(process.env.REPO, process.env.RUNTIME_DIR, process.env.RUNTIME_DIST_DIR, 'manifest.json'));
119
+ if (id) process.stdout.write(id);
120
+ NODE
121
+ )"
122
+ if [[ "$manifest_id" =~ ^[a-p]{32}$ ]]; then
123
+ printf '%s\n' "$manifest_id"
124
+ return
125
+ fi
126
+
127
+ for idf in "$REPO/$RUNTIME_DIR/extension.id" "$AGENT_DIR/extension.id"; do
102
128
  if [ -f "$idf" ]; then
103
- tr -d '[:space:]' < "$idf"
104
- return
129
+ local marker_id
130
+ marker_id="$(tr -d '[:space:]' < "$idf")"
131
+ if [[ "$marker_id" =~ ^[a-p]{32}$ ]]; then
132
+ printf '%s\n' "$marker_id"
133
+ return
134
+ fi
105
135
  fi
106
136
  done
107
137
  json_list | python3 -c "import json,re,sys; d=json.load(sys.stdin); ids=[]; [ids.extend(re.findall(r'^chrome-extension://([^/]+)/', t.get('url',''))) for t in d]; print(ids[0] if ids else '')"
@@ -142,6 +172,32 @@ print_targets() {
142
172
  json_list | python3 -c "import json,sys; d=json.load(sys.stdin); [print(f' {t.get(\"type\",\"?\")[:18]:18s} {t.get(\"url\",\"\")[:100]}') for t in d]"
143
173
  }
144
174
 
175
+ activate_dapp_tab() {
176
+ local target_id
177
+ target_id="$(
178
+ json_list | python3 -c "import json,sys; d=json.load(sys.stdin); pages=[t for t in d if t.get('type')=='page']; target=next((t for t in pages if t.get('url','').startswith('http://') or t.get('url','').startswith('https://')), None); print(target.get('id','') if target else '')"
179
+ )"
180
+ if [ -n "$target_id" ]; then
181
+ curl -s "http://127.0.0.1:${CDP_PORT}/json/activate/${target_id}" >/dev/null || true
182
+ fi
183
+ }
184
+
185
+ cleanup_sidepanel_tabs() {
186
+ local ext_id close_ids
187
+ ext_id="$(resolve_ext_id)"
188
+ [ -n "$ext_id" ] || return 0
189
+ close_ids="$(
190
+ json_list | EXT_ID="$ext_id" python3 -c "import json,os,sys; d=json.load(sys.stdin); ext=os.environ.get('EXT_ID',''); has_panel=any(t.get('type')=='page' and t.get('url','').startswith('chrome-extension://'+ext+'/') and 'sidepanel.html' in t.get('url','') for t in d); has_dapp=any(t.get('type')=='page' and (t.get('url','').startswith('http://') or t.get('url','').startswith('https://')) for t in d); print('\\n'.join(t.get('id','') for t in d if has_panel and has_dapp and t.get('type')=='page' and t.get('url','').startswith('chrome-extension://'+ext+'/') and 'home.html' in t.get('url','')))"
191
+ )"
192
+ if [ -n "$close_ids" ]; then
193
+ while IFS= read -r target_id; do
194
+ [ -n "$target_id" ] || continue
195
+ curl -s "http://127.0.0.1:${CDP_PORT}/json/close/${target_id}" >/dev/null || true
196
+ done <<< "$close_ids"
197
+ fi
198
+ activate_dapp_tab
199
+ }
200
+
145
201
  status_sidepanel() {
146
202
  local sp
147
203
  sp="$(find_sidepanel_id)"
@@ -165,6 +221,7 @@ close_sidepanel() {
165
221
 
166
222
  open_sidepanel() {
167
223
  if [ -n "$(find_sidepanel_id)" ]; then
224
+ cleanup_sidepanel_tabs
168
225
  echo "[sidepanel] already open"
169
226
  return 0
170
227
  fi
@@ -186,7 +243,6 @@ const { chromium } = require('playwright');
186
243
  const extId = process.env.EXT_ID;
187
244
  const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
188
245
  const context = browser.contexts()[0];
189
- let createdPage = false;
190
246
  let page = context
191
247
  .pages()
192
248
  .find(
@@ -197,7 +253,6 @@ const { chromium } = require('playwright');
197
253
 
198
254
  if (!page) {
199
255
  page = await context.newPage();
200
- createdPage = true;
201
256
  await page.goto(`chrome-extension://${extId}/home.html`, {
202
257
  waitUntil: 'domcontentloaded',
203
258
  timeout: 15000,
@@ -241,23 +296,35 @@ const { chromium } = require('playwright');
241
296
  document.getElementById('__recipe_open_sidepanel__')?.remove();
242
297
  });
243
298
 
244
- // The wallet now lives in the sidepanel: leftover extension home tabs would
245
- // show it twice and steal the foreground from the dapp tab. Close every
246
- // non-sidepanel extension page (not just one this helper created), then
247
- // bring the first http(s) tab back to front.
248
- for (const candidate of context.pages()) {
249
- if (
250
- candidate.url().startsWith(`chrome-extension://${extId}/`) &&
251
- !candidate.url().includes('/sidepanel.html')
252
- ) {
253
- try {
254
- await candidate.close();
255
- } catch (error) {
256
- console.warn(
257
- `[sidepanel] extension page close failed after successful open: ${
258
- error && error.message ? error.message : error
259
- }`,
260
- );
299
+ const deadline = Date.now() + 5000;
300
+ let sidepanelPage;
301
+ while (Date.now() < deadline) {
302
+ sidepanelPage = context
303
+ .pages()
304
+ .find((candidate) => candidate.url().startsWith(`chrome-extension://${extId}/`) && candidate.url().includes('/sidepanel.html'));
305
+ if (sidepanelPage) break;
306
+ await new Promise((resolve) => setTimeout(resolve, 200));
307
+ }
308
+
309
+ // Only collapse fullscreen extension tabs after Chrome exposes the sidepanel
310
+ // target. chrome.sidePanel.open can return before the panel page appears; if we
311
+ // close home first, a slow/failed panel leaves the operator with no visible
312
+ // wallet page and the next readiness probe has to reopen from scratch.
313
+ if (sidepanelPage) {
314
+ for (const candidate of context.pages()) {
315
+ if (
316
+ candidate.url().startsWith(`chrome-extension://${extId}/`) &&
317
+ !candidate.url().includes('/sidepanel.html')
318
+ ) {
319
+ try {
320
+ await candidate.close();
321
+ } catch (error) {
322
+ console.warn(
323
+ `[sidepanel] extension page close failed after successful open: ${
324
+ error && error.message ? error.message : error
325
+ }`,
326
+ );
327
+ }
261
328
  }
262
329
  }
263
330
  }
@@ -271,7 +338,11 @@ const { chromium } = require('playwright');
271
338
  // focus is cosmetic; the panel is already open
272
339
  }
273
340
  }
274
- await browser.close();
341
+ if (typeof browser.disconnect === 'function') {
342
+ await browser.disconnect();
343
+ } else {
344
+ await browser.close();
345
+ }
275
346
  })().catch((error) => {
276
347
  console.error(`FAIL: ${error.message || error}`);
277
348
  process.exit(4);
@@ -279,6 +350,7 @@ const { chromium } = require('playwright');
279
350
  NODE
280
351
 
281
352
  if wait_for_sidepanel; then
353
+ cleanup_sidepanel_tabs
282
354
  echo "[sidepanel] opened"
283
355
  return 0
284
356
  fi
@@ -88,7 +88,7 @@ RUNNER_BIN="$HARNESS_DIR/runner/bin/mm-harness"
88
88
  # --out (optional): a task-local recipes dir. Resolve it safely within the target
89
89
  # (resolve_harness_out rejects absolute/.. escapes) and prefer its smoke recipe so
90
90
  # `live --out <dir>` does not silently fall back to the installed default.
91
- SMOKE_RECIPE="$HARNESS_DIR/runner/recipes/smoke.extension.recipe.json"
91
+ SMOKE_RECIPE="$HARNESS_DIR/runner/recipes/runner/smoke.extension.recipe.json"
92
92
  if [ -n "$OUT" ]; then
93
93
  # Fail fast on a task-local --out that does not contain the requested recipe.
94
94
  # Silently falling back to the installed default would validate a different
@@ -98,11 +98,11 @@ if [ -n "$OUT" ]; then
98
98
  echo "extension verify: --out '$OUT' did not resolve to a safe path under the target." >&2
99
99
  exit 2
100
100
  fi
101
- if [ ! -f "$OUT_ABS/smoke.extension.recipe.json" ]; then
102
- echo "extension verify: --out '$OUT' (resolved: $OUT_ABS) has no smoke.extension.recipe.json. Refusing to fall back to the installed default recipe; place the recipe under --out or omit --out." >&2
101
+ if [ ! -f "$OUT_ABS/runner/smoke.extension.recipe.json" ]; then
102
+ echo "extension verify: --out '$OUT' (resolved: $OUT_ABS) has no runner/smoke.extension.recipe.json. Refusing to fall back to the installed default recipe; place the recipe under --out or omit --out." >&2
103
103
  exit 2
104
104
  fi
105
- SMOKE_RECIPE="$OUT_ABS/smoke.extension.recipe.json"
105
+ SMOKE_RECIPE="$OUT_ABS/runner/smoke.extension.recipe.json"
106
106
  fi
107
107
  ARTIFACTS="${ARTIFACTS:-$HARNESS_DIR/verify/$(date -u +%Y%m%dT%H%M%SZ)}"
108
108
  mkdir -p "$ARTIFACTS/logs"
@@ -385,6 +385,14 @@ if [ "$STATIC_ONLY" = false ]; then
385
385
  else
386
386
  live_mode="live"
387
387
  cdp_holder_json "$CDP_PORT" > "$ARTIFACTS/logs/cdp-holder.json"
388
+ if "$RUNNER_BIN" ensure-ready --adapter extension --target "$TARGET" --cdp-port "$CDP_PORT" --json > "$ARTIFACTS/logs/extension-ensure-ready.json" 2>&1; then
389
+ checks+=("{\"name\":\"home-tab convergence\",\"status\":\"pass\",\"detail\":\"see logs/extension-ensure-ready.json\"}")
390
+ refresh_extension_id
391
+ else
392
+ checks+=("{\"name\":\"home-tab convergence\",\"status\":\"fail\",\"detail\":\"see logs/extension-ensure-ready.json\"}")
393
+ status="fail"
394
+ fi
395
+
388
396
  if node "$READINESS_MJS" --target "$TARGET" --cdp-port "$CDP_PORT" --json > "$ARTIFACTS/logs/extension-readiness.json" 2>&1; then
389
397
  checks+=("{\"name\":\"live extension readiness\",\"status\":\"pass\"}")
390
398
  # extension-readiness.mjs may repair $RUNTIME_DIR/extension.id when the
@@ -438,6 +446,8 @@ try { distFreshness = JSON.parse(fs.readFileSync(path.join(artifacts, 'logs/dist
438
446
  try { fixtureStatus = JSON.parse(fs.readFileSync(path.join(artifacts, 'logs/fixture-status.json'), 'utf8')); } catch {}
439
447
  try { cdpHolder = JSON.parse(fs.readFileSync(path.join(artifacts, 'logs/cdp-holder.json'), 'utf8')); } catch {}
440
448
  try { readinessReport = JSON.parse(fs.readFileSync(path.join(artifacts, 'logs/extension-readiness.json'), 'utf8')); } catch {}
449
+ let ensureReadyReport = null;
450
+ try { ensureReadyReport = JSON.parse(fs.readFileSync(path.join(artifacts, 'logs/extension-ensure-ready.json'), 'utf8')); } catch {}
441
451
  function runGit(args) {
442
452
  try {
443
453
  return cp.execFileSync('git', ['-C', target, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
@@ -498,6 +508,7 @@ fs.writeFileSync(path.join(artifacts, 'summary.json'), `${JSON.stringify({
498
508
  },
499
509
  fixtureStatus,
500
510
  cdpHolder,
511
+ ensureReady: ensureReadyReport,
501
512
  cdpTarget,
502
513
  checks: parsedChecks,
503
514
  generatedAt: new Date().toISOString(),
@@ -20,6 +20,7 @@ const crypto = require('node:crypto');
20
20
  const fs = require('node:fs');
21
21
  const http = require('node:http');
22
22
  const path = require('node:path');
23
+ const { extensionIdFromManifestKey } = require('./lib/extension-id.cjs');
23
24
 
24
25
  const EOA_METHODS = [
25
26
  'personal_sign',
@@ -573,16 +574,6 @@ function extensionIdFromUrl(url) {
573
574
  return String(url).split('/')[2] || '';
574
575
  }
575
576
 
576
- function extensionIdFromManifestKey(key) {
577
- if (!key) {
578
- return '';
579
- }
580
- const digest = crypto.createHash('sha256').update(Buffer.from(key, 'base64')).digest();
581
- return [...digest.subarray(0, 16)]
582
- .map((byte) => `${'abcdefghijklmnop'[byte >> 4]}${'abcdefghijklmnop'[byte & 0x0f]}`)
583
- .join('');
584
- }
585
-
586
577
  function versionedStorageState(fixtureState) {
587
578
  return fixtureState.data
588
579
  ? { data: fixtureState.data, meta: { ...(fixtureState.meta || {}), storageKind: 'data' } }
@@ -639,6 +630,10 @@ async function detectExtension(context, extensionDir, extensionIdFile) {
639
630
  const expectedServiceWorker = manifest.background?.service_worker || '';
640
631
  const manifestId = extensionIdFromManifestKey(manifest.key);
641
632
  const rejected = new Set();
633
+ if (manifestId && extensionIdFile) {
634
+ fs.mkdirSync(path.dirname(extensionIdFile), { recursive: true });
635
+ fs.writeFileSync(extensionIdFile, `${manifestId}\n`);
636
+ }
642
637
 
643
638
  for (let attempt = 0; attempt < 30; attempt += 1) {
644
639
  const candidates = [];
@@ -648,10 +643,10 @@ async function detectExtension(context, extensionDir, extensionIdFile) {
648
643
  }
649
644
  candidates.push({ id, reason });
650
645
  };
646
+ push(manifestId, 'manifest key');
651
647
  if (extensionIdFile && fs.existsSync(extensionIdFile)) {
652
648
  push(fs.readFileSync(extensionIdFile, 'utf8').trim(), 'extension id marker');
653
649
  }
654
- push(manifestId, 'manifest key');
655
650
  for (const worker of context.serviceWorkers()) {
656
651
  const id = extensionIdFromUrl(worker.url());
657
652
  if (expectedServiceWorker && worker.url().endsWith(`/${expectedServiceWorker}`)) {
@@ -106,20 +106,47 @@ sim_udid_for_target() {
106
106
  if printf '%s' "$target" | grep -Eq '^[0-9A-Fa-f-]{36}$'; then
107
107
  printf '%s\n' "$target"; return 0
108
108
  fi
109
- xcrun simctl list devices available 2>/dev/null \
110
- | sed -n "s/.*${target} (\([0-9A-Fa-f-]*\)) (.*/\1/p" | head -1
109
+ xcrun simctl list devices available --json 2>/dev/null | TARGET_SIMULATOR="$target" node -e '
110
+ let input = "";
111
+ process.stdin.setEncoding("utf8");
112
+ process.stdin.on("data", (chunk) => { input += chunk; });
113
+ process.stdin.on("end", () => {
114
+ const target = process.env.TARGET_SIMULATOR || "";
115
+ try {
116
+ const parsed = JSON.parse(input);
117
+ for (const devices of Object.values(parsed.devices || {})) {
118
+ const found = Array.isArray(devices)
119
+ ? devices.find((device) => device && device.isAvailable !== false && device.name === target)
120
+ : null;
121
+ if (found && found.udid) {
122
+ process.stdout.write(`${found.udid}\n`);
123
+ return;
124
+ }
125
+ }
126
+ } catch {
127
+ // Empty stdout means unresolved; callers print the teaching error.
128
+ }
129
+ });
130
+ '
111
131
  }
112
132
 
113
133
  boot_simulator_if_needed() {
114
134
  local target="$1" state=""
115
135
  [ "$target" != "booted" ] || return 0
136
+ local udid=""
137
+ udid="$(sim_udid_for_target "$target")"
138
+ if [ -z "$udid" ]; then
139
+ printf "open-device: configured iOS simulator '%s' does not exist.\n" "$target" >&2
140
+ printf "Next: mm-harness provision runway ios --adapter mobile --target '%s' --device '%s'\n" "$TARGET" "$target" >&2
141
+ return 1
142
+ fi
116
143
  local line=""
117
144
  line="$(xcrun simctl list devices available 2>/dev/null | grep -F "${target} (" | head -1 || true)"
118
145
  case "$line" in *"(Booted)"*) state="Booted" ;; *"(Shutdown)"*) state="Shutdown" ;; esac
119
146
  [ "$state" = "Booted" ] && return 0
120
147
  printf 'Booting iOS simulator %s%s\n' "$target" "${state:+ ($state)}" >&2
121
- xcrun simctl boot "$target" 2>/dev/null || true
122
- xcrun simctl bootstatus "$target" -b >/dev/null
148
+ xcrun simctl boot "$udid" 2>/dev/null || true
149
+ xcrun simctl bootstatus "$udid" -b >/dev/null
123
150
  }
124
151
 
125
152
  show_simulator() {
@@ -192,7 +219,7 @@ if [ "$PLATFORM" = "ios" ]; then
192
219
  # Expo dev-client URL scheme — drives both the deep link and the scheme-approval key.
193
220
  DEV_CLIENT_SCHEME="${IOS_DEV_CLIENT_SCHEME:-expo-metamask}"
194
221
 
195
- boot_simulator_if_needed "$SIM_TARGET"
222
+ boot_simulator_if_needed "$SIM_TARGET" || exit 1
196
223
  show_simulator "$SIM_TARGET"
197
224
 
198
225
  LAUNCHED=false
@@ -86,6 +86,20 @@ start_console_forwarder() {
86
86
  local fwd_pid_file="$LOG_DIR/console-forwarder.pid"
87
87
  [ "${METAMASK_RECIPE_CONSOLE_FORWARD:-1}" != "0" ] || return 0
88
88
  { [ -f "$forwarder" ] && command -v node >/dev/null 2>&1; } || return 0
89
+ # Only one debugger can own a React Native page. Reap any forwarder already
90
+ # attached to this Metro port, even if it was launched from another checkout or
91
+ # package install path and therefore does not own this runtime dir's pid file.
92
+ while IFS= read -r line; do
93
+ local pid cmd
94
+ pid="${line%% *}"
95
+ cmd="${line#* }"
96
+ [ "$pid" != "$line" ] || continue
97
+ case "$cmd" in
98
+ *console-forwarder.cjs*" --port $PORT "*|*console-forwarder.cjs*" --port=$PORT "*)
99
+ kill "$pid" 2>/dev/null || true
100
+ ;;
101
+ esac
102
+ done < <(ps -axo pid=,command= 2>/dev/null || true)
89
103
  if [ -f "$fwd_pid_file" ]; then
90
104
  local old_fwd
91
105
  old_fwd="$(cat "$fwd_pid_file" 2>/dev/null || true)"
@@ -126,7 +140,7 @@ set +a
126
140
  export EXPO_NO_TYPESCRIPT_SETUP=1
127
141
  export WATCHER_PORT="$(printf '%q' "$PORT")" METRO_PORT="$(printf '%q' "$PORT")"
128
142
  export METRO_MAX_WORKERS="$(printf '%q' "$METRO_WORKERS")"
129
- exec yarn expo start --port "$(printf '%q' "$PORT")"$clear_flag >> "$(printf '%q' "$LOG_FILE")" 2>&1
143
+ yarn expo start --port "$(printf '%q' "$PORT")"$clear_flag 2>&1 | tee -a "$(printf '%q' "$LOG_FILE")"
130
144
  EOF
131
145
  chmod +x "$runner"
132
146
  }
@@ -216,6 +216,34 @@ process.stdout.write(value);
216
216
  NODE
217
217
  }
218
218
 
219
+ runtime_context_value() {
220
+ TARGET_FOR_RUNTIME_CONTEXT="$TARGET" RUNTIME_DIR_FOR_RUNTIME_CONTEXT="$RUNTIME_DIR" RUNTIME_CONTEXT_NAME="$1" node <<'NODE'
221
+ const fs = require('fs');
222
+ const path = require('path');
223
+ const target = process.env.TARGET_FOR_RUNTIME_CONTEXT;
224
+ const runtimeDir = process.env.RUNTIME_DIR_FOR_RUNTIME_CONTEXT;
225
+ const names = process.env.RUNTIME_CONTEXT_NAME.split(',');
226
+ const file = path.join(target, runtimeDir, 'agentic-runtime.json');
227
+ let data = {};
228
+ try { data = JSON.parse(fs.readFileSync(file, 'utf8')); } catch {}
229
+ for (const name of names) {
230
+ const value = data && data[name];
231
+ if (value !== undefined && value !== null && value !== '') {
232
+ process.stdout.write(String(value));
233
+ process.exit(0);
234
+ }
235
+ }
236
+ NODE
237
+ }
238
+
239
+ mobile_device_target() {
240
+ if [ "$PLATFORM" = "android" ]; then
241
+ printf '%s' "${ADB_SERIAL:-${ANDROID_SERIAL:-${ANDROID_DEVICE:-$(runtime_context_value adbSerial,androidDevice,device)}}}"
242
+ return
243
+ fi
244
+ printf '%s' "${SIM_UDID:-${IOS_SIMULATOR:-$(runtime_context_value simulator,iosSimulator,device)}}"
245
+ }
246
+
219
247
  check_file() {
220
248
  local rel="$1"
221
249
  if [ -e "$TARGET/$rel" ]; then
@@ -331,6 +359,7 @@ run_with_timeout() {
331
359
 
332
360
  live_status_ok() {
333
361
  local log_path="$1"
362
+ local device_target="$2"
334
363
  cat > "$ARTIFACTS/mobile-status-smoke.recipe.json" <<'JSON'
335
364
  {
336
365
  "schema_version": 1,
@@ -349,12 +378,15 @@ live_status_ok() {
349
378
  JSON
350
379
  (
351
380
  cd "$TARGET"
352
- "$RUNNER_BIN" run "$ARTIFACTS/mobile-status-smoke.recipe.json" --adapter mobile --project-root "$TARGET" --artifacts-dir "$ARTIFACTS/runner-status-smoke" --json
381
+ device_args=()
382
+ [ -n "$device_target" ] && device_args=(--device "$device_target")
383
+ "$RUNNER_BIN" run "$ARTIFACTS/mobile-status-smoke.recipe.json" --adapter mobile --project-root "$TARGET" "${device_args[@]}" --artifacts-dir "$ARTIFACTS/runner-status-smoke" --json
353
384
  ) > "$log_path" 2>&1
354
385
  }
355
386
 
356
387
  ensure_live_runtime() {
357
- if live_status_ok "$ARTIFACTS/logs/app-status-precheck.log"; then
388
+ local device_target="$1"
389
+ if live_status_ok "$ARTIFACTS/logs/app-status-precheck.log" "$device_target"; then
358
390
  return 0
359
391
  fi
360
392
  if [ "$AUTO_START" = true ]; then
@@ -366,7 +398,17 @@ EOF
366
398
  }
367
399
 
368
400
  if [ "$STATIC_ONLY" = false ]; then
369
- if ensure_live_runtime; then
401
+ device_target="$(mobile_device_target)"
402
+ if [ -z "$device_target" ]; then
403
+ if [ "$PLATFORM" = "android" ]; then
404
+ device_target="$(jsenv_value ADB_SERIAL)"
405
+ [ -n "$device_target" ] || device_target="$(jsenv_value ANDROID_DEVICE)"
406
+ else
407
+ device_target="$(jsenv_value IOS_SIMULATOR)"
408
+ fi
409
+ fi
410
+
411
+ if ensure_live_runtime "$device_target"; then
370
412
  checks+=('{"name":"mobile runtime controllable precheck","status":"pass"}')
371
413
  else
372
414
  checks+=('{"name":"mobile runtime controllable precheck","status":"fail","detail":"see logs/app-status-precheck.log"}')
@@ -394,20 +436,13 @@ if [ "$STATIC_ONLY" = false ]; then
394
436
  {
395
437
  "schema_version": 1,
396
438
  "title": "Mobile v1 runner live bridge smoke",
397
- "description": "Verifies the installed MetaMask runner can drive the React Native debug bridge through Recipe v1 actions.",
439
+ "description": "Verifies the installed MetaMask runner can read the React Native debug bridge without mutating wallet state.",
398
440
  "validate": {
399
441
  "workflow": {
400
442
  "entry": "status",
401
443
  "nodes": {
402
444
  "status": { "action": "app.status", "intent": "Read Mobile app status through the v1 runner", "next": "cdp-probe" },
403
- "cdp-probe": { "action": "cdp.target", "intent": "Verify the React Native debug bridge target is reachable", "required": true, "timeout_ms": 15000, "next": "wallet-setup" },
404
- "wallet-setup": { "action": "metamask.wallet.setup", "intent": "Prepare the wallet fixture for the live bridge smoke", "timeout_ms": 45000, "next": "wallet-unlock" },
405
- "wallet-unlock": { "action": "metamask.wallet.ensure_unlocked", "intent": "Unlock the wallet through the manifest-declared action", "timeout_ms": 45000, "next": "wallet-read" },
406
- "wallet-read": { "action": "metamask.wallet.read_state", "intent": "Read wallet state before navigating to the wallet view", "timeout_ms": 45000, "next": "navigate-wallet" },
407
- "navigate-wallet": { "action": "ui.navigate", "intent": "Open the wallet view through the UI navigation action", "route": "WalletView", "timeout_ms": 45000, "next": "wait-wallet" },
408
- "wait-wallet": { "action": "ui.wait_for", "intent": "Wait until the wallet screen is present", "test_id": "wallet-screen", "expected": "present", "timeout_ms": 45000, "next": "hud-smoke" },
409
- "hud-smoke": { "action": "app.hud", "status": "running", "intent": "Show the Mobile live bridge smoke HUD", "progress": { "current": 1, "total": 1 }, "timeout_ms": 45000, "next": "screenshot" },
410
- "screenshot": { "action": "ui.screenshot", "intent": "Capture proof that the wallet screen is visible", "path": "screenshots/mobile-v1-live-smoke.png", "timeout_ms": 45000, "next": "done" },
445
+ "cdp-probe": { "action": "cdp.target", "intent": "Verify the React Native debug bridge target is reachable", "required": true, "timeout_ms": 15000, "next": "done" },
411
446
  "done": { "action": "end", "status": "pass" }
412
447
  }
413
448
  }
@@ -417,13 +452,15 @@ JSON
417
452
 
418
453
  ios_simulator_resolved="$(jsenv_value IOS_SIMULATOR)"
419
454
  adb_serial_resolved="$(jsenv_value ADB_SERIAL)"
455
+ device_args=()
456
+ [ -n "$device_target" ] && device_args=(--device "$device_target")
420
457
  if (
421
458
  cd "$TARGET"
422
459
  METAMASK_RECIPE_AUTO_HUD=0 \
423
460
  IOS_SIMULATOR="${IOS_SIMULATOR:-$ios_simulator_resolved}" \
424
461
  ADB_SERIAL="${ADB_SERIAL:-$adb_serial_resolved}" \
425
462
  ANDROID_SERIAL="${ANDROID_SERIAL:-$adb_serial_resolved}" \
426
- "$RUNNER_BIN" run "$ARTIFACTS/mobile-v1-live-smoke.recipe.json" --adapter mobile --project-root "$TARGET" --artifacts-dir "$ARTIFACTS/runner-live-smoke" --json
463
+ "$RUNNER_BIN" run "$ARTIFACTS/mobile-v1-live-smoke.recipe.json" --adapter mobile --project-root "$TARGET" "${device_args[@]}" --artifacts-dir "$ARTIFACTS/runner-live-smoke" --json
427
464
  ) > "$ARTIFACTS/logs/runner-live-smoke.log" 2>&1; then
428
465
  checks+=("{\"name\":\"runner v1 live bridge smoke\",\"status\":\"pass\"}")
429
466
  else
@@ -222,13 +222,17 @@ async function watchLog(options) {
222
222
  const chunk = readNewContent(logPath, offset);
223
223
  offset = chunk.offset;
224
224
  const allText = fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : '';
225
+ // Match readiness only against bytes read after this watch started. This
226
+ // assumes line-oriented log writes; a marker split across partial writes
227
+ // waits for a later complete marker instead of stale-passing.
228
+ const matchText = chunk.text;
225
229
  const events = extractEvents(allText, eventsLimit);
226
230
 
227
- if (matchesPattern(allText, 'Module build failed|^ERROR in |compiled with [1-9][0-9]* error|FAIL runtime-launch|Extension harness launch fail')) {
231
+ if (matchesPattern(matchText, 'Module build failed|^ERROR in |compiled with [1-9][0-9]* error|FAIL runtime-launch|Extension harness launch fail')) {
228
232
  renderCompact({ label, logPath, events, quiet: mode === 'quiet' });
229
233
  return 1;
230
234
  }
231
- if (matchesPattern(allText, successPattern)) {
235
+ if (matchesPattern(matchText, successPattern)) {
232
236
  renderCompact({ label, logPath, events, quiet: mode === 'quiet' });
233
237
  if (mode !== 'quiet') {
234
238
  const elapsed = Math.round((Date.now() - started) / 1000);
@@ -357,4 +361,4 @@ async function main() {
357
361
  main().catch((error) => {
358
362
  console.error(error instanceof Error ? error.message : String(error));
359
363
  process.exit(1);
360
- });
364
+ });
@@ -31,6 +31,8 @@ export function formatKvLines(kv) {
31
31
  if (kv.WATCHER_PORT !== undefined) lines.push(`WATCHER_PORT=${kv.WATCHER_PORT}`);
32
32
  if (kv.SLOT_ID) lines.push(`SLOT_ID=${kv.SLOT_ID}`);
33
33
  if (kv.IOS_SIMULATOR) lines.push(`IOS_SIMULATOR=${kv.IOS_SIMULATOR}`);
34
+ if (kv.ADB_SERIAL) lines.push(`ADB_SERIAL=${kv.ADB_SERIAL}`);
35
+ if (kv.ANDROID_DEVICE) lines.push(`ANDROID_DEVICE=${kv.ANDROID_DEVICE}`);
34
36
  return lines.length ? `${lines.join('\n')}\n` : '';
35
37
  }
36
38
 
@@ -54,6 +56,8 @@ function scoreSlot(slot, slotSuffix) {
54
56
  const cdp = resources.browser?.cdp_port;
55
57
  const port = resources['dev-server']?.port;
56
58
  const simulator = resources['ios-sim']?.simulator;
59
+ const adbSerial = resources['android-emu']?.adb_serial ?? resources.android?.adb_serial;
60
+ const androidDevice = resources['android-emu']?.device ?? resources.android?.device;
57
61
  const session = slot.session ?? '';
58
62
  const slotId = slot.id ?? '';
59
63
  let score = 0;
@@ -64,7 +68,7 @@ function scoreSlot(slot, slotSuffix) {
64
68
  if (slotId.endsWith(`mme-${slotSuffix}`) || slotId.includes(`-mme-${slotSuffix}`)) score += 40;
65
69
  }
66
70
  if (!slotId.toLowerCase().includes('demo')) score += 5;
67
- return [score, cdp, port, slotId, simulator];
71
+ return [score, cdp, port, slotId, simulator, adbSerial, androidDevice];
68
72
  }
69
73
 
70
74
  export function resolveSlotPortsByRepo(repo) {
@@ -111,12 +115,14 @@ export function resolveSlotPortsByRepo(repo) {
111
115
  }
112
116
 
113
117
  if (best) {
114
- const [, cdp, port, slotId, simulator] = best;
118
+ const [, cdp, port, slotId, simulator, adbSerial, androidDevice] = best;
115
119
  return formatKvLines({
116
120
  ...(cdp !== undefined ? { CDP_PORT: cdp } : {}),
117
121
  ...(port !== undefined ? { WATCHER_PORT: port } : {}),
118
122
  ...(slotId ? { SLOT_ID: slotId } : {}),
119
123
  ...(simulator ? { IOS_SIMULATOR: simulator } : {}),
124
+ ...(adbSerial ? { ADB_SERIAL: adbSerial } : {}),
125
+ ...(androidDevice ? { ANDROID_DEVICE: androidDevice } : {}),
120
126
  });
121
127
  }
122
128
  }
@@ -152,10 +158,14 @@ export function resolveMobileRuntimeContext(repo) {
152
158
  if (fs.existsSync(ctxPath)) {
153
159
  try {
154
160
  const c = JSON.parse(fs.readFileSync(ctxPath, 'utf8'));
155
- if (c.simulator || (c.metroPort != null && c.metroPort !== '')) {
161
+ const adbSerial = c.adbSerial ?? c.androidSerial;
162
+ const androidDevice = c.androidDevice;
163
+ if (c.simulator || adbSerial || androidDevice || (c.metroPort != null && c.metroPort !== '')) {
156
164
  return formatKvLines({
157
165
  ...(c.metroPort != null && c.metroPort !== '' ? { WATCHER_PORT: c.metroPort } : {}),
158
166
  ...(c.simulator ? { IOS_SIMULATOR: c.simulator } : {}),
167
+ ...(adbSerial ? { ADB_SERIAL: adbSerial } : {}),
168
+ ...(androidDevice ? { ANDROID_DEVICE: androidDevice } : {}),
159
169
  ...(c.slotId ? { SLOT_ID: c.slotId } : {}),
160
170
  });
161
171
  }
@@ -210,4 +220,4 @@ export const cliFns = {
210
220
  resolve_mobile_runtime_context: resolveMobileRuntimeContext,
211
221
  resolve_mobile_slot_defaults: resolveMobileSlotDefaults,
212
222
  resolve_mobile_runtime_ports: (repo) => resolveMobileRuntimePorts(repo) || null,
213
- };
223
+ };
@@ -31,6 +31,7 @@ const coreSurface = {
31
31
  }
32
32
  },
33
33
  devServer: {
34
+ label: "dev-server",
34
35
  describe: () => "no dev server (headless)",
35
36
  stop() {
36
37
  return {
@@ -25,7 +25,8 @@ async function prepareExtensionRuntime(options) {
25
25
  const health = await assertHealthyExtensionRuntime({
26
26
  projectRoot,
27
27
  cdpPort,
28
- timeoutMs: options.healthTimeoutMs
28
+ timeoutMs: options.healthTimeoutMs,
29
+ pageMode: options.pageMode
29
30
  });
30
31
  return { launch, health };
31
32
  }
@@ -34,14 +35,16 @@ async function assertHealthyExtensionRuntime(options) {
34
35
  const deadline = Date.now() + timeoutMs;
35
36
  let lastReport = null;
36
37
  while (Date.now() <= deadline) {
37
- lastReport = await checkExtensionRuntimeHealth(options.projectRoot, options.cdpPort);
38
+ lastReport = await checkExtensionRuntimeHealth(options.projectRoot, options.cdpPort, {
39
+ pageMode: options.pageMode
40
+ });
38
41
  if (lastReport.status === "PASS") return lastReport;
39
42
  await sleep(500);
40
43
  }
41
44
  const report = lastReport ?? failReport(options.cdpPort, ["CDP runtime was not probed."], {});
42
45
  throw new Error(formatHealthFailure(report, options.projectRoot));
43
46
  }
44
- async function checkExtensionRuntimeHealth(projectRoot, cdpPort) {
47
+ async function checkExtensionRuntimeHealth(projectRoot, cdpPort, options = {}) {
45
48
  const findings = [];
46
49
  const warnings = [];
47
50
  let targets = [];
@@ -52,11 +55,17 @@ async function checkExtensionRuntimeHealth(projectRoot, cdpPort) {
52
55
  } catch (error) {
53
56
  return failReport(cdpPort, [`CDP is not reachable on port ${cdpPort}: ${messageOf(error)}`], {});
54
57
  }
55
- const extensionTargets = targets.filter(
58
+ const pageMode = options.pageMode ?? "home";
59
+ const homeTargets = targets.filter(
56
60
  (target2) => target2.type === "page" && String(target2.url ?? "").startsWith("chrome-extension://") && String(target2.url ?? "").includes("/home.html") && Boolean(target2.webSocketDebuggerUrl)
57
61
  );
62
+ const sidepanelTargets = targets.filter(
63
+ (target2) => target2.type === "page" && String(target2.url ?? "").startsWith("chrome-extension://") && String(target2.url ?? "").includes("/sidepanel.html") && Boolean(target2.webSocketDebuggerUrl)
64
+ );
65
+ const extensionTargets = pageMode === "home-or-sidepanel" && homeTargets.length === 0 ? sidepanelTargets : homeTargets;
58
66
  if (extensionTargets.length !== 1) {
59
- findings.push(`Expected exactly one MetaMask extension home page target, found ${extensionTargets.length}.`);
67
+ const expected = pageMode === "home-or-sidepanel" ? "exactly one MetaMask extension home page target, or one sidepanel target when home is intentionally closed" : "exactly one MetaMask extension home page target";
68
+ findings.push(`Expected ${expected}, found ${extensionTargets.length}.`);
60
69
  }
61
70
  const target = extensionTargets[0];
62
71
  if (!target?.webSocketDebuggerUrl) {