@deeeed/metamask-harness 0.69.0 → 0.70.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.
@@ -475,8 +475,13 @@ async function openBrokeredMobileDebugger(port, shouldOpen) {
475
475
  function pickExtensionTarget(targets, targetKind) {
476
476
  const extensionTargets = targets.filter((target) => String(target.url || '').startsWith('chrome-extension://'));
477
477
  if (targetKind === 'worker') {
478
+ // Branded Chrome runs component extensions with their own service workers;
479
+ // prefer the worker of the extension whose wallet page is open.
480
+ const walletPage = extensionTargets.find((target) => /\/(home|sidepanel)\.html/u.test(String(target.url || '')));
481
+ const walletId = process.env.RECIPE_HARNESS_EXTENSION_ID || (walletPage ? String(walletPage.url).split('/')[2] : '');
478
482
  return (
479
- extensionTargets.find((target) => target.type === 'service_worker')
483
+ (walletId && extensionTargets.find((target) => target.type === 'service_worker' && String(target.url).startsWith(`chrome-extension://${walletId}/`)))
484
+ || extensionTargets.find((target) => target.type === 'service_worker')
480
485
  || targets.find((target) => target.type === 'service_worker' && String(target.url || '').startsWith('chrome-extension://'))
481
486
  || null
482
487
  );
@@ -44,6 +44,7 @@ import {
44
44
  beginRunDiagnostics,
45
45
  finishRunDiagnostics
46
46
  } from "../run-diagnostics.js";
47
+ import { executedBrowser, recipeCdpPorts } from "./run-report.js";
47
48
  import { EXIT } from "./shared.js";
48
49
  import {
49
50
  actionManifestPathOption,
@@ -155,7 +156,13 @@ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManif
155
156
  finalized.artifactManifestPath,
156
157
  effectsState
157
158
  );
158
- return finalized;
159
+ const browser = executedBrowser(
160
+ adapter,
161
+ projectRoot,
162
+ finalized.artifactManifestPath,
163
+ recipeCdpPorts(runRequest)
164
+ );
165
+ return browser ? { ...finalized, browser } : finalized;
159
166
  } finally {
160
167
  restoreRuntimeEnvironment();
161
168
  }
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
+ import { createRequire } from "node:module";
2
3
  import path from "node:path";
3
4
  import { execFileSync } from "node:child_process";
5
+ import { recipeRuntimeDir } from "../paths.js";
4
6
  import { isRecord } from "./parse-args.js";
5
7
  function writeRunReport(result) {
6
8
  const summaryPath = String(result.summaryPath);
@@ -20,7 +22,7 @@ function writeRunReport(result) {
20
22
  preview: lines.filter((line) => line.startsWith("- ")).slice(0, 6).map((line) => line.slice(2))
21
23
  };
22
24
  }
23
- function indexProductProvenanceArtifact(artifactManifestPath, target, adapter) {
25
+ function indexProductProvenanceArtifact(artifactManifestPath, target, adapter, browser = null) {
24
26
  const artifactsDir = path.dirname(artifactManifestPath);
25
27
  let gitRef = "unknown";
26
28
  let branch = "unknown";
@@ -53,7 +55,8 @@ function indexProductProvenanceArtifact(artifactManifestPath, target, adapter) {
53
55
  repository: path.basename(path.resolve(target)),
54
56
  git_ref: gitRef,
55
57
  branch,
56
- dirty
58
+ dirty,
59
+ ...browser ? { browser } : {}
57
60
  };
58
61
  fs.writeFileSync(
59
62
  path.join(artifactsDir, "product-provenance.json"),
@@ -77,7 +80,13 @@ function indexProductProvenanceArtifact(artifactManifestPath, target, adapter) {
77
80
  type: "json",
78
81
  label: "Product checkout provenance",
79
82
  category: "system",
80
- metadata: { adapter, git_ref: gitRef, branch, dirty }
83
+ metadata: {
84
+ adapter,
85
+ git_ref: gitRef,
86
+ branch,
87
+ dirty,
88
+ ...browser?.boundTo && browser.boundTo !== "none" ? { browser: browser.browser, browser_version: browser.version, browser_source: browser.source, extension_loading: browser.extensionLoading } : {}
89
+ }
81
90
  }
82
91
  ];
83
92
  fs.writeFileSync(
@@ -86,6 +95,87 @@ function indexProductProvenanceArtifact(artifactManifestPath, target, adapter) {
86
95
  `
87
96
  );
88
97
  }
98
+ function readRecord(file) {
99
+ try {
100
+ const recorded = JSON.parse(fs.readFileSync(file, "utf8"));
101
+ return isRecord(recorded) ? recorded : null;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+ const browserCdp = createRequire(import.meta.url)("../../adapters/shared/browser-cdp.cjs");
107
+ function verifyLaunch(record, cdpPort) {
108
+ const launch = isRecord(record.launch) ? record.launch : {};
109
+ const pid = Number(launch.pid);
110
+ const port = Number(launch.cdpPort);
111
+ if (!Number.isInteger(pid) || !Number.isInteger(port) || typeof launch.profile !== "string" || typeof launch.processStartedAt !== "string") {
112
+ return "the browser record has no process identity (pid, start time, profile, CDP port)";
113
+ }
114
+ if (cdpPort && port !== Number(cdpPort)) return `the browser record is for CDP port ${port}, not ${cdpPort}`;
115
+ try {
116
+ const owner = browserCdp.cdpOwner(port, launch.profile);
117
+ if (owner.pid !== pid || owner.startedAt !== launch.processStartedAt) {
118
+ return `CDP port ${port} is now held by another browser process than the recorded pid ${pid}`;
119
+ }
120
+ } catch (error) {
121
+ return error instanceof Error ? error.message : String(error);
122
+ }
123
+ return null;
124
+ }
125
+ function resolveTemplatedPort(value, params, recipe) {
126
+ let resolved = value;
127
+ const template = /^\{\{\s*params\.([\w-]+)\s*\}\}$/u.exec(String(value).trim());
128
+ if (template) {
129
+ const name = template[1];
130
+ const schema = isRecord(recipe) && isRecord(recipe.paramsSchema) && isRecord(recipe.paramsSchema.properties) ? recipe.paramsSchema.properties[name] : void 0;
131
+ resolved = isRecord(params) && params[name] !== void 0 ? params[name] : isRecord(schema) ? schema.default : void 0;
132
+ }
133
+ const text = String(resolved ?? "").trim();
134
+ return /^\d+$/u.test(text) ? text : null;
135
+ }
136
+ function recipeCdpPorts(request) {
137
+ const ports = /* @__PURE__ */ new Set();
138
+ const envPort = request.env?.CDP_PORT || request.env?.RECIPE_CDP_PORT;
139
+ if (envPort) ports.add(String(envPort));
140
+ let recipe = request.recipeDocument;
141
+ if (recipe === void 0 && request.recipePath) {
142
+ try {
143
+ recipe = JSON.parse(fs.readFileSync(request.recipePath, "utf8"));
144
+ } catch {
145
+ recipe = void 0;
146
+ }
147
+ }
148
+ const workflow = isRecord(recipe) && isRecord(recipe.workflow) ? recipe.workflow : null;
149
+ const nodes = workflow && isRecord(workflow.nodes) ? Object.values(workflow.nodes) : [];
150
+ for (const node of nodes) {
151
+ if (!isRecord(node) || node.cdp_port === void 0 || node.cdp_port === null) continue;
152
+ const port = resolveTemplatedPort(node.cdp_port, request.params, recipe);
153
+ ports.add(port ?? `unresolved ${String(node.cdp_port)}`);
154
+ }
155
+ return [...ports];
156
+ }
157
+ function executedBrowser(adapter, target, artifactManifestPath, ports) {
158
+ if (adapter !== "extension") return null;
159
+ if (ports.length === 0) return { boundTo: "none", reason: "the run had no known CDP port" };
160
+ if (ports.length > 1) return { boundTo: "none", reason: `the run drove several CDP ports (${ports.join(", ")})` };
161
+ return launchedBrowser(target, path.dirname(artifactManifestPath), ports[0]);
162
+ }
163
+ function launchedBrowser(target, artifactsDir, cdpPort) {
164
+ const runtime = readRecord(path.join(artifactsDir, "extension-runtime", "runtime.json"));
165
+ const autolaunched = runtime && isRecord(runtime.browser) ? runtime.browser : null;
166
+ const candidates = [];
167
+ if (autolaunched) candidates.push([autolaunched, "run-autolaunch"]);
168
+ const slot = readRecord(path.join(path.resolve(target), recipeRuntimeDir(), "browser-resolution.json"));
169
+ if (slot) candidates.push([slot, "slot-runtime"]);
170
+ if (candidates.length === 0) return null;
171
+ const reasons = [];
172
+ for (const [record, boundTo] of candidates) {
173
+ const problem = verifyLaunch(record, cdpPort);
174
+ if (problem === null) return { ...record, boundTo };
175
+ reasons.push(`${boundTo}: ${problem}`);
176
+ }
177
+ return { boundTo: "none", reason: reasons.join("; ") };
178
+ }
89
179
  function renderRunReport(summary, entries) {
90
180
  const lines = [
91
181
  "# MetaMask Recipe Run",
@@ -187,6 +277,9 @@ function indexRunReportArtifact(artifactManifestPath) {
187
277
  `);
188
278
  }
189
279
  export {
280
+ executedBrowser,
190
281
  indexProductProvenanceArtifact,
282
+ launchedBrowser,
283
+ recipeCdpPorts,
191
284
  writeRunReport
192
285
  };
@@ -376,7 +376,8 @@ async function handleRunInner({ positional, options }, stream) {
376
376
  indexProductProvenanceArtifact(
377
377
  result.artifactManifestPath,
378
378
  target,
379
- adapter
379
+ adapter,
380
+ result.browser ?? null
380
381
  );
381
382
  if (violation !== null) {
382
383
  const userAction = violation.userAction ?? `inspect ${shellQuote(result.summaryPath)} and ${shellQuote(result.tracePath)}; fix the application or recipe failure before retrying`;
package/dist/doctor.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import fs from "node:fs";
2
3
  import { createRequire } from "node:module";
3
4
  import path from "node:path";
@@ -167,7 +168,7 @@ function createDoctorReport(adapter, target, manifestValidation, actionManifestP
167
168
  message: mode === "unsupported/no bridge" ? `No ${adapter} bridge is available for this checkout.` : `${adapter} compatibility mode: ${mode}.`
168
169
  },
169
170
  ...adapter === "mobile" ? mobileToolDoctorChecks(mobilePlatform) : [],
170
- ...adapter === "extension" ? [extensionBrowserDoctorCheck(target)] : []
171
+ ...adapter === "extension" ? [extensionBrowserDoctorCheck(target), extensionBrowserResolutionDoctorCheck(target)] : []
171
172
  ];
172
173
  const requiredChecks = requiredDoctorCheckSummary(checks);
173
174
  return {
@@ -214,6 +215,51 @@ function extensionBrowserDoctorCheck(target) {
214
215
  userAction: "yarn playwright install chromium"
215
216
  };
216
217
  }
218
+ function extensionBrowserResolutionDoctorCheck(target) {
219
+ const resolver = path.join(runnerDir, "adapters/shared/browser-resolver.cjs");
220
+ const runtime = path.join(path.resolve(target), recipeRuntimeDir());
221
+ const record = path.join(runtime, "browser-resolution.json");
222
+ let profile = process.env.CHROME_USER_DATA_DIR || path.join(runtime, process.env.RECIPE_CHROME_PROFILE_NAME || "chrome-profile");
223
+ while (profile.length > 1 && profile.endsWith("/")) profile = profile.slice(0, -1);
224
+ const profileExists = fs.existsSync(path.join(profile, "Local State"));
225
+ const result = spawnSync(process.execPath, [
226
+ resolver,
227
+ "resolve",
228
+ "--target",
229
+ path.resolve(target),
230
+ "--launch-method",
231
+ "launch-services",
232
+ "--display",
233
+ "headful",
234
+ ...profileExists && fs.existsSync(record) ? ["--recorded", record] : [],
235
+ "--json"
236
+ ], {
237
+ cwd: path.resolve(target),
238
+ encoding: "utf8",
239
+ timeout: 135e3
240
+ });
241
+ if (result.status === 0) {
242
+ const resolution = JSON.parse(result.stdout);
243
+ const managed = resolution.managedChrome;
244
+ const managedNote = managed && !resolution.warning?.includes("managed by policy") ? ` Google Chrome is managed by policy (${managed.forcedExtensions.length} forced extension${managed.forcedExtensions.length === 1 ? "" : "s"}: ${managed.forcedExtensions.map(({ id, name }) => name ? `${id} (${name})` : id).join(", ")}${managed.signinForced ? "; browser sign-in forced" : ""}); slots use Chrome for Testing.` : "";
245
+ const probe = resolution.probe ? `; Chrome for Testing probe (${resolution.probe.launchMethod}, ${resolution.probe.display}${resolution.probe.cached ? ", cached" : ""}${resolution.probe.transient ? `, retried after ${resolution.probe.expiresAt}` : ""}): ${resolution.probe.reason}` : resolution.recordedFrom ? `; kept from the slot profile record ${resolution.recordedFrom}` : "";
246
+ return {
247
+ id: "extension-browser",
248
+ status: "pass",
249
+ required: false,
250
+ message: `Launch browser: ${resolution.browser} ${resolution.version ?? "(unknown version)"} via ${resolution.source} (${resolution.mode}); extension loading: ${resolution.extensionLoading}${probe}.${managedNote}${resolution.warning ? ` Warning: ${resolution.warning}` : ""}`,
251
+ detail: resolution.bin
252
+ };
253
+ }
254
+ const message = (result.stderr || result.error?.message || "browser resolution failed").trim();
255
+ return {
256
+ id: "extension-browser",
257
+ status: "fail",
258
+ required: message.includes("BROWSER_UNLAUNCHABLE:"),
259
+ message,
260
+ userAction: "install Google Chrome, or set RECIPE_HARNESS_BROWSER / RECIPE_HARNESS_CHROME_BIN to a browser that starts"
261
+ };
262
+ }
217
263
  function mobileToolDoctorChecks(platform) {
218
264
  const specs = platform === "android" ? [{ tool: "adb", platform: "Android" }] : platform === "ios" ? [{ tool: "idb", platform: "iOS" }] : [
219
265
  { tool: "adb", platform: "Android" },
@@ -294,6 +340,7 @@ export {
294
340
  compatibilityMode,
295
341
  createDoctorReport,
296
342
  extensionBrowserDoctorCheck,
343
+ extensionBrowserResolutionDoctorCheck,
297
344
  fixtureFileSummary,
298
345
  fixtureSummary,
299
346
  mobileToolDoctorChecks,
package/docs/QA.md CHANGED
@@ -94,6 +94,9 @@ mm-harness debug
94
94
  `EXTENSION_PRODUCT_CONFIG_REQUIRED` before build or browser startup.
95
95
  - [ ] Missing Chromium returns `EXTENSION_BROWSER_REQUIRED` without using system
96
96
  Chrome.
97
+ - [ ] `doctor --adapter extension` names the launch browser, its version, why
98
+ it was chosen and how the extension loads; `RECIPE_HARNESS_BROWSER=cft`
99
+ restores Chrome for Testing, `=chrome` loads into Google Chrome over CDP.
97
100
  - [ ] Launch uses the checkout-owned profile, opens one MetaMask surface, avoids
98
101
  keychain prompts, and preserves keyboard focus by default.
99
102
  - [ ] A second launch reuses the watcher/profile without duplicate tabs.
@@ -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,46 @@ 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'));
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'));
376
+ const browserCdp = runnerRequire(path.join(runnerRoot, 'adapters/shared/browser-cdp.cjs'));
377
+ const macosFocus = runnerRequire(path.join(runnerRoot, 'adapters/extension/lib/macos-focus.cjs'));
378
+
379
+ // On macOS a browser that opens a window at startup activates itself, so the
380
+ // autolaunched browser starts without one and gets its pages over CDP in the
381
+ // background (as the extension launcher does).
382
+ const startsWithoutWindow = () => process.platform === 'darwin';
383
+
384
+ // One bounded browser-level CDP operation (the shared helpers in
385
+ // browser-cdp.cjs never take the operator's focus).
386
+ async function withBrowserCdp(port, fn) {
387
+ const deadline = Date.now() + 15000;
388
+ const remaining = () => Math.max(1, deadline - Date.now());
389
+ const client = await browserCdp.connectBrowserCdp(port, { timeoutMs: remaining() });
390
+ try {
391
+ return await fn(client.send, remaining);
392
+ } finally {
393
+ client.close();
394
+ }
373
395
  }
374
396
 
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
- }
397
+ // Same browser choice as the live launcher (RECIPE_HARNESS_BROWSER /
398
+ // RECIPE_HARNESS_CHROME_BIN); branded Chrome loads the dist over CDP. This
399
+ // launcher spawns a headful browser directly, so the probe does too, and a
400
+ // reused profile keeps its recorded browser.
401
+ async function resolveChromeBinary(projectRoot, recorded) {
402
+ try {
403
+ return await browserResolver.resolveBrowser({
404
+ cft: () => browserResolver.playwrightChromiumExecutable(projectRoot),
405
+ launchMethod: browserResolver.SPAWN,
406
+ display: browserResolver.HEADFUL,
407
+ recorded,
408
+ });
409
+ } catch (error) {
410
+ throw new Error(`Extension autolaunch could not select a browser: ${error.message}`);
387
411
  }
388
- throw new Error('Extension autolaunch requires RECIPE_HARNESS_CHROME_BIN or Playwright installed in the target checkout.');
389
412
  }
390
413
 
391
414
  async function execNodeScript(script, args, options) {
@@ -446,7 +469,7 @@ async function findWalletFixture(projectRoot) {
446
469
  return null;
447
470
  }
448
471
 
449
- async function launchExistingDistRuntime(input, port) {
472
+ export async function launchExistingDistRuntime(input, port) {
450
473
  const projectRoot = input.context.projectRoot;
451
474
  const artifactsDir = input.context.artifactsDir;
452
475
  const distDir = path.resolve(projectRoot, input.node?.dist_dir || process.env.METAMASK_RECIPE_EXTENSION_DIST_DIR || 'dist/chrome');
@@ -476,12 +499,24 @@ async function launchExistingDistRuntime(input, port) {
476
499
  fixtureSeeded = true;
477
500
  }
478
501
 
479
- const chrome = resolveChromeBinary(projectRoot);
502
+ // Never start a second browser on a port something else holds: whatever
503
+ // answers there afterwards would not be ours to load or seed.
504
+ const holders = browserResolver.cdpListenerPids(port);
505
+ if (holders.length > 0) {
506
+ 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.`);
507
+ }
508
+ // Next to the profile, so a reused profile (METAMASK_RECIPE_EXTENSION_PROFILE_DIR)
509
+ // finds the browser it was created with.
510
+ const browserRecord = `${profileDir}.browser.json`;
511
+ const profileExisted = await canAccess(path.join(profileDir, 'Local State'));
512
+ const browser = await resolveChromeBinary(projectRoot, profileExisted ? browserRecord : null);
513
+ const loadsOverCdp = browser.extensionLoading === browserResolver.CDP_LOAD_UNPACKED;
514
+ const previousFrontmost = macosFocus.captureMacFrontmost();
480
515
  const stdoutFd = openSync(path.join(logsDir, 'chrome.log'), 'a');
481
516
  const stderrFd = openSync(path.join(logsDir, 'chrome.log'), 'a');
482
517
  let child;
483
518
  try {
484
- child = spawn(chrome, [
519
+ child = spawn(browser.bin, [
485
520
  `--user-data-dir=${profileDir}`,
486
521
  ...(process.platform === 'darwin' ? ['--use-mock-keychain'] : []),
487
522
  '--remote-debugging-address=127.0.0.1',
@@ -493,9 +528,8 @@ async function launchExistingDistRuntime(input, port) {
493
528
  '--disable-extensions-file-access-check',
494
529
  '--disable-extensions-content-verification',
495
530
  '--disable-features=ExtensionContentVerification,DisableLoadExtensionCommandLineSwitch',
496
- `--disable-extensions-except=${runtimeDist}`,
497
- `--load-extension=${runtimeDist}`,
498
- 'chrome://extensions/',
531
+ ...browserResolver.extensionLaunchArgs(runtimeDist, browser.extensionLoading),
532
+ startsWithoutWindow() ? '--no-startup-window' : 'chrome://extensions/',
499
533
  ], {
500
534
  cwd: projectRoot,
501
535
  detached: true,
@@ -516,33 +550,64 @@ async function launchExistingDistRuntime(input, port) {
516
550
  }
517
551
  child.unref();
518
552
  await writeFile(path.join(logsDir, 'chrome.pid'), `${child.pid}\n`);
519
- await retryJsonGet(`http://127.0.0.1:${port}/json/version`, 45000);
520
-
521
553
  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
- );
554
+ let launchedBrowser = null;
555
+ try {
556
+ await retryJsonGet(`http://127.0.0.1:${port}/json/version`, 45000);
557
+ const owner = browserResolver.waitForCdpOwner(port, profileDir);
558
+ // The CDP load opens a background window itself; otherwise open one here.
559
+ // Without it the browser has no window, so a failure fails the launch.
560
+ const cdpLoad = loadsOverCdp ? await browserResolver.loadUnpackedOverPort(port, runtimeDist, { profile: profileDir }) : null;
561
+ if (!loadsOverCdp && startsWithoutWindow()) await withBrowserCdp(port, browserCdp.ensureBackgroundWindow);
562
+ launchedBrowser = {
563
+ ...browser,
564
+ launch: {
565
+ pid: owner.pid,
566
+ cdpPort: port,
567
+ startedAt: new Date().toISOString(),
568
+ profile: owner.profile,
569
+ processStartedAt: owner.startedAt,
570
+ ...(cdpLoad ? { extensionId: cdpLoad.id, otherExtensions: cdpLoad.otherExtensions } : {}),
571
+ },
572
+ };
573
+ await writeFile(browserRecord, `${JSON.stringify(launchedBrowser, null, 2)}\n`);
574
+ if (fixtureSeeded) {
575
+ fixtureValidation = path.join(logsDir, 'fixture-account-parity.json');
576
+ await execNodeScript(
577
+ fixtureScript,
578
+ [
579
+ 'seed-cdp',
580
+ '--target',
581
+ projectRoot,
582
+ '--fixture',
583
+ fixture,
584
+ '--state',
585
+ fixtureState,
586
+ '--cdp-port',
587
+ String(port),
588
+ '--extension-dir',
589
+ runtimeDist,
590
+ '--extension-id-file',
591
+ extensionIdFile,
592
+ '--out',
593
+ fixtureValidation,
594
+ ],
595
+ { cwd: projectRoot },
596
+ );
597
+ }
598
+ } catch (error) {
599
+ // The launch failed after the browser started: stop it before reporting,
600
+ // so no detached browser outlives this action. A failed stop is reported
601
+ // with the original failure, never swallowed.
602
+ try {
603
+ stopProfileProcessesSync(profileDir, { extraPids: [child.pid] });
604
+ } catch (stopError) {
605
+ throw new Error(`${error.message}\nStopping the autolaunched browser (pid ${child.pid}, profile ${profileDir}) also failed: ${stopError.message}`, { cause: error });
606
+ }
607
+ throw error;
545
608
  }
609
+ // One check, at most one restore, only if this launch's browser holds the front.
610
+ macosFocus.restoreFrontmostIfOurs(previousFrontmost, [launchedBrowser.launch.pid]);
546
611
 
547
612
  await writeFile(path.join(runtimeRoot, 'runtime.json'), `${JSON.stringify({
548
613
  port,
@@ -553,6 +618,8 @@ async function launchExistingDistRuntime(input, port) {
553
618
  profileDir,
554
619
  fixtureSeeded,
555
620
  fixtureValidation,
621
+ browser: launchedBrowser,
622
+ browserRecord,
556
623
  launchedAt: new Date().toISOString(),
557
624
  }, null, 2)}\n`);
558
625
  return { launched: true, pid: child.pid, runtimeRoot, fixtureSeeded };
@@ -577,17 +644,16 @@ function extensionIdFromAnyTarget(targets) {
577
644
  return null;
578
645
  }
579
646
 
580
- async function openExtensionHomePage(port, extensionId) {
647
+ // The wallet home goes into the launch's blank tab (so it is the window's
648
+ // selected page, and what a native screenshot of the window captures), or a
649
+ // background window when there is no blank tab; never a foreground one.
650
+ export async function openExtensionHomePage(port, extensionId) {
581
651
  const url = `chrome-extension://${extensionId}/home.html`;
582
- const endpoint = `http://127.0.0.1:${port}/json/new?${encodeURIComponent(url)}`;
583
- let response = await fetch(endpoint, { method: 'PUT' });
584
- if (response.status === 405 || response.status === 404) {
585
- response = await fetch(endpoint);
586
- }
587
- if (!response.ok) {
588
- throw new Error(`Failed to open extension home page ${url}: HTTP ${response.status}`);
652
+ try {
653
+ return await withBrowserCdp(port, (send, remaining) => browserCdp.openUrl(send, url, remaining));
654
+ } catch (error) {
655
+ throw new Error(`Failed to open extension home page ${url}: ${error.message}`);
589
656
  }
590
- return response.json();
591
657
  }
592
658
 
593
659
  async function ensureExtensionTarget(input, port) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.69.0",
3
+ "version": "0.70.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -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
  })()