@deeeed/metamask-harness 0.68.0 → 0.70.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/RECIPES.md CHANGED
@@ -542,12 +542,19 @@ selected deterministically from `recipes/<adapter>/<domain>/`. Identical
542
542
  cross-adapter graphs live once in `recipes/shared/<domain>/`; an adapter-specific
543
543
  variant takes precedence. Both resolve as `<domain>.<recipe>`, without a platform
544
544
  or `shared` prefix. Keep domains below `core`, `extension`, `mobile`, or `shared`.
545
- Legacy
546
- `*.<adapter>.recipe.json` paths remain readable during migration, but new and
547
- migrated libraries use the adapter-first layout. A file cannot declare its
545
+
546
+ Legacy `*.<adapter>.recipe.json` paths remain readable during migration, but new
547
+ and migrated libraries use the adapter-first layout. A file cannot declare its
548
548
  adapter through both forms. Every run records the root recipe, exact resolved
549
549
  dependency documents, their digests, call edges, selected sources, and shadows.
550
550
 
551
+ `mm-harness run --list` and `mm-harness call --list` group recipes/actions by domain
552
+ and show their library origins separately.
553
+ Use `--domain swap-bridge` or `--source perps` to filter, and `--sort name|domain|library`
554
+ to change the order. These options also apply to `--json` output, which keeps name
555
+ ordering unless `--sort` is supplied. Domain filters accept hyphens or underscores,
556
+ so `--domain swap-bridge` includes `metamask.swap_bridge.*` actions.
557
+
551
558
  ## Direct product access
552
559
 
553
560
  The bundled library is intentionally small. A task may use a verified controller
@@ -19,6 +19,7 @@ import { closeSync, openSync } from 'node:fs';
19
19
  import { spawn } from 'node:child_process';
20
20
  import { createRequire } from 'node:module';
21
21
  import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
22
23
 
