@deeeed/metamask-harness 0.35.0 → 0.37.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +9 -1
  3. package/adapters/extension/artifact-runtime-state.cjs +128 -0
  4. package/adapters/extension/check-infura-readiness.cjs +102 -0
  5. package/adapters/extension/inject.mjs +2 -0
  6. package/adapters/extension/launch-browser.cjs +22 -0
  7. package/adapters/extension/live.sh +103 -18
  8. package/adapters/extension/readiness.mjs +77 -36
  9. package/adapters/extension/snapshot-dist.sh +88 -3
  10. package/adapters/extension/start-watch.sh +15 -0
  11. package/adapters/extension/verify.sh +6 -2
  12. package/adapters/extension/wallet-fixture-state.cjs +3 -1
  13. package/adapters/manifest.json +25 -1
  14. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +73 -42
  15. package/adapters/mobile/reset-app-data.sh +154 -0
  16. package/adapters/shared/log-tui.mjs +1 -1
  17. package/dist/adapters/extension/artifact-integrity.js +38 -0
  18. package/dist/adapters/extension/extension-id.js +23 -4
  19. package/dist/adapters/extension/product-config.js +29 -1
  20. package/dist/adapters/extension/release-artifact.js +386 -0
  21. package/dist/adapters/extension/runtime-decision.js +161 -20
  22. package/dist/adapters/extension/runtime.js +127 -0
  23. package/dist/adapters/mobile/release-artifact-state.js +124 -0
  24. package/dist/adapters/mobile/release-artifact.js +295 -0
  25. package/dist/adapters.js +29 -4
  26. package/dist/cli-commands.js +1 -1
  27. package/dist/cli.js +2 -2
  28. package/dist/command-contract.js +17 -1
  29. package/dist/commands/call.js +2 -1
  30. package/dist/commands/device-target.js +5 -0
  31. package/dist/commands/fixtures.js +106 -31
  32. package/dist/commands/launch/extension.js +92 -7
  33. package/dist/commands/launch/index.js +13 -0
  34. package/dist/commands/launch/mobile.js +2 -0
  35. package/dist/commands/provision.js +2 -0
  36. package/dist/commands/run-engine.js +89 -5
  37. package/dist/commands/run.js +3 -1
  38. package/dist/commands/runtime-launch.js +178 -10
  39. package/dist/heal-bounds.js +1 -1
  40. package/dist/live-adapter-contract.js +3 -1
  41. package/dist/metamask-action-validation.js +47 -1
  42. package/dist/mm-harness-cli.js +37 -4
  43. package/dist/recipe-security.js +3 -0
  44. package/dist/run-diagnostics.js +1 -1
  45. package/docs/RECIPES.md +29 -0
  46. package/docs/RELEASE-QA-CAPABILITY-MAP.md +150 -0
  47. package/library/actions/extension/perps/perps.mjs +2 -0
  48. package/library/actions/extension/perps/read_snapshot.mjs +470 -0
  49. package/library/actions/extension/platform/cdp.mjs +6 -3
  50. package/library/actions/extension/wallet/import.mjs +201 -0
  51. package/library/actions/extension/wallet/reset.mjs +98 -0
  52. package/library/actions/extension/wallet/secret-input.mjs +98 -0
  53. package/library/actions/extension/wallet/state.mjs +1 -0
  54. package/library/actions/mobile/analytics/consent-settings.mjs +112 -0
  55. package/library/actions/mobile/analytics/set_consent.mjs +4 -112
  56. package/library/actions/mobile/platform/bridge.mjs +8 -0
  57. package/library/actions/mobile/platform/observe-ui.mjs +84 -2
  58. package/library/actions/mobile/ui/native-navigation.mjs +225 -0
  59. package/library/actions/mobile/ui/navigate.mjs +7 -0
  60. package/library/actions/mobile/wallet/import.mjs +328 -0
  61. package/library/actions/mobile/wallet/native-ui.mjs +493 -0
  62. package/library/actions/mobile/wallet/read_state.mjs +16 -0
  63. package/library/actions/mobile/wallet/reset-helper.mjs +99 -0
  64. package/library/actions/mobile/wallet/reset.mjs +20 -0
  65. package/library/actions/shared/ui/locators.mjs +7 -0
  66. package/library/actions/shared/wallet/import-source.mjs +101 -0
  67. package/library/manifests/extension.action-manifest.json +228 -0
  68. package/library/manifests/mobile.action-manifest.json +124 -0
  69. package/library/recipes/extension/runner/action-validation.recipe.json +12 -1
  70. package/library/recipes/wallet/import.recipe.json +102 -0
  71. package/library/recipes/wallet/reset-import.recipe.json +107 -0
  72. package/package.json +1 -1
  73. package/scripts/completions.sh +2 -2
