@walletwright/core 0.2.1 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # @walletwright/core
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - fdb91d4: Fix a set of state-representation defects found by a repo-wide simplification audit.
8
+
9
+ **Caches now publish from staging.** Extension extraction and the onboarded profile build both wrote
10
+ in place, so an interrupted run (Ctrl-C, ENOSPC, a CI timeout, a flaky MV3 worker) could leave a
11
+ partial directory that still satisfied the "does it exist" check every later run made. That surfaced
12
+ much later as an unlock or selector timeout rather than as a build failure. Both now build inside the
13
+ cache and publish by rename. Profile replacement keeps the prior cache at a recovery path until the
14
+ new one lands, so a failed or interrupted publication can restore it.
15
+
16
+ **`launchWallet` no longer leaks its throwaway profile copy.** Cleanup was bound to the browser
17
+ context's `close` event, which does not exist yet if the profile copy or the launch itself fails, and
18
+ which nobody awaited. `launchWallet` now returns `close()` alongside `context` and `wallet`: it closes
19
+ the browser and then awaits removal of the copy. `context.close()` still triggers a best-effort
20
+ cleanup, so existing code keeps working, but `close()` is the documented path.
21
+
22
+ **Approval handling is more precise.** A popup respawned mid-approval is now positioned on screen like
23
+ the first one (it previously skipped that, and an off-screen window renders fine but refuses clicks).
24
+ The "did it close" check now tracks the exact page it drove instead of scanning the context for any
25
+ matching URL, which reported failure whenever a wallet chained a second popup or left a notification
26
+ window on its home screen. That check also now applies to `connectToDapp()`, which could previously
27
+ report success on a popup that never closed. MetaMask's `reject` gained the third-party-notice
28
+ dismissal and retry that `approve` already had, since the notice covers the whole footer.
29
+
30
+ **The `cache` CLI validates its flags.** It now uses `node:util`'s `parseArgs` in strict mode, so an
31
+ unknown flag, a flag missing its value, `--headless false` (which used to turn headless _on_), and
32
+ `--wallet=value` are all handled correctly instead of silently ignored or inverted. `--setup` combined
33
+ with a flag it would discard is now an error. Failures set `process.exitCode` rather than calling
34
+ `process.exit`, which could truncate the error message when stderr was piped.
35
+
36
+ **The mocks give each install its own bridge.** Installing `installMockWallet` or
37
+ `installMockStandardWallet` twice with different options used to discard the second handler while
38
+ returning its address, so the page signed with the first install's key on the first install's chain.
39
+
40
+ **Breaking (types only):** `WalletDefinition.reject` is now required. Every wallet in the registry
41
+ already declares it, and the public `Wallet` type has always promised rejection unconditionally, so
42
+ this only affects external code that annotates a `WalletDefinition` literal by hand. `isWalletKind` is
43
+ now exported.
44
+
3
45
  ## 0.2.1
4
46
 
5
47
  ### Patch Changes
package/README.md CHANGED
@@ -91,10 +91,10 @@ The same two calls drive every chain. A Phantom test can connect and sign on
91
91
  ```ts
92
92
  import { launchWallet } from "@walletwright/core";
93
93
 
94
- const { context, wallet } = await launchWallet(metamask);
94
+ const { close, context, wallet } = await launchWallet(metamask);
95
95
  const page = await context.newPage();
96
96
  // drive the page and the wallet here
97
- await context.close();
97
+ await close();
98
98
  ```
99
99
 
100
100
  ## Requirements and notes
