@deeeed/metamask-harness 0.14.0 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +31 -0
  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 +11 -0
  10. package/adapters/extension/wallet-fixture-state.cjs +6 -11
  11. package/adapters/mobile/open-device.sh +32 -5
  12. package/adapters/shared/resolve-slot-ports-core.mjs +14 -4
  13. package/dist/adapters/core/surface.js +1 -0
  14. package/dist/adapters/extension/runtime.js +14 -5
  15. package/dist/adapters/extension/surface.js +1 -0
  16. package/dist/adapters/mobile/provision.js +49 -4
  17. package/dist/adapters/mobile/surface.js +1 -0
  18. package/dist/adapters/slot-ports.js +11 -4
  19. package/dist/cli.js +4 -0
  20. package/dist/commands/call.js +19 -2
  21. package/dist/commands/check.js +326 -0
  22. package/dist/commands/core-readiness.js +75 -0
  23. package/dist/commands/device-target.js +113 -10
  24. package/dist/commands/doctor.js +30 -18
  25. package/dist/commands/launch/extension.js +105 -9
  26. package/dist/commands/launch/index.js +116 -15
  27. package/dist/commands/mobile-device-view.js +140 -0
  28. package/dist/commands/parse-args.js +4 -1
  29. package/dist/commands/recipe-quality.js +6 -2
  30. package/dist/commands/run-engine.js +28 -2
  31. package/dist/commands/run-report.js +115 -0
  32. package/dist/commands/run.js +113 -8
  33. package/dist/commands/shared.js +2 -1
  34. package/dist/commands/status-probe.js +9 -3
  35. package/dist/commands/status.js +40 -60
  36. package/dist/live-adapter-contract.js +5 -1
  37. package/dist/mm-harness-cli.js +61 -22
  38. package/docs/CLI-SPEC.md +25 -0
  39. package/library/actions/extension/platform/cdp.mjs +7 -4
  40. package/package.json +4 -4
@@ -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
@@ -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
@@ -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) {
@@ -43,6 +43,7 @@ const extensionSurface = {
43
43
  }
44
44
  },