@@ -64,10 +64,29 @@ function parseArgs(argv) {
64
64
  return out;
65
65
  }
66
66
 
67
+ function extensionBuildRoot(target) {
68
+ const runtimeRoot = path.join(target, recipeRuntimeDir());
69
+ const statePath = path.join(runtimeRoot, 'extension-release-artifact.json');
70
+ if (!fs.existsSync(statePath)) return path.join(target, 'dist/chrome');
71
+ try {
72
+ const stat = fs.lstatSync(statePath);
73
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > 64 * 1024) {
74
+ throw new Error('Loaded release artifact identity is not a regular bounded file.');
75
+ }
76
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
77
+ const expected = path.join(runtimeRoot, process.env.RECIPE_RUNTIME_DIST_DIR || 'runtime-dist');
78
+ if (state.schemaVersion === 1 && path.resolve(state.runtimeDist || '') === expected) return expected;
79
+ } catch (error) {
80
+ throw new Error(`Loaded release artifact identity is invalid: ${error instanceof Error ? error.message : String(error)}`);
81
+ }
82
+ throw new Error('Loaded release artifact identity is invalid.');
83
+ }
84
+
67
85
  function readManifest(target) {
68
- const manifestPath = path.join(target, 'dist/chrome/manifest.json');
86
+ const buildRoot = extensionBuildRoot(target);
87
+ const manifestPath = path.join(buildRoot, 'manifest.json');
69
88
  const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
70
- return { manifestPath, manifest };
89
+ return { buildRoot, manifestPath, manifest };
71
90
  }
72
91
 
