@deeeed/metamask-harness 0.28.0 → 0.29.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 (52) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +41 -0
  3. package/adapters/extension/build-lavamoat.sh +2 -1
  4. package/adapters/extension/ensure-browser.sh +82 -9
  5. package/adapters/extension/inject.mjs +1 -0
  6. package/adapters/extension/launch-browser.cjs +83 -1
  7. package/adapters/extension/lib/chrome-args.cjs +325 -1
  8. package/adapters/extension/lib/playwright-cdp.cjs +34 -0
  9. package/adapters/extension/lib/slot-title.cjs +2 -4
  10. package/adapters/extension/lib/validation-launch-supervisor.cjs +292 -0
  11. package/adapters/extension/lib/validation-process-ownership.cjs +69 -0
  12. package/adapters/extension/reattach.sh +2 -1
  13. package/adapters/extension/sidepanel-toggle.sh +14 -96
  14. package/adapters/extension/wallet-fixture-state.cjs +8 -31
  15. package/adapters/manifest.json +16 -0
  16. package/adapters/shared/private-atomic-write.cjs +47 -0
  17. package/adapters/shared/setup-base.sh +864 -0
  18. package/dist/adapters/extension/runtime.js +367 -24
  19. package/dist/adapters/extension/validation-process-ownership.js +10 -0
  20. package/dist/cli-commands.js +1 -0
  21. package/dist/command-contract.js +12 -0
  22. package/dist/commands/launch/extension.js +130 -19
  23. package/dist/commands/setup-base.js +24 -0
  24. package/dist/mm-harness-cli.js +28 -2
  25. package/library/actions/extension/analytics/consent.mjs +203 -0
  26. package/library/actions/extension/analytics/set_consent.mjs +19 -143
  27. package/library/actions/extension/perps/perps.mjs +2 -16
  28. package/library/actions/extension/perps/state.mjs +20 -0
  29. package/library/actions/extension/wallet/list_accounts.mjs +3 -25
  30. package/library/actions/extension/wallet/read_state.mjs +3 -23
  31. package/library/actions/extension/wallet/select_account.mjs +6 -33
  32. package/library/actions/extension/wallet/setup.mjs +2 -20
  33. package/library/actions/extension/wallet/state.mjs +111 -0
  34. package/library/recipes/runner/action-validation.extension.recipe.json +1 -1
  35. package/library/recipes/runner/action-validation.mobile.recipe.json +1 -1
  36. package/package.json +7 -4
  37. package/scripts/site-contrast.mjs +538 -0
  38. package/site/architecture.html +415 -0
  39. package/site/assets/progress.mjs +272 -0
  40. package/site/assets/style.css +808 -0
  41. package/site/cheatsheet.html +305 -0
  42. package/site/index.html +643 -0
  43. package/site/recipes.html +396 -0
  44. package/site/reviewers.html +374 -0
  45. package/site/tutorials/index.html +180 -0
  46. package/site/tutorials/v1.html +211 -0
  47. package/site/tutorials/v2.html +207 -0
  48. package/site/tutorials/v3.html +214 -0
  49. package/site/tutorials/v4.html +195 -0
  50. package/site/tutorials/v5.html +163 -0
  51. package/site/tutorials/v6.html +165 -0
  52. package/site/tutorials/v7.html +184 -0
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ const { execFileSync } = require('node:child_process');
4
+
5
+ function commandHasExactProfile(command, profile) {
6
+ const expected = `--user-data-dir=${profile}`;
7
+ let offset = command.indexOf(expected);
8
+ while (offset !== -1) {
9
+ const before = offset === 0 ? '' : command[offset - 1];
10
+ const after = command[offset + expected.length] || '';
11
+ if ((!before || /\s/u.test(before)) && (!after || /\s/u.test(after))) return true;
12
+ offset = command.indexOf(expected, offset + 1);
13
+ }
14
+ return false;
15
+ }
16
+
17
+ function profileProcessPids(profile) {
18
+ const output = execFileSync('ps', ['-ww', '-axo', 'pid=,command='], {
19
+ encoding: 'utf8',
20
+ stdio: ['ignore', 'pipe', 'ignore'],
21
+ });
22
+ const pids = [];
23
+ for (const line of output.split('\n')) {
24
+ const match = line.match(/^\s*(\d+)\s+(.*)$/u);
25
+ if (!match || !commandHasExactProfile(match[2], profile)) continue;
26
+ const pid = Number(match[1]);
27
+ if (pid !== process.pid && pid !== process.ppid) pids.push(pid);
28
+ }
29
+ return [...new Set(pids)];
30
+ }
31
+
32
+ function signalPids(pids, signal) {
33
+ for (const pid of pids) {
34
+ try {
35
+ process.kill(pid, signal);
36
+ } catch (error) {
37
+ if (error.code !== 'ESRCH') throw error;
38
+ }
39
+ }
40
+ }
41
+
42
+ function delay(milliseconds) {
43
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
44
+ }
45
+
46
+ async function stopProfileProcesses(profile, { quietMs = 0, timeoutMs = 5_000 } = {}) {
47
+ const deadline = Date.now() + timeoutMs;
48
+ let quietSince = Date.now();
49
+ let ownersSince = 0;
50
+ while (Date.now() < deadline) {
51
+ const pids = profileProcessPids(profile);
52
+ if (pids.length === 0) {
53
+ ownersSince = 0;
54
+ if (Date.now() - quietSince >= quietMs) return;
55
+ } else {
56
+ if (ownersSince === 0) ownersSince = Date.now();
57
+ quietSince = Date.now();
58
+ signalPids(pids, Date.now() - ownersSince >= 500 ? 'SIGKILL' : 'SIGTERM');
59
+ }
60
+ await delay(50);
61
+ }
62
+ const remaining = profileProcessPids(profile);
63
+ if (remaining.length > 0) {
64
+ throw new Error(`Extension validation profile processes survived cleanup: ${remaining.join(', ')}.`);
65
+ }
66
+ throw new Error(`Extension validation profile did not remain quiescent for ${quietMs}ms.`);
67
+ }
68
+
69
+ module.exports = { profileProcessPids, stopProfileProcesses };
@@ -126,6 +126,7 @@ const fs = require('node:fs');
126
126
  const path = require('node:path');
