@deeeed/metamask-harness 0.69.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.
@@ -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 ? ` 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,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.69.0",
3
+ "version": "0.70.0",
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
  })()