73
92
  function manifestExpectedFiles(manifest) {
@@ -190,6 +209,17 @@ function resolveWebSocket(target) {
190
209
  }
191
210
 
192
211
  async function cdpEvaluate(target, webSocketDebuggerUrl, expression, timeoutMs = 5000) {
212
+ const result = await cdpCall(
213
+ target,
214
+ webSocketDebuggerUrl,
215
+ 'Runtime.evaluate',
216
+ { expression, awaitPromise: true, returnByValue: true },
217
+ timeoutMs,
218
+ );
219
+ return result?.result?.value ?? null;
220
+ }
221
+
222
+ async function cdpCall(target, webSocketDebuggerUrl, method, params, timeoutMs = 5000) {
193
223
  assertLocalWebSocketUrl(webSocketDebuggerUrl);
194
224
  const WebSocketImpl = resolveWebSocket(target);
195
225
  if (!WebSocketImpl) return { skipped: true, reason: 'WebSocket unavailable in this Node runtime' };
@@ -206,8 +236,8 @@ async function cdpEvaluate(target, webSocketDebuggerUrl, expression, timeoutMs =
206
236
  const onOpen = () => {
207
237
  ws.send(JSON.stringify({
208
238
  id: 1,
209
- method: 'Runtime.evaluate',
210
- params: { expression, awaitPromise: true, returnByValue: true },
239
+ method,
240
+ params,
211
241
  }));
212
242
  };
213
243
  const onMessage = (event) => {
@@ -220,11 +250,11 @@ async function cdpEvaluate(target, webSocketDebuggerUrl, expression, timeoutMs =
220
250
  reject(new Error(msg.error.message || JSON.stringify(msg.error)));
221
251
  return;
222
252
  }
223
- resolve(msg.result?.result?.value ?? null);
253
+ resolve(msg.result ?? null);
224
254
  };
225
255
  const onError = (err) => {
226
256
  clearTimeout(timer);
227
- reject(new Error(`CDP websocket error while inspecting extension page: ${err?.message || err || 'unknown'}`));
257
+ reject(new Error(`CDP websocket error during ${method}: ${err?.message || err || 'unknown'}`));
228
258
  };
229
259
  if (typeof ws.on === 'function') {
230
260
  ws.on('open', onOpen);
@@ -286,6 +316,23 @@ async function openExtensionPage(cdpPort, extensionId, pagePath) {
286
316
  }
287
317
  }
288
318
 
319
+ const PAGE_UI_EXPRESSION = `(() => {
320
+ const text = document.body?.innerText || '';
321
+ const cell = (sel) => document.querySelector(sel)?.innerText?.trim() || '';
322
+ const errorBoundaryMessage = cell('[data-testid="error-page-error-message"]');
323
+ const errorBoundaryName = cell('[data-testid="error-page-error-name"]');
324
+ const hasErrorBoundary = Boolean(errorBoundaryMessage) || Boolean(errorBoundaryName);
325
+ return {
326
+ title: document.title,
327
+ url: location.href,
328
+ textSample: text.slice(0, 500),
329
+ hasStartupError: /MetaMask had trouble starting|Background connection unresponsive|Unknown Infura network/i.test(text),
330
+ hasErrorBoundary,
331
+ errorBoundaryName,
332
+ errorBoundaryMessage: errorBoundaryMessage || (hasErrorBoundary ? text.slice(0, 300) : ''),
333
+ };
334
+ })()`;
335
+
289
336
  // The page path only, with any '#fragment' or '?query' stripped. The app router
290
337
  // rewrites home.html to home.html#/ once loaded, so an exact '/home.html' suffix
291
338
  // match must compare against this, not the raw url.
@@ -349,29 +396,7 @@ async function inspectCdp(target, cdpPort, expectedExtensionId, expectedServiceW
349
396
  // tab has none; skip inspection and report it rather than opening a duplicate.
350
397
  if (pageTarget && typeof pageTarget.webSocketDebuggerUrl === 'string') {
351
398
  const slotId = readSlotId(target);
352
- ui = await cdpEvaluate(
353
- target,
354
- pageTarget.webSocketDebuggerUrl,
355
- `(() => {
356
- const text = document.body?.innerText || '';
357
- const cell = (sel) => document.querySelector(sel)?.innerText?.trim() || '';
358
- // React error-boundary screen (ui/pages/error-page); deterministic testids.
359
- const errorBoundaryMessage = cell('[data-testid="error-page-error-message"]');
360
- const errorBoundaryName = cell('[data-testid="error-page-error-name"]');
361
- // Testid-only trigger: ui/pages/error-page reliably renders these testids.
362
- // Avoid matching the visible phrase in body text (false positives).
363
- const hasErrorBoundary = Boolean(errorBoundaryMessage) || Boolean(errorBoundaryName);
364
- return {
365
- title: document.title,
366
- url: location.href,
367
- textSample: text.slice(0, 500),
368
- hasStartupError: /MetaMask had trouble starting|Background connection unresponsive|Unknown Infura network/i.test(text),
369
- hasErrorBoundary,
370
- errorBoundaryName,
371
- errorBoundaryMessage: errorBoundaryMessage || (hasErrorBoundary ? text.slice(0, 300) : ''),
372
- };
373
- })()`,
374
- );
399
+ ui = await cdpEvaluate(target, pageTarget.webSocketDebuggerUrl, PAGE_UI_EXPRESSION);
375
400
  pageInspected = Boolean(ui) && !ui.skipped;
376
401
  if (pageInspected && ui.hasStartupError) {
377
402
  throw Object.assign(new Error('MetaMask extension page loaded startup error UI'), {
@@ -385,9 +410,25 @@ async function inspectCdp(target, cdpPort, expectedExtensionId, expectedServiceW
385
410
  });
386
411
  }
387
412
  if (pageInspected && String(ui.url || '').startsWith('chrome-error://')) {
388
- throw Object.assign(new Error('MetaMask extension page loaded Chrome error UI'), {
389
- report: { cdp: { browser: version.Browser || 'unknown', selectedExtensionId, ui } },
390
- });
413
+ const destination = `chrome-extension://${selectedExtensionId}/${String(extensionPagePath || 'home.html').replace(/^\/+/u, '')}`;
414
+ await cdpCall(
415
+ target,
416
+ pageTarget.webSocketDebuggerUrl,
417
+ 'Page.navigate',
418
+ { url: destination },
419
+ );
420
+ await new Promise((resolve) => setTimeout(resolve, 500));
421
+ ui = await cdpEvaluate(target, pageTarget.webSocketDebuggerUrl, PAGE_UI_EXPRESSION);
422
+ if (
423
+ !ui ||
424
+ String(ui.url || '').startsWith('chrome-error://') ||
425
+ ui.hasStartupError ||
426
+ ui.hasErrorBoundary
427
+ ) {
428
+ throw Object.assign(new Error('MetaMask extension page loaded Chrome error UI'), {
429
+ report: { cdp: { browser: version.Browser || 'unknown', selectedExtensionId, ui } },
430
+ });
431
+ }
391
432
  }
392
433
  if (pageInspected && slotId) {
393
434
  const stampedTitle = await cdpEvaluate(
@@ -419,12 +460,12 @@ async function main() {
419
460
  const args = parseArgs(process.argv.slice(2));
420
461
  const target = path.resolve(args.target);
421
462
  const checks = [];
422
- const { manifestPath, manifest } = readManifest(target);
463
+ const { buildRoot, manifestPath, manifest } = readManifest(target);
423
464
  const expectedFiles = manifestExpectedFiles(manifest);
424
465
  const missingFiles = [];
425
466
  for (const rel of expectedFiles) {
426
- const exists = fs.existsSync(path.join(target, 'dist/chrome', rel));
427
- checks.push({ name: `dist/chrome/${rel}`, status: exists ? 'pass' : 'fail' });
467
+ const exists = fs.existsSync(path.join(buildRoot, rel));
468
+ checks.push({ name: `${path.relative(target, buildRoot)}/${rel}`, status: exists ? 'pass' : 'fail' });
428
469
  if (!exists) missingFiles.push(rel);
429
470
  }
430
471
  if (missingFiles.length > 0) {
@@ -434,7 +475,7 @@ async function main() {
434
475
  );
435
476
  }
436
477
 
437
- const defaultPage = fs.existsSync(path.join(target, 'dist/chrome/home.html'))
478
+ const defaultPage = fs.existsSync(path.join(buildRoot, 'home.html'))
438
479
  ? 'home.html'
439
480
  : manifestDefaultPage(manifest);
440
481
  const report = {
@@ -9,9 +9,12 @@
9
9
  #
10
10
  # Inputs (flags):
11
11
  # --dist <dir> source dist (required, e.g. <repo>/dist/chrome)
12
+ # --target <dir> absolute checkout containment root
13
+ # --runtime-root <dir> absolute directory that owns the snapshot
12
14
  # --runtime-dist <dir> snapshot destination (required, recreated)
13
15
  # --wait-iterations <n> manifest wait loop length, 2s each (default 180)
14
16
  # --summary <file> optional standard summary.json
17
+ # --exact preserve every source entry (release artifacts)
15
18
  #
16
19
  # Outputs:
17
20
  # <runtime-dist>/ snapshot (excludes _metadata); optional --summary file
@@ -24,24 +27,32 @@
24
27
  set -euo pipefail
25
28
 
26
29
  DIST=""
30
+ TARGET_ROOT=""
31
+ RUNTIME_ROOT=""
27
32
  RUNTIME_DIST=""
28
33
  WAIT_ITERATIONS=180
29
34
  SUMMARY=""
35
+ EXACT=false
30
36
  require_value() { [ "$#" -ge 2 ] || { echo "Missing value for $1" >&2; exit 2; }; }
31
37
  while [ "$#" -gt 0 ]; do
32
38
  case "$1" in
33
39
  --dist) require_value "$@"; DIST="$2"; shift 2 ;;
40
+ --target) require_value "$@"; TARGET_ROOT="$2"; shift 2 ;;
41
+ --runtime-root) require_value "$@"; RUNTIME_ROOT="$2"; shift 2 ;;
34
42
  --runtime-dist) require_value "$@"; RUNTIME_DIST="$2"; shift 2 ;;
35
43
  --wait-iterations) require_value "$@"; WAIT_ITERATIONS="$2"; shift 2 ;;
36
44
  --summary) require_value "$@"; SUMMARY="$2"; shift 2 ;;
45
+ --exact) EXACT=true; shift ;;
37
46
  -h|--help)
38
- echo "Usage: snapshot-dist.sh --dist <dir> --runtime-dist <dir> [--wait-iterations <n>] [--summary <file>]"
47
+ echo "Usage: snapshot-dist.sh --dist <dir> --target <dir> --runtime-root <dir> --runtime-dist <dir> [--wait-iterations <n>] [--summary <file>]"
39
48
  exit 0
40
49
  ;;
41
50
  *) echo "Unknown arg: $1" >&2; exit 2 ;;
42
51
  esac
43
52
  done
44
53
  [ -n "$DIST" ] || { echo "Missing --dist" >&2; exit 2; }
54
+ [ -n "$TARGET_ROOT" ] || { echo "Missing --target" >&2; exit 2; }
55
+ [ -n "$RUNTIME_ROOT" ] || { echo "Missing --runtime-root" >&2; exit 2; }
45
56
  [ -n "$RUNTIME_DIST" ] || { echo "Missing --runtime-dist" >&2; exit 2; }
46
57
  case "$WAIT_ITERATIONS" in ''|*[!0-9]*) echo "Invalid --wait-iterations (must be numeric): $WAIT_ITERATIONS" >&2; exit 2 ;; esac