@@ -1,4 +1,4 @@
1
- import { mkdir, readdir, rm } from "node:fs/promises";
1
+ import { mkdir, mkdtemp, readdir, rename, rm, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { chromium } from "@playwright/test";
4
4
  import { createHash } from "node:crypto";
@@ -14,10 +14,12 @@ const ZIP_SIGNATURE = Buffer.from([
14
14
  4
15
15
  ]);
16
16
  /**
17
- * Download an extension archive and extract it to `<cacheDir>/<name>`, returning that path. A `.crx`
18
- * is a ZIP with a binary header, we slice from the ZIP signature before unzipping. Reuses an
19
- * existing extraction (so you can pre-place the extension to skip the download).
17
+ * Prefix for in-progress extractions. It has to live inside `cacheDir` so publishing is a `rename`
18
+ * on one filesystem (a cross-device rename fails with EXDEV), and it starts with a dot so it can
19
+ * never collide with a wallet's `name`.
20
20
  */
21
+ const STAGING_PREFIX = ".staging-";
22
+ /** Download and validate an extension archive, then publish its extraction atomically from staging. */
21
23
  const downloadAndExtractExtension = async (options) => {
22
24
  const { cacheDir, kind, name, sha256, url } = options;
23
25
  const cacheRoot = path.resolve(cacheDir);
@@ -38,19 +40,28 @@ const downloadAndExtractExtension = async (options) => {
38
40
  if (start === -1) throw new Error(`[walletwright] ${url} is not a valid CRX (no ZIP header found)`);
39
41
  zipBytes = bytes.subarray(start);
40
42
  }
41
- await rm(outDir, {
42
- force: true,
43
- recursive: true
44
- });
45
- const zip = new AdmZip(zipBytes);
46
- const root = path.resolve(outDir);
47
- for (const entry of zip.getEntries()) {
48
- const target = path.resolve(root, entry.entryName);
49
- if (target !== root && !target.startsWith(root + path.sep)) throw new Error(`[walletwright] refusing to extract ${entry.entryName}: escapes ${outDir}`);
43
+ const staging = await mkdtemp(path.join(cacheRoot, STAGING_PREFIX));
44
+ try {
45
+ const zip = new AdmZip(zipBytes);
46
+ for (const entry of zip.getEntries()) {
47
+ const target = path.resolve(staging, entry.entryName);
48
+ if (target !== staging && !target.startsWith(staging + path.sep)) throw new Error(`[walletwright] refusing to extract ${entry.entryName}: escapes ${outDir}`);
49
+ }
50
+ zip.extractAllTo(staging, true);
51
+ if (!existsSync(path.join(staging, "manifest.json"))) throw new Error(`[walletwright] extracted ${name} but no manifest.json found in ${outDir}`);
52
+ if (existsSync(path.join(outDir, "manifest.json"))) return outDir;
53
+ await rm(outDir, {
54
+ force: true,
55
+ recursive: true
56
+ });
57
+ await rename(staging, outDir);
58
+ return outDir;
59
+ } finally {
60
+ await rm(staging, {
61
+ force: true,
62
+ recursive: true
63
+ }).catch(() => {});
50
64
  }
51
- zip.extractAllTo(outDir, true);
52
- if (!existsSync(path.join(outDir, "manifest.json"))) throw new Error(`[walletwright] extracted ${name} but no manifest.json found in ${outDir}`);
53
- return outDir;
54
65
  };
55
66
  /** Build the Chrome Web Store CRX download URL for an extension id. */
56
67
  const chromeWebStoreCrxUrl = (extensionId) => `https://clients2.google.com/service/update2/crx?response=redirect&prodversion=130.0&acceptformat=crx2,crx3&x=id%3D${extensionId}%26uc`;
@@ -101,7 +112,7 @@ const importPrivateKey = async ({ home }, privateKey) => {
101
112
  });
102
113
  await closeAccountMenu(home);
103
114
  };