45
45
  devServer: {
46
+ label: "webpack",
46
47
  describe: () => "webpack watcher",
47
48
  stop(target) {
48
49
  const signalled = stopExtensionWatcher(target);
@@ -457,11 +457,13 @@ function preapproveDeepLinkScheme(device, bundleId, options) {
457
457
  function ensureSimulator(name, runtime, deviceType) {
458
458
  const existing = findSimulator(name);
459
459
  if (existing) return { name, udid: existing, created: false, runtime, deviceType };
460
- if (!runtime || !deviceType) {
461
- throw new Error(`simulator ${name} is missing and runtime/device type could not be resolved. Pass --runtime and --device-type, or write them to agentic-runtime.json.`);
460
+ const resolvedRuntime = runtime || latestIosRuntime();
461
+ const resolvedDeviceType = deviceType || preferredIphoneDeviceType();
462
+ if (!resolvedRuntime || !resolvedDeviceType) {
463
+ throw new Error(`simulator ${name} is missing and no available iOS simulator runtime/device type could be resolved from xcrun simctl.`);
462
464
  }
463
- const udid = execFileSync("xcrun", ["simctl", "create", name, deviceType, runtime], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
464
- return { name, udid: udid || name, created: true, runtime, deviceType };
465
+ const udid = execFileSync("xcrun", ["simctl", "create", name, resolvedDeviceType, resolvedRuntime], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
466
+ return { name, udid: udid || name, created: true, runtime: resolvedRuntime, deviceType: resolvedDeviceType };
465
467
  }
466
468
  function findSimulator(name) {
467
469
  const data = execJson("xcrun", ["simctl", "list", "devices", "--json"]);
@@ -471,6 +473,49 @@ function findSimulator(name) {
471
473
  }
472
474
  return void 0;
473
475
  }
476
+ function latestIosRuntime() {
477
+ const data = execJson(
478
+ "xcrun",
479
+ ["simctl", "list", "runtimes", "--json"]
480
+ );
481
+ const runtimes = (data.runtimes ?? []).filter((runtime) => runtime.isAvailable !== false && runtime.identifier && (runtime.platform === "iOS" || /(^|[.-])iOS([.-]|$)/u.test(runtime.identifier) || /^iOS\b/u.test(runtime.name ?? ""))).sort((a, b) => compareVersions(runtimeVersion(b), runtimeVersion(a)));
482
+ return runtimes[0]?.identifier;
483
+ }
484
+ function preferredIphoneDeviceType() {
485
+ const data = execJson("xcrun", ["simctl", "list", "devicetypes", "--json"]);
486
+ const devices = (data.devicetypes ?? []).filter((device) => device.identifier && /^iPhone\b/u.test(device.name ?? "")).sort((a, b) => {
487
+ const generation = iphoneGeneration(b) - iphoneGeneration(a);
488
+ if (generation !== 0) return generation;
489
+ const proScore = iphoneProScore(b) - iphoneProScore(a);
490
+ if (proScore !== 0) return proScore;
491
+ return String(b.name ?? "").localeCompare(String(a.name ?? ""));
492
+ });
493
+ return devices[0]?.identifier;
494
+ }
495
+ function runtimeVersion(runtime) {
496
+ const raw = runtime.version || /iOS[-. ]([0-9][0-9A-Za-z_.-]*)/u.exec(runtime.identifier ?? "")?.[1] || runtime.name || "";
497
+ const parts = raw.match(/\d+/gu)?.map((part) => Number(part)) ?? [];
498
+ return parts.length ? parts : [0];
499
+ }
500
+ function compareVersions(a, b) {
501
+ const length = Math.max(a.length, b.length);
502
+ for (let i = 0; i < length; i += 1) {
503
+ const diff = (a[i] ?? 0) - (b[i] ?? 0);
504
+ if (diff !== 0) return diff;
505
+ }
506
+ return 0;
507
+ }
508
+ function iphoneGeneration(device) {
509
+ const match = /^iPhone\s+(\d+)/u.exec(device.name ?? "");
510
+ return match ? Number(match[1]) : 0;
511
+ }
512
+ function iphoneProScore(device) {
513
+ const name = device.name ?? "";
514
+ if (/\bPro Max\b/u.test(name)) return 3;
515
+ if (/\bPro\b/u.test(name)) return 2;
516
+ if (/\bPlus\b/u.test(name)) return 1;
517
+ return 0;
518
+ }
474
519
  function baselineSimulatorCandidates(data, current) {
475
520
  const candidates = [];
476
521
  if (current.simulator) candidates.push(current.simulator);
@@ -29,6 +29,7 @@ const mobileSurface = {
29
29
  }
30
30
  },
31
31
  devServer: {
32
+ label: "metro",
32
33
  describe: () => "Metro dev server",
33
34
  stop(target) {
34
35
  const leaf = path.join(runnerDir, "adapters", "mobile", "stop-metro.sh");
@@ -10,10 +10,8 @@ import {
10
10
  resolveMobileSlotDefaults
11
11
  } from "./resolve-slot-ports.js";
12
12
  function applyKVLines(output, overwrite) {
13
- for (const line of output.split("\n")) {
14
- const m = /^([A-Z_]+)=(.+)$/u.exec(line.trim());
15
- if (!m) continue;
16
- const [, key, val] = m;
13
+ const entries = output.split("\n").map((line) => /^([A-Z_]+)=(.+)$/u.exec(line.trim())).filter((match) => match !== null).map((match) => ({ key: match[1], val: match[2] }));
14
+ for (const { key, val } of entries) {
17
15
  if (val === "null" || val === "undefined" || val === "") continue;
18
16
  switch (key) {
19
17
  case "WATCHER_PORT":
@@ -26,6 +24,15 @@ function applyKVLines(output, overwrite) {
26
24
  case "IOS_SIMULATOR":
27
25
  if (overwrite || !process.env["IOS_SIMULATOR"]) process.env["IOS_SIMULATOR"] = val;
28
26
  break;
27
+ case "ADB_SERIAL":
28
+ if (overwrite || !process.env["ADB_SERIAL"]) {
29
+ process.env["ADB_SERIAL"] = val;
30
+ process.env["ANDROID_SERIAL"] = val;
31
+ }
32
+ break;
33
+ case "ANDROID_DEVICE":
34
+ if (overwrite || !process.env["ANDROID_DEVICE"]) process.env["ANDROID_DEVICE"] = val;
35
+ break;
29
36
  case "SLOT_ID":
30
37
  if (overwrite || !process.env["RECIPE_SLOT_ID"]) process.env["RECIPE_SLOT_ID"] = val;
31
38
  break;
package/dist/cli.js CHANGED
@@ -19,6 +19,7 @@ import { handleDebug } from "./commands/debug.js";
19
19
  import { handleFixtures } from "./commands/fixtures.js";
20
20
  import { handleRecipeQuality } from "./commands/recipe-quality.js";
21
21
  import { handleStatus } from "./commands/status.js";
22
+ import { handleCheck } from "./commands/check.js";
22
23
  import { parseArgs, targetPath } from "./commands/parse-args.js";
23
24
  import { runOneNode } from "./commands/run-engine.js";
24
25
  const COMMANDS = {
@@ -56,6 +57,8 @@ PROVE \u2014 run recipes and inspect capabilities:
56
57
  mm-harness flows list
57
58
  doctor Readiness check for a checkout (no app launch).
58
59
  mm-harness doctor
60
+ check Run bounded repo-local checks for an active diff.
61
+ mm-harness check diff --profile fast --artifacts-dir artifacts/validation
59
62
  actions Describe the actions a manifest declares.
60
63
  mm-harness actions --adapter mobile
61
64
  manifest Print/validate the action manifest for an adapter.
@@ -127,6 +130,7 @@ async function main(argv) {
127
130
  if (command === "debug") return handleDebug(argv.slice(1));
128
131
  if (command === "fixtures") return handleFixtures(argv.slice(1), { runOneNode });
129
132
  if (command === "recipe-quality") return handleRecipeQuality(argv.slice(1));
133
+ if (command === "check") return handleCheck(argv.slice(1));
130
134
  const handler = COMMANDS[command];
131
135
  if (!handler) throw new Error(`Unknown command: ${command}`);
132
136
  return handler(parseArgs(argv.slice(1), command));
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import { loadActionManifest } from "../manifest.js";
6
6
  import { importRecipeProtocol } from "../paths.js";
7
7
  import { recipeRunning } from "../heal-bounds.js";
8
+ import { color } from "../cli-color.js";
8
9
  import { EXIT } from "./shared.js";
9
10
  import { describeManifestActions, fuzzyResolveActions } from "./manifest.js";
10
11
  import {
@@ -19,6 +20,7 @@ import {
19
20
  import { getAdapterSurface } from "../adapters/surface.js";
20
21
  import { handleListExecutables } from "./list-executables.js";
21
22
  import { applyDeviceTargeting } from "./device-target.js";
23
+ import { coreDependencyBlock } from "./core-readiness.js";
22
24
  import {
23
25
  emitHealViolation,
24
26
  executeWithHealBounds,
@@ -96,6 +98,16 @@ async function handleCall(argv) {
96
98
  return EXIT.usage;
97
99
  }
98
100
  const resolvedAction = resolution.resolved;
101
+ const depsBlock = adapter === "core" && resolvedAction.startsWith("metamask.perps.") ? coreDependencyBlock(target) : null;
102
+ if (depsBlock) {
103
+ if (json) {
104
+ console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, status: "fail", exitCode: EXIT.usage, error: depsBlock }, null, 2));
105
+ } else {
106
+ console.error(`\u2717 call: ${depsBlock.message}`);
107
+ console.error(` Next: ${depsBlock.userAction}`);
108
+ }
109
+ return EXIT.usage;
110
+ }
99
111
  const recipe = synthesizeOneNodeRecipe(resolvedAction, args);
100
112
  const validation = await validateRecipeAdapterAware(recipe, manifest);
101
113
  if (validation.status === "invalid") {
@@ -157,8 +169,13 @@ async function handleCall(argv) {
157
169
  const rendered = callOutput !== void 0 ? `
158
170
  Result:
159
171
  ${formatCallOutput(callOutput)}` : "";
160
- console.log(`call ${resolvedAction}: ${result.status}${rendered}
161
- Artifacts: ${result.artifactManifestPath}`);
172
+ const out = (style, text) => color(style, text, { stream: process.stdout });
173
+ console.log(
174
+ `${out("label", "call")} ${out("cmd", resolvedAction)}: ${out(result.status === "pass" ? "ok" : "err", result.status)}${rendered ? `
175
+ ${out("label", "Result:")}
176
+ ${formatCallOutput(callOutput)}` : ""}
177
+ ${out("label", "Artifacts:")} ${out("path", result.artifactManifestPath)}`
178
+ );
162
179
  }
163
180
  return result.status === "pass" ? EXIT.ok : EXIT.runtime;
164
181
  }