127
127
  const { extensionIdFromManifestFile } = require(path.join(process.env.SCRIPT_DIR, 'lib/extension-id.cjs'));
128
128
  const { readSlotId, applyPersistentSlotTitle } = require(path.join(process.env.SCRIPT_DIR, 'lib/slot-title.cjs'));
129
+ const { evaluatePageViaCdp } = require(path.join(process.env.SCRIPT_DIR, 'lib/playwright-cdp.cjs'));
129
130
 
130
131
  const port = process.env.CDP_PORT;
131
132
  const target = process.env.TARGET;
@@ -198,7 +199,7 @@ async function connect() {
198
199
 
199
200
  async function stampSlotTitle(page) {
200
201
  if (!slotId || !page) return;
201
- await page.evaluate(applyPersistentSlotTitle, slotId).catch(() => {
202
+ await evaluatePageViaCdp(page, applyPersistentSlotTitle, slotId).catch(() => {
202
203
  // Best-effort operator affordance; reload success is verified by CDP state.
203
204
  });
204
205
  }
@@ -239,7 +239,7 @@ open_sidepanel() {
239
239
  exit 4
240
240
  fi
241
241
 
242
- CDP_PORT="$CDP_PORT" EXT_ID="$ext_id" node <<'NODE'
242
+ CDP_PORT="$CDP_PORT" EXT_ID="$ext_id" SCRIPT_DIR="$SCRIPT_DIR" node <<'NODE'
243
243
  const { chromium } = require('playwright');
244
244
 
245
245
  (async () => {
@@ -247,105 +247,23 @@ const { chromium } = require('playwright');
247
247
  const extId = process.env.EXT_ID;
248
248
  const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
249
249
  const context = browser.contexts()[0];
250
- let page = context
251
- .pages()
252
- .find(
253
- (candidate) =>
254
- candidate.url().startsWith(`chrome-extension://${extId}/`) &&
255
- !candidate.url().includes('/sidepanel.html'),
256
- );
257
-
258
- if (!page) {
259
- page = await context.newPage();
260
- await page.goto(`chrome-extension://${extId}/home.html`, {
250
+ const page = await context.newPage();
251
+ try {
252
+ await page.goto(`chrome-extension://${extId}/popup-init.html`, {
261
253
  waitUntil: 'domcontentloaded',
262
254
  timeout: 15000,
263
255
  });
264
- }
265
-
266
- if (process.env.MM_HARNESS_FOCUS_BROWSER === '1') {
267
- await page.bringToFront();
268
- }
269
- const result = await page.evaluate(async () => {
270
- const currentWindow = await chrome.windows.getCurrent();
271
- const id = '__recipe_open_sidepanel__';
272
- document.getElementById(id)?.remove();
273
- const button = document.createElement('button');
274
- button.id = id;
275
- button.textContent = 'open sidepanel';
276
- button.style.cssText =
277
- 'position:fixed;left:8px;top:8px;z-index:2147483647';
278
- button.onclick = async () => {
279
- try {
280
- await chrome.sidePanel.open({ windowId: currentWindow.id });
281
- button.dataset.result = `ok:${currentWindow.id}`;
282
- } catch (error) {
283
- button.dataset.result = `error:${
284
- error && error.message ? error.message : String(error)
285
- }`;
286
- }
287
- };
288
- document.documentElement.appendChild(button);
289
- return { windowId: currentWindow.id };
290
- });
291
-
292
- // This harness-owned button exists only to provide Chrome's trusted user
293
- // gesture. Its visual layout is irrelevant and can be occluded by the app's
294
- // full-screen loading surface, so do not wait on Playwright actionability.
295
- await page.locator('#__recipe_open_sidepanel__').click({ timeout: 5000, force: true });
296
- const clickResult = await page
297
- .locator('#__recipe_open_sidepanel__')
298
- .evaluate((button) => button.dataset.result || '');
299
- if (!clickResult.startsWith('ok:')) {
300
- throw new Error(
301
- `chrome.sidePanel.open failed for window ${result.windowId}: ${clickResult}`,
302
- );
303
- }
304
- await page.evaluate(() => {
305
- document.getElementById('__recipe_open_sidepanel__')?.remove();
306
- });
307
-
308
- const deadline = Date.now() + 5000;
309
- let sidepanelPage;
310
- while (Date.now() < deadline) {
311
- sidepanelPage = context
312
- .pages()
313
- .find((candidate) => candidate.url().startsWith(`chrome-extension://${extId}/`) && candidate.url().includes('/sidepanel.html'));
314
- if (sidepanelPage) break;
315
- await new Promise((resolve) => setTimeout(resolve, 200));
316
- }
317
-
318
- // Only collapse fullscreen extension tabs after Chrome exposes the sidepanel
319
- // target. chrome.sidePanel.open can return before the panel page appears; if we
320
- // close home first, a slow/failed panel leaves the operator with no visible
321
- // wallet page and the next readiness probe has to reopen from scratch.
322
- if (sidepanelPage) {
323
- for (const candidate of context.pages()) {
324
- if (
325
- candidate.url().startsWith(`chrome-extension://${extId}/`) &&
326
- !candidate.url().includes('/sidepanel.html')
327
- ) {
328
- try {
329
- await candidate.close();
330
- } catch (error) {
331
- console.warn(
332
- `[sidepanel] extension page close failed after successful open: ${
333
- error && error.message ? error.message : error
334
- }`,
335
- );
336
- }
337
- }
338
- }
339
- }
340
- const dappPage = context
341
- .pages()
342
- .find((candidate) => candidate.url().startsWith('http://') || candidate.url().startsWith('https://'));
343
- if (dappPage && process.env.MM_HARNESS_FOCUS_BROWSER === '1') {
344
- try {
345
- await dappPage.bringToFront();
346
- } catch {
347
- // focus is cosmetic; the panel is already open
256
+ if (process.env.MM_HARNESS_FOCUS_BROWSER === '1') {
257
+ await page.bringToFront();
348
258
  }
259
+ const menu = page.locator('[data-testid="account-options-menu-button"]');
260
+ await menu.waitFor({ state: 'visible', timeout: 15000 });
261
+ await menu.click({ timeout: 5000 });
262
+ const toggle = page.locator('[data-testid="global-menu-toggle-view"]');
263
+ await toggle.waitFor({ state: 'visible', timeout: 10000 });
264
+ await toggle.click({ timeout: 5000 });
265
+ } finally {
266
+ if (!page.isClosed()) await page.close();
349
267
  }
350
268
  if (typeof browser.disconnect === 'function') {
351
269
  await browser.disconnect();
@@ -21,6 +21,7 @@ const fs = require('node:fs');
21
21
  const http = require('node:http');
22
22
  const path = require('node:path');
23
23
  const { extensionIdFromManifestKey } = require('./lib/extension-id.cjs');
24
+ const { evaluatePageViaCdp } = require('./lib/playwright-cdp.cjs');
24
25
 
25
26
  const EOA_METHODS = [
26
27
  'personal_sign',
@@ -708,7 +709,7 @@ async function waitForWalletScreen(page) {
708
709
  const unlockSelector = '[data-testid="unlock-password"]';
709
710
  const deadline = Date.now() + 45000;
710
711
  while (Date.now() < deadline) {
711
- const fatalStartup = await evaluateViaCdp(page, () => {
712
+ const fatalStartup = await evaluatePageViaCdp(page, () => {
712
713
  const text = document.body?.innerText || '';
713
714
  return text.includes('MetaMask had trouble starting') ? text.slice(0, 500) : null;
714
715
  })
@@ -732,36 +733,12 @@ async function waitForWalletScreen(page) {
732
733
  return { state: 'unknown', selector: null };
733
734
  }
734
735
 
735
- async function evaluateViaCdp(page, callback, argument) {
736
- const session = await page.context().newCDPSession(page);
737
- const invocation = argument === undefined
738
- ? `(${callback.toString()})()`
739
- : `(${callback.toString()})(${JSON.stringify(argument)})`;
740
- try {
741
- const response = await session.send('Runtime.evaluate', {
742
- expression: invocation,
743
- awaitPromise: true,
744
- returnByValue: true,
745
- });
746
- if (response.exceptionDetails) {
747
- const detail =
748
- response.exceptionDetails.exception?.description ??
749
- response.exceptionDetails.text ??
750
- 'unknown evaluation failure';
751
- throw new Error(`CDP evaluation failed: ${detail}`);
752
- }
753
- return response.result?.value;
754
- } finally {
755
- await session.detach().catch(() => {});
756
- }
757
- }
758
-
759
736
  async function attemptUnlock(page, password) {
760
737
  // Selector ladder: current testids first, then the generic selectors the CDP
761
738
  // unlock action uses (proven against the same build). A freshly seeded vault
762
739
  // can also still be initializing, so a single attempt is not conclusive —
763
740
  // the caller retries.
764
- const filled = await evaluateViaCdp(page, (pw) => {
741
+ const filled = await evaluatePageViaCdp(page, (pw) => {
765
742
  const input =
766
743
  document.querySelector('[data-testid="unlock-password"]') ??
767
744
  document.querySelector('input[type="password"]');
@@ -776,7 +753,7 @@ async function attemptUnlock(page, password) {
776
753
  return true;
777
754
  }, password);
778
755
  if (!filled) return false;
779
- return evaluateViaCdp(page, () => {
756
+ return evaluatePageViaCdp(page, () => {
780
757
  const button =
781
758
  document.querySelector('[data-testid="unlock-submit"]') ??
782
759
  document.querySelector('button[type="submit"]') ??
@@ -807,7 +784,7 @@ async function unlockIfNeeded(page, password) {
807
784
  }
808
785
 
809
786
  async function readLiveAccounts(page) {
810
- const raw = await evaluateViaCdp(page, async () => {
787
+ const raw = await evaluatePageViaCdp(page, async () => {
811
788
  const metamask = (await window.stateHooks?.getCleanAppState?.())?.metamask || {};
812
789
  const accts = metamask.internalAccounts || {};
813
790
  const byId = accts.accounts || {};
@@ -931,7 +908,7 @@ async function applyAccountNames(page, expectedAccounts) {
931
908
  if (!account.name || !account.address.startsWith('0x')) {
932
909
  continue;
933
910
  }
934
- await evaluateViaCdp(
911
+ await evaluatePageViaCdp(
935
912
  page,
936
913
  async ({ address, name }) => {
937
914
  const metamask = (await window.stateHooks?.getCleanAppState?.())?.metamask || {};
@@ -964,7 +941,7 @@ async function applySelectedAccount(page, expectedSelected) {
964
941
  if (!expectedSelected?.address) {
965
942
  return;
966
943
  }
967
- await evaluateViaCdp(
944
+ await evaluatePageViaCdp(
968
945
  page,
969
946
  async ({ address }) => {
970
947
  const accounts =
@@ -1039,7 +1016,7 @@ async function seedCdp(args) {
1039
1016
  }
1040
1017
 
1041
1018
  try {
1042
- await evaluateViaCdp(page, async (state) => {
1019
+ await evaluatePageViaCdp(page, async (state) => {
1043
1020
  await chrome.storage.local.set(state);
1044
1021
  }, versionedState);
1045
1022
  } catch (error) {
@@ -534,6 +534,22 @@
534
534
  "purpose": "Detached, silent registry probe used by the daily passive update notice.",
535
535
  "inputs": "<cache-file> <timeout-ms> [registry-endpoint]",
536
536
  "outputs": "atomic update-cache refresh; always silent and best-effort"
537
+ },
538
+ {
539
+ "id": "lib/private-atomic-write",
540
+ "entry": "adapters/shared/private-atomic-write.cjs",
541
+ "kind": "lib",
542
+ "purpose": "Publish private setup state through a no-follow temporary file and atomic rename.",
543
+ "inputs": "target path and file bytes on stdin",
544
+ "outputs": "owner-only target file"
545
+ },
546
+ {
547
+ "id": "lib/setup-base",
548
+ "entry": "adapters/shared/setup-base.sh",
549
+ "kind": "lib",
550
+ "purpose": "Bootstrap numbered MetaMask checkouts through the setup-base command.",
551
+ "inputs": "setup-base command arguments",
552
+ "outputs": "numbered checkouts, saved preferences, and run summary"
537
553
  }
538
554
  ]
539
555
  }
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+
5
+ const target = path.resolve(process.argv[2] || '');
6
+ if (!process.argv[2]) {
7
+ process.stderr.write('private-atomic-write: target path is required\n');
8
+ process.exit(2);
9
+ }
10
+
11
+ let bytes = '';
12
+ process.stdin.setEncoding('utf8');
13
+ process.stdin.on('data', (chunk) => {
14
+ bytes += chunk;
15
+ });
16
+ process.stdin.on('end', () => {
17
+ const parent = path.dirname(target);
18
+ fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
19
+
20
+ const temporary = path.join(
21
+ parent,
22
+ `.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`,
23
+ );
24
+ let descriptor;
25
+ try {
26
+ descriptor = fs.openSync(
27
+ temporary,
28
+ fs.constants.O_CREAT |
29
+ fs.constants.O_EXCL |
30
+ fs.constants.O_WRONLY |
31
+ (fs.constants.O_NOFOLLOW || 0),
32
+ 0o600,
33
+ );
34
+ fs.writeFileSync(descriptor, bytes);
35
+ fs.fsyncSync(descriptor);
36
+ fs.closeSync(descriptor);
37
+ descriptor = undefined;
38
+ fs.renameSync(temporary, target);
39
+ } finally {
40
+ if (descriptor !== undefined) fs.closeSync(descriptor);
41
+ try {
42
+ fs.unlinkSync(temporary);
43
+ } catch (error) {
44
+ if (error.code !== 'ENOENT') throw error;
45
+ }
46
+ }
47
+ });