104
- const rename = async ({ home }, options) => {
115
+ const rename$1 = async ({ home }, options) => {
105
116
  await openAccountMenu(home);
106
117
  await home.getByTestId("multichain-account-cell-end-accessory").nth(options.index).click();
107
118
  await home.getByTestId("multichain-account-menu-item-rename").click();
@@ -120,7 +131,7 @@ const switchTo = async ({ home }, index) => {
120
131
  const accounts = {
121
132
  add: add$1,
122
133
  importPrivateKey,
123
- rename,
134
+ rename: rename$1,
124
135
  switch: switchTo
125
136
  };
126
137
 
@@ -158,12 +169,7 @@ const sleep = (ms) => new Promise((resolve) => {
158
169
  });
159
170
  /** Render a timeout for an error message, so what a failure claims matches what it waited. */
160
171
  const formatTimeout = (ms) => `${Math.round(ms / 100) / 10}s`;
161
- /**
162
- * Poll `check` until it yields anything other than `undefined`/`false`, then hand that value back.
163
- * Returns `undefined` when the budget elapses, so only a caller that can genuinely continue without
164
- * the condition (an optional approval popup, say) branches on the result; everything else uses
165
- * `waitUntilOrThrow` and gets a named failure instead of a wallet that looks ready and isn't.
166
- */
172
+ /** Poll until `check` returns a value, or `undefined` when the timeout expires. */
167
173
  const waitUntil = async (check, { intervalMs = DEFAULT_INTERVAL_MS, timeoutMs }) => {
168
174
  const deadline = Date.now() + timeoutMs;
169
175
  for (;;) {
@@ -200,6 +206,12 @@ const RELOAD_SETTLE_TIMEOUT_MS = 5e3;
200
206
  const PROBE_INTERVAL_MS = 500;
201
207
  const PASSWORD_CLEARED_TIMEOUT_MS = 15e3;
202
208
  /**
209
+ * Short on purpose. `unlock` is normally handed a page `reachUnlockScreen` already settled, so this
210
+ * budget only matters when it is called on its own (the `settings.unlock` path), where a wallet with a
211
+ * late-mounting UI needs a poll rather than a single read but a dead page should still fail fast.
212
+ */
213
+ const UNLOCK_SETTLE_TIMEOUT_MS = 5e3;
214
+ /**
203
215
  * The shared unlock flow: open the wallet's own page, wait for it to settle into a state we can
204
216
  * name, and type the password. Every wallet differs only in its entry page, how it submits, and
205
217
  * whether it can come back already unlocked.
@@ -228,11 +240,10 @@ const createUnlockScreen = ({ entry, isUnlocked, submit, wallet }) => {
228
240
  return page;
229
241
  };
230
242
  const unlock = async (page, password) => {
243
+ const state = await settleWithin(page, UNLOCK_SETTLE_TIMEOUT_MS);
244
+ if (state === "unlocked") return;
245
+ if (state === void 0) throw new Error(`[walletwright] ${wallet} showed neither an unlock screen nor an unlocked wallet`);
231
246
  const field = page.locator(PASSWORD_FIELD);
232
- if (!await field.isVisible().catch(() => false)) {
233
- if (await isUnlocked?.(page).catch(() => false) === true) return;
234
- throw new Error(`[walletwright] ${wallet} showed neither an unlock screen nor an unlocked wallet`);
235
- }
236
247
  await field.fill(password);
237
248
  await (submit === void 0 ? field.press("Enter") : submit(page, field));
238
249
  if (!await field.waitFor({
@@ -305,39 +316,24 @@ const settings = {
305
316
  const BUTTON_TIMEOUT_MS = 45e3;
306
317
  /** The retry only covers a notice that rendered late, so it does not need the full budget again. */
307
318
  const RETRY_TIMEOUT_MS = 1e4;
308
- /**
309
- * First-time requests that MetaMask routes through a protocol Snap (e.g. adding a custom chain)
310
- * open a "Third-party software notice" modal over the confirm footer, and every click is
311
- * intercepted until it is accepted. Its buttons carry no testids, only text, and Accept stays
312
- * disabled until the notice is scrolled to the bottom.
313
- */
319
+ /** Snap notices intercept footer clicks and disable Accept until their content is scrolled. */
314
320
  const acceptThirdPartyNotice = async (popup) => {
315
321
  const modal = popup.locator(".snap-install-warning");
316
322
  if (await modal.count().catch(() => 0) === 0) return;
317
323
  await modal.locator("p").last().scrollIntoViewIfNeeded().catch(() => {});
318
324
  await modal.getByRole("button", { name: "Accept" }).click({ timeout: 3e3 }).catch(() => {});
319
325
  };
320
- /**
321
- * Confirm buttons differ by request type: connect popups use `confirm-btn`, signature and
322
- * transaction popups use `confirm-footer-button`, older builds used `page-container-footer-next`,
323
- * and requests routed through a protocol Snap (e.g. Solana) use
324
- * `confirm-<type>-confirm-snap-footer-button`, matched by suffix so every snap confirmation type is
325
- * covered. A popup renders exactly one of these buttons; the `.or()` union resolves whichever one
326
- * is present by DOM order, not by the order it's written in below, so one locator covers every
327
- * popup.
328
- */
326
+ /** Resolve the confirm test id used by standard, legacy, and Snap request footers. */
329
327
  const confirmButton = (popup) => popup.getByTestId("confirm-btn").or(popup.getByTestId("confirm-footer-button")).or(popup.locator("[data-testid=\"page-container-footer-next\"]")).or(popup.locator("[data-testid$=\"-confirm-snap-footer-button\"]")).first();
330
328
  const cancelButton = (popup) => popup.getByTestId("cancel-btn").or(popup.getByTestId("confirm-footer-cancel-button")).or(popup.locator("[data-testid=\"page-container-footer-cancel\"]")).or(popup.locator("[data-testid$=\"-cancel-snap-footer-button\"]")).first();
329
+ /** Distinguishes a pending request from MetaMask's requestless notification home screen. */
330
+ const approvalControls = (popup) => confirmButton(popup).or(cancelButton(popup));
331
331
  /**
332
- * What "this page is showing a request" means for MetaMask, as opposed to its wallet home. The
333
- * distinction matters for the page walletwright opens itself: `notification.html` renders the home
334
- * screen (account header, Buy/Swap/Send, a news carousel) when nothing is pending, so buttons on
335
- * screen prove nothing. A CI run handed that home screen to `approve` and waited out the full click
336
- * budget against a confirm button that was never going to exist.
332
+ * Click one of the popup's footer buttons. The third-party notice covers that whole footer, so both
333
+ * confirm and cancel are intercepted while it is up, and both need the dismiss-then-retry: without it
334
+ * a click times out against a button that is rendered and enabled, which reads as a stale selector.
337
335
  */
338
- const approvalControls = (popup) => confirmButton(popup).or(cancelButton(popup));
339
- const approve = async (popup) => {
340
- const button = confirmButton(popup);
336
+ const clickFooterButton = async (popup, button) => {
341
337
  await acceptThirdPartyNotice(popup);
342
338
  try {
343
339
  await button.click({ timeout: BUTTON_TIMEOUT_MS });
@@ -348,10 +344,9 @@ const approve = async (popup) => {
348
344
  });
349
345
  }
350
346
  };
351
- /** The cancel counterparts of `approve`; same union-by-DOM-order resolution, not written order. */
352
- const reject = async (popup) => {
353
- await cancelButton(popup).click({ timeout: BUTTON_TIMEOUT_MS });
354
- };
347
+ const approve = (popup) => clickFooterButton(popup, confirmButton(popup));
348
+ /** The cancel counterpart of `approve`; same union-by-DOM-order resolution, not written order. */
349
+ const reject = (popup) => clickFooterButton(popup, cancelButton(popup));
355
350
 
356
351
  //#endregion
357
352
  //#region src/internal/utils.ts
@@ -360,15 +355,7 @@ const DEFAULT_CACHE_DIR = ".walletwright";
360
355
  const DEFAULT_NOTIFICATION_MATCH = "notification.html";
361
356
  /** Stable per-setup profile directory name, so the same wallet+seed+password reuses one cache. */
362
357
  const profileKey = (setup) => createHash("sha256").update(`${setup.wallet}:${setup.version ?? "default"}:${setup.seedPhrase}:${setup.password}`).digest("hex").slice(0, 20);
363
- /**
364
- * Compute an extension's id without querying the browser (`chrome://extensions` is blocked headless,
365
- * and the service worker starts lazily). Chrome derives the id from the sha256 of either the
366
- * manifest's public `key` (if present, e.g. Phantom → its fixed Web Store id) or the absolute load
367
- * path (no key, e.g. MetaMask), taking the first 16 bytes and mapping each nibble 0-f → a-p.
368
- *
369
- * The path is resolved through symlinks (`realpathSync`) because Chrome hashes the *real* path, a
370
- * cache under a symlinked dir (e.g. macOS `/tmp` → `/private/tmp`) would otherwise yield a wrong id.
371
- */
358
+ /** Mirror Chrome's extension-id derivation; resolve symlinks because Chrome hashes the real path. */
372
359
  const extensionIdFromPath = (extensionPath) => {
373
360
  const resolved = path.resolve(extensionPath);
374
361
  const abs = (() => {
@@ -414,14 +401,7 @@ const APPROVAL_WINDOW = {
414
401
  };
415
402
  /** Far enough from the edge that a popup this size lands fully on any usable display. */
416
403
  const APPROVAL_WINDOW_OFFSET = 50;
417
- /**
418
- * Put a wallet-spawned approval window somewhere it can be clicked. It can open partly off-screen
419
- * on a small or virtual display, and Playwright will not click what is out of view, so a confirm
420
- * button that is rendered and enabled still times out. Synpress carries the same workaround.
421
- *
422
- * Best-effort throughout: headless has no window to move, and nothing here is required for a window
423
- * that already sits on screen.
424
- */
404
+ /** Move approval popups on-screen; headless and unsupported CDP targets are ignored. */
425
405
  const placeApprovalWindow = async (page) => {
426
406
  await page.setViewportSize(APPROVAL_WINDOW).catch(() => {});
427
407
  try {
@@ -440,7 +420,6 @@ const placeApprovalWindow = async (page) => {
440
420
  };
441
421
  /** Where Chrome persists an extension's `chrome.storage.local` inside a browser profile. */
442
422
  const extensionStateDir = (profileDir, extensionId) => path.join(profileDir, "Default", "Local Extension Settings", extensionId);
443
- const hasNotificationPopup = (context, extensionId, match = DEFAULT_NOTIFICATION_MATCH) => context.pages().some((page) => isApprovalPopup(page, extensionId, match));
444
423
 
445
424
  //#endregion
446
425
  //#region src/wallets/metamask/onboarding-patch.ts
@@ -451,18 +430,7 @@ const markOnboarded = (onboarding) => ({
451
430
  firstTimeFlowType: typeof onboarding.firstTimeFlowType === "string" ? onboarding.firstTimeFlowType : "import",
452
431
  onboardingTabs: {}
453
432
  });
454
- /**
455
- * Force `completedOnboarding=true` in MetaMask's persisted state so the cached wallet boots straight
456
- * to the unlock screen instead of getting stuck on the "wallet is ready" screen (whose "Open wallet"
457
- * action goes through the MV3 service worker and is unreliable under automation).
458
- *
459
- * MetaMask 13.13.x stored all state under a single `data` key; 13.3x stores each controller under its
460
- * own key (`OnboardingController`). Both are handled. Must run while the browser is closed.
461
- *
462
- * Every branch that can't find the state it means to patch throws: leaving the cache unpatched means
463
- * a wallet that boots to "wallet is ready" and ignores every dapp request, which is far harder to
464
- * diagnose from a spec than a failed cache build.
465
- */
433
+ /** Patch both MetaMask 13.x onboarding layouts while the browser has released LevelDB. */
466
434
  const markMetaMaskOnboarded = async (profileDir, extensionId) => {
467
435
  const dbDir = extensionStateDir(profileDir, extensionId);
468
436
  if (!existsSync(dbDir)) throw new Error(`[walletwright] MetaMask wrote no extension state to ${dbDir}`);
@@ -505,37 +473,24 @@ const markMetaMaskOnboarded = async (profileDir, extensionId) => {
505
473
 
506
474
  //#endregion
507
475
  //#region src/wallets/metamask.ts
508
- const DEFAULT_VERSION = "13.35.1";
476
+ /** Release hashes for the pinned default. Add an entry before changing `DEFAULT_VERSION`. */
477
+ const RELEASE_SHA256 = { "13.35.1": "4e0f8626df0ae9fb15f5f3ad6784a0b518f3ede067b2b0d4f539f9f457c5049c" };
509
478
  /**
510
- * sha256 of each pinned `metamask-chrome-<version>.zip` release asset, so a pinned download is
511
- * verified rather than trusted. This is the one place to fill in when bumping `DEFAULT_VERSION`:
512
- *
513
- * curl -sL https://github.com/MetaMask/metamask-extension/releases/download/v<v>/metamask-chrome-<v>.zip | shasum -a 256
514
- *
515
- * A version a caller pins through `setup.version` has no entry here and downloads unverified, and
516
- * the Web-Store wallets can't be pinned at all (that endpoint always serves the current version).
479
+ * Typed as a key of `RELEASE_SHA256`, so bumping the default without adding its hash above is a
480
+ * compile error. `download.ts` skips verification for an unpinned `sha256`, so the same bump would
481
+ * otherwise silently downgrade every default MetaMask download to unverified.
517
482
  */
518
- const RELEASE_SHA256 = { "13.35.1": "4e0f8626df0ae9fb15f5f3ad6784a0b518f3ede067b2b0d4f539f9f457c5049c" };
483
+ const DEFAULT_VERSION = "13.35.1";
484
+ const releaseSha256 = (version) => Object.entries(RELEASE_SHA256).find(([pinned]) => pinned === version)?.[1];
519
485
  /** Download inputs for a MetaMask release, split out so the integrity wiring is unit-testable. */
520
486
  const metamaskDownload = (cacheDir, version) => ({
521
487
  cacheDir,
522
488
  kind: "zip",
523
489
  name: `metamask-chrome-${version}`,
524
- sha256: RELEASE_SHA256[version],
490
+ sha256: releaseSha256(version),
525
491
  url: `https://github.com/MetaMask/metamask-extension/releases/download/v${version}/metamask-chrome-${version}.zip`
526
492
  });
527
- /**
528
- * MetaMask's backup-and-sync restores account names for whichever SRP a profile holds, and test
529
- * seeds are shared (the public `test test … junk` one by thousands), so a synced profile reports a
530
- * stranger's account names in place of the ones a test just set: CI runs showed accounts called
531
- * `dev1` and `personal`, holding a real balance, where `Account 2` was expected. It only happens
532
- * where the sync actually lands, which makes it look like a flake. MetaMask's own e2e suite mocks
533
- * its external services for the same reason.
534
- *
535
- * Just the one host that stores the names. Cutting the auth stack around it (`authentication`,
536
- * `oidc`) reaches further than intended: other features authenticate through it too, and a wallet
537
- * that cannot authenticate can leave a request's confirm button disabled with nothing to explain it.
538
- */
493
+ /** MetaMask's account-name sync mutates shared test-seed profiles, so its own e2e suite blocks this host. */
539
494
  const ACCOUNT_SYNC_HOST = "user-storage.api.cx.metamask.io";
540
495
  const metamask = {
541
496
  actions: {
@@ -696,34 +651,23 @@ const CONFIRM_LABELS = [
696
651
  "Confirm"
697
652
  ];
698
653
  const CANCEL_LABELS = ["Cancel", "Reject"];
699
- /**
700
- * Rabby's approval window is focus-fragile: it unmounts its contents a few seconds after losing
701
- * focus, and Playwright's click (which waits for actionability, then for the click to settle) loses
702
- * the window mid-action. Rabby reads the vanished window as a dismissal, so the dapp gets
703
- * "User rejected the request" even though the confirm button was visible and enabled. Dispatching
704
- * the click inside the page skips focus, actionability, and post-click bookkeeping entirely. Rabby
705
- * is a plain React app, so `evaluate` works here (unlike MetaMask, which scuttles the realm).
706
- *
707
- * Signing is a two-step footer: "Sign" swaps itself for "Confirm", which must be clicked too, and
708
- * both start disabled while Rabby analyses the request. So keep clicking whichever labelled button
709
- * is currently enabled until the window closes, which is the only signal the request was answered.
710
- */
654
+ /** Rabby's focus-fragile popup requires in-page clicks; signing then requires a second Confirm click. */
711
655
  const clickApprovalButton = async (popup, labels) => {
712
- let lastClicked = "";
656
+ const clickedLabels = /* @__PURE__ */ new Set();
713
657
  return await waitUntil(async () => {
714
658
  if (popup.isClosed()) return true;
715
659
  const clicked = await popup.evaluate((arg) => {
716
660
  const target = [...document.querySelectorAll("button")].find((button) => {
717
661
  const text = (button.textContent ?? "").trim();
718
- return arg.names.includes(text) && !button.disabled && text !== arg.skip;
662
+ return arg.names.includes(text) && !button.disabled && !arg.skip.includes(text);
719
663
  });
720
664
  target?.click();
721
665
  return target ? (target.textContent ?? "").trim() : "";
722
666
  }, {
723
667
  names: [...labels],
724
- skip: lastClicked
668
+ skip: [...clickedLabels]
725
669
  }).catch(() => "");
726
- if (clicked) lastClicked = clicked;
670
+ if (clicked) clickedLabels.add(clicked);
727
671
  }, {
728
672
  intervalMs: 300,
729
673
  timeoutMs: APPROVAL_TIMEOUT_MS
@@ -796,7 +740,7 @@ const slush = {
796
740
  await fclick(page, "Import existing from passphrase");
797
741
  await sleep(1500);
798
742
  const words = seedPhrase.trim().split(new RegExp("\\s+", "v"));
799
- for (let i = 0; i < words.length; i++) await page.locator(`input[placeholder="Word ${i + 1}"]`).fill(words[i] ?? "");
743
+ for (let i = 0; i < words.length; i++) await page.getByLabel(`Word ${i + 1}`, { exact: true }).fill(words[i] ?? "");
800
744
  await page.getByRole("button", { name: "Next" }).click();
801
745
  await sleep(2e3);
802
746
  await page.locator("input[placeholder=\"Password\"]").fill(password);
@@ -913,36 +857,82 @@ const extensionContextOptions = (extensionPath, headless) => ({
913
857
  const NAVIGATION_TIMEOUT_MS = 15e3;
914
858
  const FLUSH_SETTLE_MS = 3e3;
915
859
  const STATE_WRITE_TIMEOUT_MS = 1e4;
916
- /**
917
- * Chrome stores an extension's `chrome.storage.local` under `Local Extension Settings/<id>` and its
918
- * IndexedDB (what some single-page wallets persist their vault to) under
919
- * `IndexedDB/chrome-extension_<id>_0…`. Bytes in either one mean onboarding actually landed in the
920
- * profile; bytes in neither mean the "cache" is empty, and every later run would fail at unlock with
921
- * no hint that the build was the thing that went wrong.
922
- */
860
+ /** Wallet state may live in `chrome.storage.local` or extension IndexedDB. */
923
861
  const hasPersistedState = async (profileDir, extensionId) => {
924
862
  const stores = [extensionStateDir(profileDir, extensionId), path.join(profileDir, "Default", "IndexedDB", `chrome-extension_${extensionId}_0.indexeddb.leveldb`)];
925
863
  return (await Promise.allSettled(stores.map((dir) => readdir(dir)))).some((listing) => listing.status === "fulfilled" && listing.value.length > 0);
926
864
  };
927
865
  /**
928
- * Import the wallet once and persist an onboarded browser profile to disk (the "cache"), so tests
929
- * launch a ready-to-unlock wallet instead of re-running onboarding each time. Returns the profile
930
- * directory. Idempotent per (wallet, version, seed, password).
931
- *
932
- * `headless` only affects this build step; the tests choose their own mode via `launchWallet`.
866
+ * Prefix for an in-progress build. Inside `cacheDir` so publishing is a `rename` on one filesystem,
867
+ * dot-prefixed so it can never be mistaken for a 20-hex-character `profileKey`.
933
868
  */
869
+ const BUILD_PREFIX = ".building-";
870
+ const PREVIOUS_SUFFIX = ".previous";
871
+ const isErrorCode = (error, code) => error instanceof Error && "code" in error && error.code === code;
872
+ const pathExists = async (target) => {
873
+ try {
874
+ await stat(target);
875
+ return true;
876
+ } catch (error) {
877
+ if (isErrorCode(error, "ENOENT")) return false;
878
+ throw error;
879
+ }
880
+ };
881
+ const previousProfileDir = (profileDir) => `${profileDir}${PREVIOUS_SUFFIX}`;
882
+ const restorePreviousProfile = async (profileDir) => {
883
+ const previous = previousProfileDir(profileDir);
884
+ if (await pathExists(profileDir)) {
885
+ await rm(previous, {
886
+ force: true,
887
+ recursive: true
888
+ });
889
+ return;
890
+ }
891
+ try {
892
+ await rename(previous, profileDir);
893
+ } catch (error) {
894
+ if (!isErrorCode(error, "ENOENT")) throw error;
895
+ }
896
+ };
897
+ const publishProfile = async (staging, profileDir) => {
898
+ await restorePreviousProfile(profileDir);
899
+ const previous = previousProfileDir(profileDir);
900
+ let movedPrevious = false;
901
+ try {
902
+ await rename(profileDir, previous);
903
+ movedPrevious = true;
904
+ } catch (error) {
905
+ if (!isErrorCode(error, "ENOENT")) throw error;
906
+ }
907
+ try {
908
+ await rename(staging, profileDir);
909
+ } catch (error) {
910
+ if (movedPrevious) try {
911
+ await rename(previous, profileDir);
912
+ } catch (rollbackError) {
913
+ const publishMessage = error instanceof Error ? error.message : String(error);
914
+ const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
915
+ throw new Error(`[walletwright] failed to publish cache at ${profileDir}: ${publishMessage}; rollback failed: ${rollbackMessage}`, { cause: rollbackError });
916
+ }
917
+ throw error;
918
+ }
919
+ if (movedPrevious) await rm(previous, {
920
+ force: true,
921
+ recursive: true
922
+ });
923
+ };
924
+ /** Build in staging and retain the prior cache until the new profile publishes successfully. */
934
925
  const buildCache = async (setup, options = {}) => {
935
926
  const definition = wallets[setup.wallet];
936
927
  const cacheDir = path.resolve(setup.cacheDir ?? ".walletwright");
937
928
  const extensionPath = await definition.prepareExtension(cacheDir, setup.version);
938
929
  const profileDir = path.join(cacheDir, profileKey(setup));
939
- await rm(profileDir, {
940
- force: true,
941
- recursive: true
942
- });
943
- await mkdir(profileDir, { recursive: true });
944
- const context = await chromium.launchPersistentContext(profileDir, extensionContextOptions(extensionPath, options.headless === true));
930
+ await restorePreviousProfile(profileDir);
931
+ await mkdir(cacheDir, { recursive: true });
932
+ const staging = await mkdtemp(path.join(cacheDir, BUILD_PREFIX));
933
+ let context;
945
934
  try {
935
+ context = await chromium.launchPersistentContext(staging, extensionContextOptions(extensionPath, options.headless === true));
946
936
  await definition.prepareContext?.(context);
947
937
  const extensionId = extensionIdFromPath(extensionPath);
948
938
  const page = context.pages().find((candidate) => candidate.url() === "about:blank") ?? await context.newPage();
@@ -953,20 +943,26 @@ const buildCache = async (setup, options = {}) => {
953
943
  await sleep(2e3);
954
944
  await definition.importWallet(page, setup.seedPhrase, setup.password);
955
945
  await sleep(FLUSH_SETTLE_MS);
956
- await waitUntilOrThrow(() => hasPersistedState(profileDir, extensionId), {
946
+ await waitUntilOrThrow(() => hasPersistedState(staging, extensionId), {
957
947
  intervalMs: 500,
958
- message: `${definition.extensionName} onboarding wrote no wallet state into ${profileDir}`,
948
+ message: `${definition.extensionName} onboarding wrote no wallet state into ${staging}`,
959
949
  timeoutMs: STATE_WRITE_TIMEOUT_MS
960
950
  });
961
951
  await context.close();
962
- await definition.finalizeCache?.(profileDir, extensionId);
952
+ await definition.finalizeCache?.(staging, extensionId);
953
+ await publishProfile(staging, profileDir);
963
954
  return profileDir;
964
955
  } catch (error) {
965
- await context.close().catch(() => {});
956
+ await context?.close().catch(() => {});
966
957
  throw error;
958
+ } finally {
959
+ await rm(staging, {
960
+ force: true,
961
+ recursive: true
962
+ });
967
963
  }
968
964
  };
969
965
 
970
966
  //#endregion
971
- export { wallets as a, extensionIdFromPath as c, placeApprovalWindow as d, profileKey as f, walletKindsByEcosystem as i, findNotificationPopup as l, waitUntil as m, extensionContextOptions as n, DEFAULT_CACHE_DIR as o, formatTimeout as p, isWalletKind as r, DEFAULT_NOTIFICATION_MATCH as s, buildCache as t, hasNotificationPopup as u };
972
- //# sourceMappingURL=cache-Bwv2i-hv.mjs.map
967
+ export { walletKindsByEcosystem as a, DEFAULT_NOTIFICATION_MATCH as c, placeApprovalWindow as d, profileKey as f, isWalletKind as i, extensionIdFromPath as l, waitUntil as m, restorePreviousProfile as n, wallets as o, formatTimeout as p, extensionContextOptions as r, DEFAULT_CACHE_DIR as s, buildCache as t, findNotificationPopup as u };
968
+ //# sourceMappingURL=cache-CHP-6eU0.mjs.map