47
58
 
@@ -73,8 +84,82 @@ done
73
84
  test -f "$DIST/manifest.json" || { echo "snapshot-dist: no manifest at $DIST/manifest.json" >&2; exit 1; }
74
85
 
75
86
  echo "[recipe-harness] snapshotting dist -> runtime-dist: $RUNTIME_DIST" >&2
76
- rm -rf "$RUNTIME_DIST" && mkdir -p "$RUNTIME_DIST" \
77
- && rsync -a --delete --exclude _metadata "$DIST/" "$RUNTIME_DIST/" || exit 1
87
+ node - "$DIST" "$TARGET_ROOT" "$RUNTIME_ROOT" "$RUNTIME_DIST" <<'NODE'
88
+ const fs = require('node:fs');
89
+ const path = require('node:path');
90
+
91
+ const [sourceInput, targetInput, rootInput, destinationInput] = process.argv.slice(2);
92
+ const source = path.resolve(sourceInput);
93
+ const target = path.resolve(targetInput);
94
+ const root = path.resolve(rootInput);
95
+ const destination = path.resolve(destinationInput);
96
+
97
+ function refuse(message) {
98
+ throw new Error(`snapshot-dist: ${message}`);
99
+ }
100
+
101
+ if (![targetInput, rootInput, destinationInput].every(path.isAbsolute)) {
102
+ refuse('--target, --runtime-root, and --runtime-dist must be absolute paths.');
103
+ }
104
+ const targetStat = fs.lstatSync(target, { throwIfNoEntry: false });
105
+ if (!targetStat?.isDirectory() || targetStat.isSymbolicLink()) {
106
+ refuse(`target is not a regular directory: ${target}.`);
107
+ }
108
+ const rootRelative = path.relative(target, root);
109
+ if (!rootRelative || rootRelative === '..' || rootRelative.startsWith(`..${path.sep}`) || path.isAbsolute(rootRelative)) {
110
+ refuse(`runtime root must be a child of ${target}.`);
111
+ }
112
+ const relative = path.relative(root, destination);
113
+ if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
114
+ refuse(`runtime destination must be a child of ${root}.`);
115
+ }
116
+ if (
117
+ source === destination ||
118
+ source.startsWith(`${destination}${path.sep}`) ||
119
+ destination.startsWith(`${source}${path.sep}`)
120
+ ) {
121
+ refuse('source and runtime destination must be separate directory trees.');
122
+ }
123
+
124
+ function ensureDirectoryTree(base, relativePath) {
125
+ let current = base;
126
+ const components = relativePath.split(path.sep).filter(Boolean);
127
+ for (const component of components) {
128
+ current = path.join(current, component);
129
+ const stat = fs.lstatSync(current, { throwIfNoEntry: false });
130
+ if (!stat) {
131
+ fs.mkdirSync(current, { mode: 0o700 });
132
+ continue;
133
+ }
134
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
135
+ refuse(`runtime path contains an unsafe entry: ${current}.`);
136
+ }
137
+ }
138
+ }
139
+
140
+ ensureDirectoryTree(target, rootRelative);
141
+ ensureDirectoryTree(root, path.relative(root, path.dirname(destination)));
142
+ const realTarget = fs.realpathSync(target);
143
+ const realRoot = fs.realpathSync(root);
144
+ const realParent = fs.realpathSync(path.dirname(destination));
145
+ if (
146
+ !realRoot.startsWith(`${realTarget}${path.sep}`) ||
147
+ (realParent !== realRoot && !realParent.startsWith(`${realRoot}${path.sep}`))
148
+ ) {
149
+ refuse(`runtime destination escapes ${root}.`);
150
+ }
151
+ const destinationStat = fs.lstatSync(destination, { throwIfNoEntry: false });
152
+ if (destinationStat && (!destinationStat.isDirectory() || destinationStat.isSymbolicLink())) {
153
+ refuse(`runtime destination is not a regular directory: ${destination}.`);
154
+ }
155
+ if (destinationStat) fs.rmSync(destination, { recursive: true });
156
+ fs.mkdirSync(destination, { mode: 0o700 });
157
+ NODE
158
+ if $EXACT; then
159
+ rsync -a --delete "$DIST/" "$RUNTIME_DIST/" || exit 1
160
+ else
161
+ rsync -a --delete --exclude _metadata "$DIST/" "$RUNTIME_DIST/" || exit 1
162
+ fi
78
163
  echo "[recipe-harness] runtime-dist snapshot complete" >&2
