@walletwright/core 0.2.1 → 0.3.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.
@@ -1,10 +1,10 @@
1
- import { mkdir, readdir, rm } from "node:fs/promises";
1
+ import { access, mkdir, mkdtemp, readFile, readdir, realpath, 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";
5
- import { existsSync, readFileSync, realpathSync } from "node:fs";
6
5
  import AdmZip from "adm-zip";
7
6
  import { ClassicLevel } from "classic-level";
7
+ import { z } from "zod";
8
8
 
9
9
  //#region src/internal/download.ts
10
10
  const ZIP_SIGNATURE = Buffer.from([
@@ -14,16 +14,27 @@ 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
+ const pathExists$1 = async (target) => {
23
+ try {
24
+ await stat(target);
25
+ return true;
26
+ } catch (error) {
27
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
28
+ throw error;
29
+ }
30
+ };
31
+ /** Download and validate an extension archive, then publish its extraction atomically from staging. */
21
32
  const downloadAndExtractExtension = async (options) => {
22
33
  const { cacheDir, kind, name, sha256, url } = options;
23
34
  const cacheRoot = path.resolve(cacheDir);
24
35
  const outDir = path.resolve(cacheDir, name);
25
36
  if (outDir === cacheRoot || !outDir.startsWith(cacheRoot + path.sep)) throw new Error(`[walletwright] invalid extension name: ${name}`);
26
- if (existsSync(path.join(outDir, "manifest.json"))) return outDir;
37
+ if (await pathExists$1(path.join(outDir, "manifest.json"))) return outDir;
27
38
  await mkdir(cacheDir, { recursive: true });
28
39
  const response = await fetch(url, { redirect: "follow" });
29
40
  if (!response.ok) throw new Error(`[walletwright] failed to download ${url}: ${response.status} ${response.statusText}`);
@@ -38,19 +49,28 @@ const downloadAndExtractExtension = async (options) => {
38
49
  if (start === -1) throw new Error(`[walletwright] ${url} is not a valid CRX (no ZIP header found)`);
39
50
  zipBytes = bytes.subarray(start);
40
51
  }
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}`);
52
+ const staging = await mkdtemp(path.join(cacheRoot, STAGING_PREFIX));
53
+ try {
54
+ const zip = new AdmZip(zipBytes);
55
+ for (const entry of zip.getEntries()) {
56
+ const target = path.resolve(staging, entry.entryName);
57
+ if (target !== staging && !target.startsWith(staging + path.sep)) throw new Error(`[walletwright] refusing to extract ${entry.entryName}: escapes ${outDir}`);
58
+ }
59
+ zip.extractAllTo(staging, true);
60
+ if (!await pathExists$1(path.join(staging, "manifest.json"))) throw new Error(`[walletwright] extracted ${name} but no manifest.json found in ${outDir}`);
61
+ if (await pathExists$1(path.join(outDir, "manifest.json"))) return outDir;
62
+ await rm(outDir, {
63
+ force: true,
64
+ recursive: true
65
+ });
66
+ await rename(staging, outDir);
67
+ return outDir;
68
+ } finally {
69
+ await rm(staging, {
70
+ force: true,
71
+ recursive: true
72
+ }).catch(() => {});
50
73
  }
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
74
  };
55
75
  /** Build the Chrome Web Store CRX download URL for an extension id. */
56
76
  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 +121,7 @@ const importPrivateKey = async ({ home }, privateKey) => {
101
121
  });
102
122
  await closeAccountMenu(home);
103
123
  };
104
- const rename = async ({ home }, options) => {
124
+ const rename$1 = async ({ home }, options) => {
105
125
  await openAccountMenu(home);
106
126
  await home.getByTestId("multichain-account-cell-end-accessory").nth(options.index).click();
107
127
  await home.getByTestId("multichain-account-menu-item-rename").click();
@@ -120,7 +140,7 @@ const switchTo = async ({ home }, index) => {
120
140
  const accounts = {
121
141
  add: add$1,
122
142
  importPrivateKey,
123
- rename,
143
+ rename: rename$1,
124
144
  switch: switchTo
125
145
  };
126
146
 
@@ -158,12 +178,7 @@ const sleep = (ms) => new Promise((resolve) => {
158
178
  });
159
179
  /** Render a timeout for an error message, so what a failure claims matches what it waited. */
160
180
  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
- */
181
+ /** Poll until `check` returns a value, or `undefined` when the timeout expires. */
167
182
  const waitUntil = async (check, { intervalMs = DEFAULT_INTERVAL_MS, timeoutMs }) => {
168
183
  const deadline = Date.now() + timeoutMs;
169
184
  for (;;) {
@@ -200,6 +215,12 @@ const RELOAD_SETTLE_TIMEOUT_MS = 5e3;
200
215
  const PROBE_INTERVAL_MS = 500;
201
216
  const PASSWORD_CLEARED_TIMEOUT_MS = 15e3;
202
217
  /**
218
+ * Short on purpose. `unlock` is normally handed a page `reachUnlockScreen` already settled, so this
219
+ * budget only matters when it is called on its own (the `settings.unlock` path), where a wallet with a
220
+ * late-mounting UI needs a poll rather than a single read but a dead page should still fail fast.
221
+ */
222
+ const UNLOCK_SETTLE_TIMEOUT_MS = 5e3;
223
+ /**
203
224
  * The shared unlock flow: open the wallet's own page, wait for it to settle into a state we can
204
225
  * name, and type the password. Every wallet differs only in its entry page, how it submits, and
205
226
  * whether it can come back already unlocked.
@@ -228,11 +249,10 @@ const createUnlockScreen = ({ entry, isUnlocked, submit, wallet }) => {
228
249
  return page;
229
250
  };
230
251
  const unlock = async (page, password) => {
252
+ const state = await settleWithin(page, UNLOCK_SETTLE_TIMEOUT_MS);
253
+ if (state === "unlocked") return;
254
+ if (state === void 0) throw new Error(`[walletwright] ${wallet} showed neither an unlock screen nor an unlocked wallet`);
231
255
  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
256
  await field.fill(password);
237
257
  await (submit === void 0 ? field.press("Enter") : submit(page, field));
238
258
  if (!await field.waitFor({
@@ -305,39 +325,24 @@ const settings = {
305
325
  const BUTTON_TIMEOUT_MS = 45e3;
306
326
  /** The retry only covers a notice that rendered late, so it does not need the full budget again. */
307
327
  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
- */
328
+ /** Snap notices intercept footer clicks and disable Accept until their content is scrolled. */
314
329
  const acceptThirdPartyNotice = async (popup) => {
315
330
  const modal = popup.locator(".snap-install-warning");
316
331
  if (await modal.count().catch(() => 0) === 0) return;
317
332
  await modal.locator("p").last().scrollIntoViewIfNeeded().catch(() => {});
318
333
  await modal.getByRole("button", { name: "Accept" }).click({ timeout: 3e3 }).catch(() => {});
319
334
  };
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
- */
335
+ /** Resolve the confirm test id used by standard, legacy, and Snap request footers. */
329
336
  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
337
  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();
338
+ /** Distinguishes a pending request from MetaMask's requestless notification home screen. */
339
+ const approvalControls = (popup) => confirmButton(popup).or(cancelButton(popup));
331
340
  /**
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.
341
+ * Click one of the popup's footer buttons. The third-party notice covers that whole footer, so both
342
+ * confirm and cancel are intercepted while it is up, and both need the dismiss-then-retry: without it
343
+ * a click times out against a button that is rendered and enabled, which reads as a stale selector.
337
344
  */
338
- const approvalControls = (popup) => confirmButton(popup).or(cancelButton(popup));
339
- const approve = async (popup) => {
340
- const button = confirmButton(popup);
345
+ const clickFooterButton = async (popup, button) => {
341
346
  await acceptThirdPartyNotice(popup);
342
347
  try {
343
348
  await button.click({ timeout: BUTTON_TIMEOUT_MS });
@@ -348,10 +353,9 @@ const approve = async (popup) => {
348
353
  });
349
354
  }
350
355
  };
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
- };
356
+ const approve = (popup) => clickFooterButton(popup, confirmButton(popup));
357
+ /** The cancel counterpart of `approve`; same union-by-DOM-order resolution, not written order. */
358
+ const reject = (popup) => clickFooterButton(popup, cancelButton(popup));
355
359
 
356
360
  //#endregion
357
361
  //#region src/internal/utils.ts
@@ -360,29 +364,22 @@ const DEFAULT_CACHE_DIR = ".walletwright";
360
364
  const DEFAULT_NOTIFICATION_MATCH = "notification.html";
361
365
  /** Stable per-setup profile directory name, so the same wallet+seed+password reuses one cache. */
362
366
  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
- */
372
- const extensionIdFromPath = (extensionPath) => {
367
+ /** Mirror Chrome's extension-id derivation; resolve symlinks because Chrome hashes the real path. */
368
+ const extensionIdFromPath = async (extensionPath) => {
373
369
  const resolved = path.resolve(extensionPath);
374
- const abs = (() => {
375
- try {
376
- return realpathSync(resolved);
377
- } catch {
378
- return resolved;
379
- }
380
- })();
370
+ let abs = resolved;
371
+ try {
372
+ abs = await realpath(resolved);
373
+ } catch {
374
+ abs = resolved;
375
+ }
381
376
  let key;
382
377
  try {
383
- const manifest = JSON.parse(readFileSync(path.join(abs, "manifest.json"), "utf8"));
384
- if (typeof manifest === "object" && manifest !== null && "key" in manifest) key = typeof manifest.key === "string" ? manifest.key : void 0;
385
- } catch {}
378
+ const text = await readFile(path.join(abs, "manifest.json"), "utf8");
379
+ key = z.object({ key: z.string().optional() }).parse(JSON.parse(text)).key;
380
+ } catch {
381
+ key = void 0;
382
+ }
386
383
  const source = key === void 0 || key === "" ? Buffer.from(abs, "utf8") : Buffer.from(key, "base64");
387
384
  const hex = createHash("sha256").update(source).digest("hex").slice(0, 32);
388
385
  return Array.from(hex, (nibble) => String.fromCodePoint(97 + Number.parseInt(nibble, 16))).join("");
@@ -414,14 +411,7 @@ const APPROVAL_WINDOW = {
414
411
  };
415
412
  /** Far enough from the edge that a popup this size lands fully on any usable display. */
416
413
  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
- */
414
+ /** Move approval popups on-screen; headless and unsupported CDP targets are ignored. */
425
415
  const placeApprovalWindow = async (page) => {
426
416
  await page.setViewportSize(APPROVAL_WINDOW).catch(() => {});
427
417
  try {
@@ -440,32 +430,29 @@ const placeApprovalWindow = async (page) => {
440
430
  };
441
431
  /** Where Chrome persists an extension's `chrome.storage.local` inside a browser profile. */
442
432
  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
433
 
445
434
  //#endregion
446
435
  //#region src/wallets/metamask/onboarding-patch.ts
447
- const asState = (value) => typeof value === "object" && value !== null ? { ...value } : void 0;
436
+ const onboardingStateSchema = z.record(z.string(), z.json());
437
+ const parseState = (text, label) => {
438
+ const result = onboardingStateSchema.safeParse(JSON.parse(text));
439
+ if (!result.success) throw new Error(`[walletwright] MetaMask's ${label} state is not an object`, { cause: result.error });
440
+ return result.data;
441
+ };
448
442
  const markOnboarded = (onboarding) => ({
449
443
  ...onboarding,
450
444
  completedOnboarding: true,
451
445
  firstTimeFlowType: typeof onboarding.firstTimeFlowType === "string" ? onboarding.firstTimeFlowType : "import",
452
446
  onboardingTabs: {}
453
447
  });
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
- */
448
+ /** Patch both MetaMask 13.x onboarding layouts while the browser has released LevelDB. */
466
449
  const markMetaMaskOnboarded = async (profileDir, extensionId) => {
467
450
  const dbDir = extensionStateDir(profileDir, extensionId);
468
- if (!existsSync(dbDir)) throw new Error(`[walletwright] MetaMask wrote no extension state to ${dbDir}`);
451
+ try {
452
+ await access(dbDir);
453
+ } catch (error) {
454
+ throw new Error(`[walletwright] MetaMask wrote no extension state to ${dbDir}`, { cause: error });
455
+ }
469
456
  const db = new ClassicLevel(dbDir, {
470
457
  createIfMissing: false,
471
458
  keyEncoding: "utf8",
@@ -475,19 +462,18 @@ const markMetaMaskOnboarded = async (profileDir, extensionId) => {
475
462
  await db.open();
476
463
  const perController = await db.get("OnboardingController");
477
464
  if (perController !== void 0 && perController !== "") {
478
- const onboarding = asState(JSON.parse(perController));
479
- if (onboarding === void 0) throw new Error(`[walletwright] MetaMask's OnboardingController state is not an object`);
465
+ const onboarding = parseState(perController, "OnboardingController");
480
466
  if (onboarding.completedOnboarding !== true) await db.put("OnboardingController", JSON.stringify(markOnboarded(onboarding)));
481
467
  return;
482
468
  }
483
469
  const raw = await db.get("data");
484
470
  if (raw === void 0 || raw === "") throw new Error(`[walletwright] MetaMask persisted no onboarding state in ${dbDir} (neither an OnboardingController key nor a data key)`);
485
- const state = asState(JSON.parse(raw));
486
- if (state === void 0) throw new Error(`[walletwright] MetaMask's "data" state is not an object`);
487
- const nested = asState(state.data);
488
- const controller = asState((nested ?? state).OnboardingController);
489
- if (controller === void 0) throw new Error(`[walletwright] MetaMask's "data" state holds no OnboardingController`);
490
- const patched = markOnboarded(controller);
471
+ const state = parseState(raw, "\"data\"");
472
+ const nestedResult = onboardingStateSchema.safeParse(state.data);
473
+ const nested = nestedResult.success ? nestedResult.data : void 0;
474
+ const controllerResult = onboardingStateSchema.safeParse((nested ?? state).OnboardingController);
475
+ if (!controllerResult.success) throw new Error(`[walletwright] MetaMask's "data" state holds no OnboardingController`);
476
+ const patched = markOnboarded(controllerResult.data);
491
477
  await db.put("data", JSON.stringify(nested === void 0 ? {
492
478
  ...state,
493
479
  OnboardingController: patched
@@ -505,37 +491,24 @@ const markMetaMaskOnboarded = async (profileDir, extensionId) => {
505
491
 
506
492
  //#endregion
507
493
  //#region src/wallets/metamask.ts
508
- const DEFAULT_VERSION = "13.35.1";
494
+ /** Release hashes for the pinned default. Add an entry before changing `DEFAULT_VERSION`. */
495
+ const RELEASE_SHA256 = { "13.35.1": "4e0f8626df0ae9fb15f5f3ad6784a0b518f3ede067b2b0d4f539f9f457c5049c" };
509
496
  /**
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).
497
+ * Typed as a key of `RELEASE_SHA256`, so bumping the default without adding its hash above is a
498
+ * compile error. `download.ts` skips verification for an unpinned `sha256`, so the same bump would
499
+ * otherwise silently downgrade every default MetaMask download to unverified.
517
500
  */
518
- const RELEASE_SHA256 = { "13.35.1": "4e0f8626df0ae9fb15f5f3ad6784a0b518f3ede067b2b0d4f539f9f457c5049c" };
501
+ const DEFAULT_VERSION = "13.35.1";
502
+ const releaseSha256 = (version) => Object.entries(RELEASE_SHA256).find(([pinned]) => pinned === version)?.[1];
519
503
  /** Download inputs for a MetaMask release, split out so the integrity wiring is unit-testable. */
520
504
  const metamaskDownload = (cacheDir, version) => ({
521
505
  cacheDir,
522
506
  kind: "zip",
523
507
  name: `metamask-chrome-${version}`,
524
- sha256: RELEASE_SHA256[version],
508
+ sha256: releaseSha256(version),
525
509
  url: `https://github.com/MetaMask/metamask-extension/releases/download/v${version}/metamask-chrome-${version}.zip`
526
510
  });
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
- */
511
+ /** MetaMask's account-name sync mutates shared test-seed profiles, so its own e2e suite blocks this host. */
539
512
  const ACCOUNT_SYNC_HOST = "user-storage.api.cx.metamask.io";
540
513
  const metamask = {
541
514
  actions: {
@@ -696,34 +669,23 @@ const CONFIRM_LABELS = [
696
669
  "Confirm"
697
670
  ];
698
671
  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
- */
672
+ /** Rabby's focus-fragile popup requires in-page clicks; signing then requires a second Confirm click. */
711
673
  const clickApprovalButton = async (popup, labels) => {
712
- let lastClicked = "";
674
+ const clickedLabels = /* @__PURE__ */ new Set();
713
675
  return await waitUntil(async () => {
714
676
  if (popup.isClosed()) return true;
715
677
  const clicked = await popup.evaluate((arg) => {
716
678
  const target = [...document.querySelectorAll("button")].find((button) => {
717
679
  const text = (button.textContent ?? "").trim();
718
- return arg.names.includes(text) && !button.disabled && text !== arg.skip;
680
+ return arg.names.includes(text) && !button.disabled && !arg.skip.includes(text);
719
681
  });
720
682
  target?.click();
721
683
  return target ? (target.textContent ?? "").trim() : "";
722
684
  }, {
723
685
  names: [...labels],
724
- skip: lastClicked
686
+ skip: [...clickedLabels]
725
687
  }).catch(() => "");
726
- if (clicked) lastClicked = clicked;
688
+ if (clicked) clickedLabels.add(clicked);
727
689
  }, {
728
690
  intervalMs: 300,
729
691
  timeoutMs: APPROVAL_TIMEOUT_MS
@@ -755,7 +717,53 @@ const rabby = {
755
717
  const SLUSH_EXTENSION_ID = "opcgpfmipidbgpenhmajoajpbobppdil";
756
718
  const HOME_ROUTE = "#/tokens";
757
719
  const IMPORT_HOME_TIMEOUT_MS = 3e4;
720
+ const PERSIST_TIMEOUT_MS = 6e4;
721
+ const ACCOUNTS_DATABASE = "signaldb-accounts";
722
+ const PREFERENCES_DATABASE = "signaldb-preferences";
723
+ const ITEMS_STORE = "items";
758
724
  const atHome = async (page) => await page.evaluate(() => globalThis.location.hash).catch(() => "") === HOME_ROUTE;
725
+ /**
726
+ * Slush writes the imported account, its provider and the preferences row that selects it in
727
+ * separate transactions, so reaching the home route does not mean the profile is durable yet.
728
+ * Caching between those writes yields a profile that unlocks to an account-less wallet.
729
+ */
730
+ const walletPersisted = (page) => page.evaluate(async ({ accountsDatabase, preferencesDatabase, storeName }) => {
731
+ const readItems = async (databaseName) => {
732
+ if (!(await indexedDB.databases()).some((database) => database.name === databaseName)) return [];
733
+ const database = await new Promise((resolve, reject) => {
734
+ const request = indexedDB.open(databaseName);
735
+ request.addEventListener("success", () => {
736
+ resolve(request.result);
737
+ });
738
+ request.addEventListener("error", () => {
739
+ reject(request.error ?? /* @__PURE__ */ new Error(`Unable to open ${databaseName}`));
740
+ });
741
+ });
742
+ try {
743
+ if (!database.objectStoreNames.contains(storeName)) return [];
744
+ const request = database.transaction(storeName, "readonly").objectStore(storeName).getAll();
745
+ return await new Promise((resolve, reject) => {
746
+ request.addEventListener("success", () => {
747
+ resolve(request.result.filter((item) => typeof item === "object" && item !== null));
748
+ });
749
+ request.addEventListener("error", () => {
750
+ reject(request.error ?? /* @__PURE__ */ new Error(`Unable to read ${storeName}`));
751
+ });
752
+ });
753
+ } finally {
754
+ database.close();
755
+ }
756
+ };
757
+ const accounts = await readItems(accountsDatabase);
758
+ const accountIds = /* @__PURE__ */ new Set();
759
+ for (const account of accounts) if ("id" in account && typeof account.id === "string") accountIds.add(account.id);
760
+ if (accountIds.size === 0) return false;
761
+ return (await readItems(preferencesDatabase)).some((preference) => "currentAccountId" in preference && typeof preference.currentAccountId === "string" && accountIds.has(preference.currentAccountId));
762
+ }, {
763
+ accountsDatabase: ACCOUNTS_DATABASE,
764
+ preferencesDatabase: PREFERENCES_DATABASE,
765
+ storeName: ITEMS_STORE
766
+ });
759
767
  const fclick = async (page, text, timeoutMs = 15e3) => {
760
768
  const target = page.getByText(text, { exact: true }).first();
761
769
  if (!await target.waitFor({
@@ -796,7 +804,7 @@ const slush = {
796
804
  await fclick(page, "Import existing from passphrase");
797
805
  await sleep(1500);
798
806
  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] ?? "");
807
+ for (let i = 0; i < words.length; i++) await page.getByLabel(`Word ${i + 1}`, { exact: true }).fill(words[i] ?? "");
800
808
  await page.getByRole("button", { name: "Next" }).click();
801
809
  await sleep(2e3);
802
810
  await page.locator("input[placeholder=\"Password\"]").fill(password);
@@ -809,6 +817,11 @@ const slush = {
809
817
  message: `Slush import never reached the wallet home (${HOME_ROUTE})`,
810
818
  timeoutMs: IMPORT_HOME_TIMEOUT_MS
811
819
  });
820
+ await waitUntilOrThrow(() => walletPersisted(page), {
821
+ intervalMs: 1e3,
822
+ message: "Slush import never persisted an account and its selected preference",
823
+ timeoutMs: PERSIST_TIMEOUT_MS
824
+ });
812
825
  },
813
826
  notificationMatch: "isPopup=1",
814
827
  onboardingPage: "index.html",
@@ -913,38 +926,86 @@ const extensionContextOptions = (extensionPath, headless) => ({
913
926
  const NAVIGATION_TIMEOUT_MS = 15e3;
914
927
  const FLUSH_SETTLE_MS = 3e3;
915
928
  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
- */
929
+ /** Wallet state may live in `chrome.storage.local` or extension IndexedDB. */
923
930
  const hasPersistedState = async (profileDir, extensionId) => {
924
931
  const stores = [extensionStateDir(profileDir, extensionId), path.join(profileDir, "Default", "IndexedDB", `chrome-extension_${extensionId}_0.indexeddb.leveldb`)];
925
932
  return (await Promise.allSettled(stores.map((dir) => readdir(dir)))).some((listing) => listing.status === "fulfilled" && listing.value.length > 0);
926
933
  };
927
934
  /**
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`.
935
+ * Prefix for an in-progress build. Inside `cacheDir` so publishing is a `rename` on one filesystem,
936
+ * dot-prefixed so it can never be mistaken for a 20-hex-character `profileKey`.
933
937
  */
934
- const buildCache = async (setup, options = {}) => {
935
- const definition = wallets[setup.wallet];
936
- const cacheDir = path.resolve(setup.cacheDir ?? ".walletwright");
937
- const extensionPath = await definition.prepareExtension(cacheDir, setup.version);
938
- const profileDir = path.join(cacheDir, profileKey(setup));
939
- await rm(profileDir, {
938
+ const BUILD_PREFIX = ".building-";
939
+ const PREVIOUS_SUFFIX = ".previous";
940
+ const pathExists = async (target) => {
941
+ try {
942
+ await stat(target);
943
+ return true;
944
+ } catch (error) {
945
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
946
+ throw error;
947
+ }
948
+ };
949
+ const previousProfileDir = (profileDir) => `${profileDir}${PREVIOUS_SUFFIX}`;
950
+ const restorePreviousProfile = async (profileDir) => {
951
+ const previous = previousProfileDir(profileDir);
952
+ if (await pathExists(profileDir)) {
953
+ await rm(previous, {
954
+ force: true,
955
+ recursive: true
956
+ });
957
+ return;
958
+ }
959
+ try {
960
+ await rename(previous, profileDir);
961
+ } catch (error) {
962
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
963
+ }
964
+ };
965
+ const publishProfile = async (staging, profileDir) => {
966
+ await restorePreviousProfile(profileDir);
967
+ const previous = previousProfileDir(profileDir);
968
+ let movedPrevious = false;
969
+ try {
970
+ await rename(profileDir, previous);
971
+ movedPrevious = true;
972
+ } catch (error) {
973
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
974
+ }
975
+ try {
976
+ await rename(staging, profileDir);
977
+ } catch (error) {
978
+ if (movedPrevious) try {
979
+ await rename(previous, profileDir);
980
+ } catch (rollbackError) {
981
+ const publishMessage = error instanceof Error ? error.message : String(error);
982
+ const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
983
+ throw new Error(`[walletwright] failed to publish cache at ${profileDir}: ${publishMessage}; rollback failed: ${rollbackMessage}`, { cause: rollbackError });
984
+ }
985
+ throw error;
986
+ }
987
+ if (movedPrevious) await rm(previous, {
940
988
  force: true,
941
989
  recursive: true
942
990
  });
943
- await mkdir(profileDir, { recursive: true });
944
- const context = await chromium.launchPersistentContext(profileDir, extensionContextOptions(extensionPath, options.headless === true));
991
+ };
992
+ const defaultBuildCacheDependencies = {
993
+ launchPersistentContext: chromium.launchPersistentContext.bind(chromium),
994
+ prepareExtension: (definition, cacheDir, version) => definition.prepareExtension(cacheDir, version)
995
+ };
996
+ const buildCacheWithDependencies = async (setup, options = {}, dependencies = defaultBuildCacheDependencies) => {
997
+ const definition = wallets[setup.wallet];
998
+ const cacheDir = path.resolve(setup.cacheDir ?? ".walletwright");
999
+ const extensionPath = await dependencies.prepareExtension(definition, cacheDir, setup.version);
1000
+ const profileDir = path.join(cacheDir, profileKey(setup));
1001
+ await restorePreviousProfile(profileDir);
1002
+ await mkdir(cacheDir, { recursive: true });
1003
+ const staging = await mkdtemp(path.join(cacheDir, BUILD_PREFIX));
1004
+ let context;
945
1005
  try {
1006
+ context = await dependencies.launchPersistentContext(staging, extensionContextOptions(extensionPath, options.headless === true));
946
1007
  await definition.prepareContext?.(context);
947
- const extensionId = extensionIdFromPath(extensionPath);
1008
+ const extensionId = await extensionIdFromPath(extensionPath);
948
1009
  const page = context.pages().find((candidate) => candidate.url() === "about:blank") ?? await context.newPage();
949
1010
  await gotoWithRetry(page, `chrome-extension://${extensionId}/${definition.onboardingPage}`, {
950
1011
  label: `${definition.extensionName} onboarding page`,
@@ -953,20 +1014,27 @@ const buildCache = async (setup, options = {}) => {
953
1014
  await sleep(2e3);
954
1015
  await definition.importWallet(page, setup.seedPhrase, setup.password);
955
1016
  await sleep(FLUSH_SETTLE_MS);
956
- await waitUntilOrThrow(() => hasPersistedState(profileDir, extensionId), {
1017
+ await waitUntilOrThrow(() => hasPersistedState(staging, extensionId), {
957
1018
  intervalMs: 500,
958
- message: `${definition.extensionName} onboarding wrote no wallet state into ${profileDir}`,
1019
+ message: `${definition.extensionName} onboarding wrote no wallet state into ${staging}`,
959
1020
  timeoutMs: STATE_WRITE_TIMEOUT_MS
960
1021
  });
961
1022
  await context.close();
962
- await definition.finalizeCache?.(profileDir, extensionId);
1023
+ await definition.finalizeCache?.(staging, extensionId);
1024
+ await publishProfile(staging, profileDir);
963
1025
  return profileDir;
964
1026
  } catch (error) {
965
- await context.close().catch(() => {});
1027
+ await context?.close().catch(() => {});
966
1028
  throw error;
1029
+ } finally {
1030
+ await rm(staging, {
1031
+ force: true,
1032
+ recursive: true
1033
+ });
967
1034
  }
968
1035
  };
1036
+ const buildCache = (setup, options) => buildCacheWithDependencies(setup, options);
969
1037
 
970
1038
  //#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
1039
+ 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 };
1040
+ //# sourceMappingURL=cache-BaWu5vyM.mjs.map