23
24
  import {
24
25
  importRecipeHarnessRuntimeBrowserExtension,
@@ -368,24 +369,26 @@ function autolaunchEnabled(input) {
368
369
  process.env.METAMASK_RECIPE_EXTENSION_LAUNCH_EXISTING_DIST === '1';
369
370
  }
370
371
 
371
- function projectRequire(projectRoot) {
372
- return createRequire(path.join(projectRoot, 'package.json'));
373
- }
372
+ const runnerRequire = createRequire(import.meta.url);
373
+ const runnerRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..');
374
+ const browserResolver = runnerRequire(path.join(runnerRoot, 'adapters/shared/browser-resolver.cjs'));
375
+ const { stopProfileProcessesSync } = runnerRequire(path.join(runnerRoot, 'adapters/extension/lib/validation-process-ownership.cjs'));
374
376
 
375
- function resolveChromeBinary(projectRoot) {
376
- if (process.env.RECIPE_HARNESS_CHROME_BIN) return process.env.RECIPE_HARNESS_CHROME_BIN;
377
- const requireFromProject = projectRequire(projectRoot);
378
- for (const packageName of ['@playwright/test', 'playwright']) {
379
- try {
380
- const { chromium } = requireFromProject(packageName);
381
- const executable = chromium.executablePath();
382
- if (executable) return executable;
383
- } catch (error) {
384
- if (error?.code === 'MODULE_NOT_FOUND') continue;
385
- throw error;
386
- }
377
+ // Same browser choice as the live launcher (RECIPE_HARNESS_BROWSER /
378
+ // RECIPE_HARNESS_CHROME_BIN); branded Chrome loads the dist over CDP. This
379
+ // launcher spawns a headful browser directly, so the probe does too, and a
380
+ // reused profile keeps its recorded browser.
381
+ async function resolveChromeBinary(projectRoot, recorded) {
382
+ try {
383
+ return await browserResolver.resolveBrowser({
384
+ cft: () => browserResolver.playwrightChromiumExecutable(projectRoot),
385
+ launchMethod: browserResolver.SPAWN,
386
+ display: browserResolver.HEADFUL,
387
+ recorded,
388
+ });
389
+ } catch (error) {
390
+ throw new Error(`Extension autolaunch could not select a browser: ${error.message}`);
387
391
  }
388
- throw new Error('Extension autolaunch requires RECIPE_HARNESS_CHROME_BIN or Playwright installed in the target checkout.');
389
392
  }
390
393
 
391
394
  async function execNodeScript(script, args, options) {
@@ -446,7 +449,7 @@ async function findWalletFixture(projectRoot) {
446
449
  return null;
447
450
  }
448
451
 
449
- async function launchExistingDistRuntime(input, port) {
452
+ export async function launchExistingDistRuntime(input, port) {
450
453
  const projectRoot = input.context.projectRoot;
451
454
  const artifactsDir = input.context.artifactsDir;
452
455
  const distDir = path.resolve(projectRoot, input.node?.dist_dir || process.env.METAMASK_RECIPE_EXTENSION_DIST_DIR || 'dist/chrome');
@@ -476,12 +479,23 @@ async function launchExistingDistRuntime(input, port) {
476
479
  fixtureSeeded = true;
477
480
  }
478
481
 
479
- const chrome = resolveChromeBinary(projectRoot);
482
+ // Never start a second browser on a port something else holds: whatever
483
+ // answers there afterwards would not be ours to load or seed.
484
+ const holders = browserResolver.cdpListenerPids(port);
485
+ if (holders.length > 0) {
486
+ throw new Error(`Extension autolaunch refused: CDP port ${port} is already held by pid ${holders.join(', ')}. Next: stop that browser or choose a free --cdp-port.`);
487
+ }
488
+ // Next to the profile, so a reused profile (METAMASK_RECIPE_EXTENSION_PROFILE_DIR)
489
+ // finds the browser it was created with.
490
+ const browserRecord = `${profileDir}.browser.json`;
491
+ const profileExisted = await canAccess(path.join(profileDir, 'Local State'));
492
+ const browser = await resolveChromeBinary(projectRoot, profileExisted ? browserRecord : null);
493
+ const loadsOverCdp = browser.extensionLoading === browserResolver.CDP_LOAD_UNPACKED;
480
494
  const stdoutFd = openSync(path.join(logsDir, 'chrome.log'), 'a');
481
495
  const stderrFd = openSync(path.join(logsDir, 'chrome.log'), 'a');
482
496
  let child;
483
497
  try {
484
- child = spawn(chrome, [
498
+ child = spawn(browser.bin, [
485
499
  `--user-data-dir=${profileDir}`,
486
500
  ...(process.platform === 'darwin' ? ['--use-mock-keychain'] : []),
487
501
  '--remote-debugging-address=127.0.0.1',
@@ -493,8 +507,7 @@ async function launchExistingDistRuntime(input, port) {
493
507
  '--disable-extensions-file-access-check',
494
508
  '--disable-extensions-content-verification',
495
509
  '--disable-features=ExtensionContentVerification,DisableLoadExtensionCommandLineSwitch',
496
- `--disable-extensions-except=${runtimeDist}`,
497
- `--load-extension=${runtimeDist}`,
510
+ ...browserResolver.extensionLaunchArgs(runtimeDist, browser.extensionLoading),
498
511
  'chrome://extensions/',
499
512
  ], {
500
513
  cwd: projectRoot,
@@ -516,32 +529,58 @@ async function launchExistingDistRuntime(input, port) {
516
529
  }
517
530
  child.unref();
518
531
  await writeFile(path.join(logsDir, 'chrome.pid'), `${child.pid}\n`);
519
- await retryJsonGet(`http://127.0.0.1:${port}/json/version`, 45000);
520
-
521
532
  let fixtureValidation = null;
522
- if (fixtureSeeded) {
523
- fixtureValidation = path.join(logsDir, 'fixture-account-parity.json');
524
- await execNodeScript(
525
- fixtureScript,
526
- [
527
- 'seed-cdp',
528
- '--target',
529
- projectRoot,
530
- '--fixture',
531
- fixture,
532
- '--state',
533
- fixtureState,
534
- '--cdp-port',
535
- String(port),
536
- '--extension-dir',
537
- runtimeDist,
538
- '--extension-id-file',
539
- extensionIdFile,
540
- '--out',
541
- fixtureValidation,
542
- ],
543
- { cwd: projectRoot },
544
- );
533
+ let launchedBrowser = null;
534
+ try {
535
+ await retryJsonGet(`http://127.0.0.1:${port}/json/version`, 45000);
536
+ const owner = browserResolver.waitForCdpOwner(port, profileDir);
537
+ const cdpLoad = loadsOverCdp ? await browserResolver.loadUnpackedOverPort(port, runtimeDist, { profile: profileDir }) : null;
538
+ launchedBrowser = {
539
+ ...browser,
540
+ launch: {
541
+ pid: owner.pid,
542
+ cdpPort: port,
543
+ startedAt: new Date().toISOString(),
544
+ profile: owner.profile,
545
+ processStartedAt: owner.startedAt,
546
+ ...(cdpLoad ? { extensionId: cdpLoad.id, otherExtensions: cdpLoad.otherExtensions } : {}),
547
+ },
548
+ };
549
+ await writeFile(browserRecord, `${JSON.stringify(launchedBrowser, null, 2)}\n`);
550
+ if (fixtureSeeded) {
551
+ fixtureValidation = path.join(logsDir, 'fixture-account-parity.json');
552
+ await execNodeScript(
553
+ fixtureScript,
554
+ [
555
+ 'seed-cdp',
556
+ '--target',
557
+ projectRoot,
558
+ '--fixture',
559
+ fixture,
560
+ '--state',
561
+ fixtureState,
562
+ '--cdp-port',
563
+ String(port),
564
+ '--extension-dir',
565
+ runtimeDist,
566
+ '--extension-id-file',
567
+ extensionIdFile,
568
+ '--out',
569
+ fixtureValidation,
570
+ ],
571
+ { cwd: projectRoot },
572
+ );
573
+ }
574
+ } catch (error) {
575
+ // The launch failed after the browser started: stop it before reporting,
576
+ // so no detached browser outlives this action. A failed stop is reported
577
+ // with the original failure, never swallowed.
578
+ try {
579
+ stopProfileProcessesSync(profileDir, { extraPids: [child.pid] });
580
+ } catch (stopError) {
581
+ throw new Error(`${error.message}\nStopping the autolaunched browser (pid ${child.pid}, profile ${profileDir}) also failed: ${stopError.message}`, { cause: error });
582
+ }
583
+ throw error;
545
584
  }
546
585
 
547
586
  await writeFile(path.join(runtimeRoot, 'runtime.json'), `${JSON.stringify({
@@ -553,6 +592,8 @@ async function launchExistingDistRuntime(input, port) {
553
592
  profileDir,
554
593
  fixtureSeeded,
555
594
  fixtureValidation,
595
+ browser: launchedBrowser,
596
+ browserRecord,
556
597
  launchedAt: new Date().toISOString(),
557
598
  }, null, 2)}\n`);
558
599
  return { launched: true, pid: child.pid, runtimeRoot, fixtureSeeded };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.68.0",
3
+ "version": "0.70.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -44,8 +44,8 @@ _mmh_flags_for() {
44
44
  execution-template) printf '%s' "--dir --domain-dir --project-worker --project-name --package-templates --package-id --flow --run-mode --platform --domain --id --provenance --include-shadowed --no-include-shadowed --title --force --json" ;;
45
45
  recipe-quality) printf '%s' "--input --output --recipe --task --evidence --params --adapter --library --advisor --advisor-model --advisor-stages --json" ;;
46
46
  actions) printf '%s' "--adapter --action --kind --raw --action-manifest --target --json" ;;
47
- call) printf '%s' "--list --arg --adapter --target --artifacts-dir --action-manifest --heal --json" ;;
48
- run) printf '%s' "--list --describe --plan --adapter --artifacts-dir --target --library --cdp-port --heal --record-video --json" ;;
47
+ call) printf '%s' "--list --domain --source --sort --arg --adapter --target --artifacts-dir --action-manifest --heal --json" ;;
48
+ run) printf '%s' "--list --domain --source --sort --describe --plan --adapter --artifacts-dir --target --library --cdp-port --heal --record-video --json" ;;
49
49
  doctor) printf '%s' "--adapter --target --fix --json" ;;
50
50
  install|verify|cleanup) printf '%s' "--adapter --target --json" ;;
51
51
  *) printf '%s' "" ;;
@@ -449,8 +449,12 @@ async function checkBehavior(browserEndpoint, port, pages) {
449
449
  return checked;
450
450
  })()
451
451
  `);
452
- if (!previews.includes('review-pr/static-perps.mobile') || !previews.includes('qa/default')) {
453
- throw new Error('Static review and QA previews must both be available');
452
+ if (!['fix-bug/extension', 'fix-bug/mobile', 'qa/default'].every(id => previews.includes(id))) {
453
+ throw new Error('Runtime workflow previews must be available');
454
+ }
455
+ const reviewSource = await page.evaluate("document.querySelector('[data-static-review-source]')?.href");
456
+ if (!reviewSource?.endsWith('/domains/perps/skills/perps-review-pr/skill.md')) {
457
+ throw new Error('Static review must link to its owning skill');
454
458
  }
455
459
  const progress = await page.evaluate(String.raw`
456
460
  (() => {
@@ -514,7 +518,16 @@ async function checkBehavior(browserEndpoint, port, pages) {
514
518
  await new Promise((resolve) => setTimeout(resolve, 100));
515
519
  }
516
520
  }
517
- if (!response?.ok) failures.push(url.pathname + ' (' + (response?.status ?? 'network') + ')');
521
+ if (!response?.ok) {
522
+ failures.push(url.pathname + ' (' + (response?.status ?? 'network') + ')');
523
+ } else if (url.hash) {
524
+ const target = url.pathname === location.pathname
525
+ ? document
526
+ : new DOMParser().parseFromString(await response.text(), 'text/html');
527
+ if (!target.getElementById(decodeURIComponent(url.hash.slice(1)))) {
528
+ failures.push(url.pathname + url.hash + ' (missing anchor)');
529
+ }
530
+ }
518
531
  }
519
532
  return failures;
520
533
  })()