79
164
 
80
165
  # Freshness guard: the loaded runtime-dist must match dist/chrome's git id. A
@@ -142,6 +142,21 @@ trap finish EXIT
142
142
 
143
143
  mkdir -p "$RUNTIME_DIR"
144
144
 
145
+ PRODUCT_CONFIG_MODULE="$SCRIPT_DIR/lib/product-config.cjs" \
146
+ node - "$PWD" "$RUNTIME_DIR/extension-product-config.sha256" <<'NODE'
147
+ const crypto = require('node:crypto');
148
+ const fs = require('node:fs');
149
+ const config = require(process.env.PRODUCT_CONFIG_MODULE);
150
+ const [target, destination] = process.argv.slice(2);
151
+ const resolution = config.resolveExtensionInfuraProjectId(target, process.env);
152
+ if (resolution.kind !== 'configured') {
153
+ fs.rmSync(destination, { force: true });
154
+ process.exit(0);
155
+ }
156
+ const fingerprint = crypto.createHash('sha256').update(resolution.value).digest('hex');
157
+ fs.writeFileSync(destination, `${fingerprint}\n`);
158
+ NODE
159
+
145
160
  watcher_pids() {
146
161
  ps -axo pid=,command= | while read -r current_pid command; do
147
162
  [ -n "${current_pid:-}" ] || continue
@@ -344,14 +344,18 @@ let r = {};
344
344
  try { r = JSON.parse(fs.readFileSync(dir + "/runtime-decision.json", "utf8")); } catch {}
345
345
  const c = r.checks || {};
346
346
  const dist = c.dist || { status: "unknown" };
347
- const distMsg = dist.status === "fresh" ? "dist id matches HEAD; no uncommitted source."
347
+ const distMsg = dist.source === "release-artifact"
348
+ ? ("release artifact " + (dist.manifestVersion || "?") + " sha256=" + (dist.artifactSha256 || "?") + " matches the loaded runtime snapshot.")
349
+ : dist.status === "fresh" ? "dist id matches HEAD; no uncommitted source."
348
350
  : dist.status === "stale" ? (dist.reason === "uncommitted-source"
349
351
  ? ((dist.modified ? dist.modified.length : "some") + " uncommitted source file(s); rebuild or commit.")
350
352
  : ("dist id " + (dist.distGitId || "?") + " != HEAD " + (dist.head || "?") + "; rebuild."))
351
353
  : dist.status === "no-build" ? "no dist/chrome build."
352
354
  : "no git id in dist or not a git checkout; cannot prove parity.";
353
355
  fs.writeFileSync(dir + "/dist-freshness.json", JSON.stringify({ ...dist, message: distMsg }));
354
- const bl = c.buildLog || { status: "unknown" };
356
+ const bl = c.releaseArtifact?.status === "valid"
357
+ ? { status: "no-watch", source: "release-artifact" }
358
+ : (c.buildLog || { status: "unknown" });
355
359
  const blMsg = bl.status === "ok" ? "webpack compiled."
356
360
  : bl.status === "no-watch" ? "no webpack watch log; build-health n/a (e.g. one-shot build)."
357
361
  : bl.status === "building" ? "webpack has not reported a successful compile yet."
@@ -658,7 +658,9 @@ async function detectExtension(context, extensionDir, extensionIdFile) {
658
658
  }
659
659
 
660
660
  for (const candidate of candidates) {
661
- const page = await context.newPage();
661
+ const page = context.pages().find(
662
+ (candidatePage) => extensionIdFromUrl(candidatePage.url()) === candidate.id,
663
+ ) || await context.newPage();
662
664
  try {
663
665
  await page.goto(`chrome-extension://${candidate.id}/home.html`, {
664
666
  waitUntil: 'domcontentloaded',
@@ -58,6 +58,14 @@
58
58
  "inputs": "--platform ios|android --target --port; env MOBILE_BUNDLE_PREWARM, MOBILE_BUNDLE_PREWARM_TIMEOUT",
59
59
  "outputs": "progress on stderr; exit 0 bundle ready / 1 timeout / 2 bad args"
60
60
  },
61
+ {
62
+ "id": "mobile/reset-app-data",
63
+ "entry": "adapters/mobile/reset-app-data.sh",
64
+ "kind": "bash",
65
+ "purpose": "Reset exactly one installed MetaMask Mobile app while preserving its install for fixture reapplication.",
66
+ "inputs": "--platform ios|android --target --simulator --adb-serial; env IOS_BUNDLE_ID, ANDROID_PACKAGE_ID",
67
+ "outputs": "selected app data cleared and app restored on iOS / cleared on Android; exit 0/1/2"
68
+ },
61
69
  {
62
70
  "id": "mobile/open-device",
63
71
  "entry": "adapters/mobile/open-device.sh",
@@ -162,6 +170,14 @@
162
170
  "inputs": "--target --manifest",
163
171
  "outputs": "runtime manifest _flags.testing.infuraProjectId; exit 0/1"
164
172
  },
173
+ {
174
+ "id": "extension/check-infura-readiness",
175
+ "entry": "adapters/extension/check-infura-readiness.cjs",
176
+ "kind": "node",
177
+ "purpose": "Reject compiled Infura credentials that are stale or unreachable on Ethereum or Linea before Chromium starts.",
178
+ "inputs": "--target --runtime-dist",
179
+ "outputs": "readiness result without credential values; exit 0/1/2"
180
+ },
165
181
  {
166
182
  "id": "extension/stamp-runtime-title",
167
183
  "entry": "adapters/extension/stamp-runtime-title.cjs",
@@ -170,6 +186,14 @@
170
186
  "inputs": "--target --runtime-dist; optional --runtime-dir",
171
187
  "outputs": "home.html and sidepanel.html titles updated in the isolated runtime only; exit 0/1"
172
188
  },
189
+ {
190
+ "id": "extension/artifact-runtime-state",
191
+ "entry": "adapters/extension/artifact-runtime-state.cjs",
192
+ "kind": "node",
193
+ "purpose": "Record or clear the acquired release artifact identity bound to the loaded runtime snapshot.",
194
+ "inputs": "set|clear --target --runtime-dir; set also requires --source-dir --runtime-dist --provenance",
195
+ "outputs": "<runtime-dir>/extension-release-artifact.json or removal; exit 0/1/2"
196
+ },
173
197
  {
174
198
  "id": "extension/stop-viewers",
175
199
  "entry": "adapters/extension/stop-viewers.sh",
@@ -191,7 +215,7 @@
191
215
  "entry": "adapters/extension/snapshot-dist.sh",
192
216
  "kind": "bash",
193
217
  "purpose": "Runtime-dist snapshot (rsync, excludes _metadata) with from-git-id freshness guard.",
194
- "inputs": "--dist --runtime-dist --wait-iterations --summary",
218
+ "inputs": "--dist --target --runtime-root --runtime-dist --wait-iterations --summary",
195
219
  "outputs": "<runtime-dist>/ snapshot; optional summary; exit 0/1/2"
196
220
  },
197
221
  {
@@ -84,6 +84,65 @@ const NESTED_ROUTE_PARENTS = {
84
84
  DeveloperOptions: 'Settings', NotificationsSettings: 'Settings',
85
85
  };
86
86
 
87
+ async function setInput(client, testId, value, { deviceName } = {}, redact = false) {
88
+ if (!testId) {
89
+ throw new Error('Usage: set-input <testId> <value>');
90
+ }
91
+ const expr = `(function() {
92
+ if (globalThis.__AGENTIC__?.setInput) return globalThis.__AGENTIC__.setInput(${JSON.stringify(testId)}, ${JSON.stringify(value)});
93
+ var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
94
+ if (!hook) return { ok: false, error: 'No React DevTools hook' };
95
+ var renderers = hook.renderers;
96
+ if (!renderers) return { ok: false, error: 'No renderers' };
97
+ var getFiberRoots = hook.getFiberRoots;
98
+ function findByTestId(fiber) {
99
+ if (!fiber) return null;
100
+ var props = fiber.memoizedProps;
101
+ if (props && props.testID === ${JSON.stringify(testId)}) return fiber;
102
+ return findByTestId(fiber.child) || findByTestId(fiber.sibling);
103
+ }
104
+ for (var [id] of renderers) {
105
+ var roots = getFiberRoots ? getFiberRoots(id) : undefined;
106
+ if (!roots) continue;
107
+ var result = null;
108
+ roots.forEach(function(r) {
109
+ if (result) return;
110
+ var fiber = findByTestId(r.current);
111
+ if (!fiber) return;
112
+ var cur = fiber;
113
+ while (cur) {
114
+ if (cur.memoizedProps && typeof cur.memoizedProps.onChangeText === 'function') {
115
+ cur.memoizedProps.onChangeText(${JSON.stringify(value)});
116
+ result = { ok: true, testId: ${JSON.stringify(testId)} };
117
+ return;
118
+ }
119
+ cur = cur.return || null;
120
+ }
121
+ });
122
+ if (result) return result;
123
+ }
124
+ return { ok: false, error: 'No component with testID=' + ${JSON.stringify(testId)} + ' found or no onChangeText' };
125
+ })()`;
126
+ let result;
127
+ try {
128
+ result = await cdpEval(client, expr);
129
+ } catch (error) {
130
+ if (redact) {
131
+ throw new Error(`Secret input could not be applied for testID ${testId}.`);
132
+ }
133
+ throw error;
134
+ }
135
+ if (redact && result?.ok === false) {
136
+ result = { ok: false, error: 'Secret input could not be applied.' };
137
+ }
138
+ return {
139
+ ...result,
140
+ testId,
141
+ value: redact ? '<redacted>' : value,
142
+ deviceName,
143
+ };
144
+ }
145
+
87
146
  const COMMANDS = {
88
147
  async navigate(client, args, { deviceName, platform } = {}) {
89
148
  const routeName = ROUTE_ALIASES[args[0]] || args[0];
@@ -608,50 +667,21 @@ const COMMANDS = {
608
667
  };
609
668
  },
610
669
 
611
- async 'set-input'(client, args, { deviceName } = {}) {
670
+ async 'set-input'(client, args, context = {}) {
671
+ return setInput(client, args[0], args.slice(1).join(' '), context);
672
+ },
673
+
674
+ async 'set-input-file'(client, args, context = {}) {
612
675
  const testId = args[0];
613
- const value = args.slice(1).join(' ');
614
- if (!testId) {
615
- throw new Error('Usage: set-input <testId> <value>');
676
+ const file = args[1];
677
+ if (!testId || !file) {
678
+ throw new Error('Usage: set-input-file <testId> <value-file>');
616
679
  }
617
- // Try __AGENTIC__ bridge first, fall back to inline fiber walking
618
- const expr = `(function() {
619
- if (globalThis.__AGENTIC__?.setInput) return globalThis.__AGENTIC__.setInput(${JSON.stringify(testId)}, ${JSON.stringify(value)});
620
- var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
621
- if (!hook) return { ok: false, error: 'No React DevTools hook' };
622
- var renderers = hook.renderers;
623
- if (!renderers) return { ok: false, error: 'No renderers' };
624
- var getFiberRoots = hook.getFiberRoots;
625
- function findByTestId(fiber) {
626
- if (!fiber) return null;
627
- var props = fiber.memoizedProps;
628
- if (props && props.testID === ${JSON.stringify(testId)}) return fiber;
629
- return findByTestId(fiber.child) || findByTestId(fiber.sibling);
630
- }
631
- for (var [id] of renderers) {
632
- var roots = getFiberRoots ? getFiberRoots(id) : undefined;
633
- if (!roots) continue;
634
- var result = null;
635
- roots.forEach(function(r) {
636
- if (result) return;
637
- var fiber = findByTestId(r.current);
638
- if (!fiber) return;
639
- var cur = fiber;
640
- while (cur) {
641
- if (cur.memoizedProps && typeof cur.memoizedProps.onChangeText === 'function') {
642
- cur.memoizedProps.onChangeText(${JSON.stringify(value)});
643
- result = { ok: true, testId: ${JSON.stringify(testId)}, value: ${JSON.stringify(value)} };
644
- return;
645
- }
646
- cur = cur.return || null;
647
- }
648
- });
649
- if (result) return result;
650
- }
651
- return { ok: false, error: 'No component with testID=' + ${JSON.stringify(testId)} + ' found or no onChangeText' };
652
- })()`;
653
- const result = await cdpEval(client, expr);
654
- return { ...result, testId, value, deviceName };
680
+ const stat = fs.lstatSync(file);
681
+ if (!stat.isFile() || stat.isSymbolicLink()) {
682
+ throw new Error('set-input-file requires a regular value file.');
683
+ }
684
+ return setInput(client, testId, fs.readFileSync(file, 'utf8'), context, true);
655
685
  },
656
686
 
657
687
  async 'sentry-debug'(client, args, { deviceName } = {}) {
@@ -977,6 +1007,7 @@ Commands:
977
1007
  Scroll a ScrollView/FlatList
978
1008
  measure-scroll-transition <json> Scroll and measure a visible target using one CDP session
979
1009
  set-input <testId> <value> Set text input value by testID (calls onChangeText)
1010
+ set-input-file <testId> <value-file> Set text input from a regular file and redact the value
980
1011
  sentry-debug [enable|disable] Patch Sentry to log errors to console with [SENTRY-DEBUG] prefix
981
1012
  unlock <password> Unlock wallet (inject password + press login button via fiber tree)
982
1013
  profiler-start Start Hermes sampling profiler