@walletwright/core 0.2.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.
@@ -0,0 +1,972 @@
1
+ import { mkdir, readdir, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { chromium } from "@playwright/test";
4
+ import { createHash } from "node:crypto";
5
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
6
+ import AdmZip from "adm-zip";
7
+ import { ClassicLevel } from "classic-level";
8
+
9
+ //#region src/internal/download.ts
10
+ const ZIP_SIGNATURE = Buffer.from([
11
+ 80,
12
+ 75,
13
+ 3,
14
+ 4
15
+ ]);
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).
20
+ */
21
+ const downloadAndExtractExtension = async (options) => {
22
+ const { cacheDir, kind, name, sha256, url } = options;
23
+ const cacheRoot = path.resolve(cacheDir);
24
+ const outDir = path.resolve(cacheDir, name);
25
+ 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;
27
+ await mkdir(cacheDir, { recursive: true });
28
+ const response = await fetch(url, { redirect: "follow" });
29
+ if (!response.ok) throw new Error(`[walletwright] failed to download ${url}: ${response.status} ${response.statusText}`);
30
+ const bytes = Buffer.from(await response.arrayBuffer());
31
+ if (sha256 !== void 0) {
32
+ const actual = createHash("sha256").update(bytes).digest("hex");
33
+ if (actual !== sha256.toLowerCase()) throw new Error(`[walletwright] ${name} failed integrity check: expected ${sha256}, got ${actual}`);
34
+ }
35
+ let zipBytes = bytes;
36
+ if (kind === "crx") {
37
+ const start = bytes.indexOf(ZIP_SIGNATURE);
38
+ if (start === -1) throw new Error(`[walletwright] ${url} is not a valid CRX (no ZIP header found)`);
39
+ zipBytes = bytes.subarray(start);
40
+ }
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}`);
50
+ }
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
+ };
55
+ /** Build the Chrome Web Store CRX download URL for an extension id. */
56
+ const chromeWebStoreCrxUrl = (extensionId) => `https://clients2.google.com/service/update2/crx?response=redirect&prodversion=130.0&acceptformat=crx2,crx3&x=id%3D${extensionId}%26uc`;
57
+ /** Download and extract a Chrome Web Store extension (latest) into `<cacheDir>/<name>`. */
58
+ const prepareWebStoreExtension = (options) => downloadAndExtractExtension({
59
+ cacheDir: options.cacheDir,
60
+ kind: "crx",
61
+ name: options.name,
62
+ sha256: void 0,
63
+ url: chromeWebStoreCrxUrl(options.extensionId)
64
+ });
65
+
66
+ //#endregion
67
+ //#region src/wallets/metamask/actions/accounts.ts
68
+ const openAccountMenu = async (home) => {
69
+ await home.getByTestId("account-menu-icon").click();
70
+ await home.getByTestId("add-multichain-account-button").waitFor({ state: "visible" });
71
+ };
72
+ /** The menu is a popover with no close affordance; going home is the reliable way out. */
73
+ const closeAccountMenu = async (home) => {
74
+ await home.goto(`${home.url().split("#")[0]}#/`);
75
+ await home.getByTestId("account-menu-icon").waitFor({ state: "visible" });
76
+ };
77
+ const add$1 = async ({ home }) => {
78
+ await openAccountMenu(home);
79
+ const cells = home.getByTestId("account-cell-avatar");
80
+ const before = await cells.count();
81
+ await home.getByTestId("add-multichain-account-button").click();
82
+ await cells.nth(before).waitFor({
83
+ state: "visible",
84
+ timeout: 15e3
85
+ });
86
+ await closeAccountMenu(home);
87
+ };
88
+ const importPrivateKey = async ({ home }, privateKey) => {
89
+ await openAccountMenu(home);
90
+ await home.getByTestId("account-list-add-wallet-button").click();
91
+ const chooseImport = home.getByTestId("choose-wallet-type-import-account");
92
+ await chooseImport.waitFor({ state: "visible" });
93
+ await chooseImport.click();
94
+ const confirm = home.getByTestId("import-account-confirm-button");
95
+ await confirm.waitFor({ state: "visible" });
96
+ await home.locator("input").first().fill(privateKey);
97
+ await confirm.click();
98
+ await confirm.waitFor({
99
+ state: "hidden",
100
+ timeout: 15e3
101
+ });
102
+ await closeAccountMenu(home);
103
+ };
104
+ const rename = async ({ home }, options) => {
105
+ await openAccountMenu(home);
106
+ await home.getByTestId("multichain-account-cell-end-accessory").nth(options.index).click();
107
+ await home.getByTestId("multichain-account-menu-item-rename").click();
108
+ await home.getByTestId("account-name-input").locator("input").fill(options.name);
109
+ await home.getByRole("button", { name: "Confirm" }).click();
110
+ await closeAccountMenu(home);
111
+ };
112
+ const switchTo = async ({ home }, index) => {
113
+ await openAccountMenu(home);
114
+ await home.getByTestId("account-cell-avatar").nth(index).click();
115
+ if (!await home.getByTestId("add-multichain-account-button").waitFor({
116
+ state: "hidden",
117
+ timeout: 1e4
118
+ }).then(() => true).catch(() => false)) throw new Error(`[walletwright] accounts.switch(${index}) did not switch (menu stayed open)`);
119
+ };
120
+ const accounts = {
121
+ add: add$1,
122
+ importPrivateKey,
123
+ rename,
124
+ switch: switchTo
125
+ };
126
+
127
+ //#endregion
128
+ //#region src/wallets/metamask/actions/network.ts
129
+ const closeButton = (home) => home.getByTestId("modal-header-close-button").filter({ visible: true }).first();
130
+ const openNetworkManager = async (home) => {
131
+ await home.getByTestId("sort-by-networks").click();
132
+ await closeButton(home).waitFor({ state: "visible" });
133
+ };
134
+ const add = async ({ home }, config) => {
135
+ await openNetworkManager(home);
136
+ await home.getByText("Custom", { exact: true }).first().click();
137
+ await home.getByRole("button", { name: "Add custom network" }).first().click();
138
+ await home.getByTestId("network-form-network-name").fill(config.name);
139
+ await home.getByTestId("network-form-chain-id").fill(String(config.chainId));
140
+ await home.getByTestId("network-form-ticker-input").fill(config.symbol);
141
+ await home.getByTestId("test-add-rpc-drop-down").click();
142
+ await home.getByText("Add RPC URL", { exact: true }).first().click();
143
+ await home.getByTestId("rpc-url-input-test").fill(config.rpcUrl);
144
+ await home.getByTestId("page-container-footer-next").click();
145
+ await home.getByTestId("network-form-network-name").waitFor();
146
+ await home.getByTestId("page-container-footer-next").click();
147
+ await closeButton(home).click();
148
+ await closeButton(home).waitFor({ state: "hidden" });
149
+ };
150
+ const network = { add };
151
+
152
+ //#endregion
153
+ //#region src/internal/wait.ts
154
+ const DEFAULT_INTERVAL_MS = 200;
155
+ const DEFAULT_NAVIGATION_INTERVAL_MS = 1e3;
156
+ const sleep = (ms) => new Promise((resolve) => {
157
+ setTimeout(resolve, ms);
158
+ });
159
+ /** Render a timeout for an error message, so what a failure claims matches what it waited. */
160
+ 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
+ */
167
+ const waitUntil = async (check, { intervalMs = DEFAULT_INTERVAL_MS, timeoutMs }) => {
168
+ const deadline = Date.now() + timeoutMs;
169
+ for (;;) {
170
+ const result = await check();
171
+ if (result !== void 0 && result !== false) return result;
172
+ if (Date.now() >= deadline) return;
173
+ await sleep(intervalMs);
174
+ }
175
+ };
176
+ const waitUntilOrThrow = async (check, options) => {
177
+ const result = await waitUntil(check, options);
178
+ if (result === void 0) throw new Error(`[walletwright] ${options.message} (gave up after ${formatTimeout(options.timeoutMs)})`);
179
+ return result;
180
+ };
181
+ /**
182
+ * A `chrome-extension://` URL fails with `ERR_BLOCKED_BY_CLIENT` until the extension registers,
183
+ * which takes seconds after launch, so reaching an extension page is a poll rather than a one-shot.
184
+ */
185
+ const gotoWithRetry = async (page, url, { intervalMs = DEFAULT_NAVIGATION_INTERVAL_MS, label, timeoutMs }) => {
186
+ await waitUntilOrThrow(() => page.goto(url, { waitUntil: "domcontentloaded" }).then(() => true).catch(() => void 0), {
187
+ intervalMs,
188
+ message: `${label} never loaded (${url})`,
189
+ timeoutMs
190
+ });
191
+ };
192
+
193
+ //#endregion
194
+ //#region src/internal/unlock-screen.ts
195
+ const PASSWORD_FIELD = "input[type=\"password\"]";
196
+ const NAVIGATION_TIMEOUT_MS$1 = 15e3;
197
+ const FIRST_SETTLE_TIMEOUT_MS = 2e4;
198
+ const RELOAD_ATTEMPTS = 5;
199
+ const RELOAD_SETTLE_TIMEOUT_MS = 5e3;
200
+ const PROBE_INTERVAL_MS = 500;
201
+ const PASSWORD_CLEARED_TIMEOUT_MS = 15e3;
202
+ /**
203
+ * The shared unlock flow: open the wallet's own page, wait for it to settle into a state we can
204
+ * name, and type the password. Every wallet differs only in its entry page, how it submits, and
205
+ * whether it can come back already unlocked.
206
+ */
207
+ const createUnlockScreen = ({ entry, isUnlocked, submit, wallet }) => {
208
+ const settled = async (page) => {
209
+ if (await page.locator(PASSWORD_FIELD).isVisible().catch(() => false)) return "locked";
210
+ return await isUnlocked?.(page).catch(() => false) === true ? "unlocked" : void 0;
211
+ };
212
+ const settleWithin = (page, timeoutMs) => waitUntil(() => settled(page), {
213
+ intervalMs: PROBE_INTERVAL_MS,
214
+ timeoutMs
215
+ });
216
+ const reachUnlockScreen = async (context, extensionId) => {
217
+ const page = await context.newPage();
218
+ await gotoWithRetry(page, `chrome-extension://${extensionId}/${entry}`, {
219
+ label: `${wallet} ${entry}`,
220
+ timeoutMs: NAVIGATION_TIMEOUT_MS$1
221
+ });
222
+ let state = await settleWithin(page, FIRST_SETTLE_TIMEOUT_MS);
223
+ for (let attempt = 0; attempt < RELOAD_ATTEMPTS && state === void 0; attempt++) {
224
+ await page.reload().catch(() => {});
225
+ state = await settleWithin(page, RELOAD_SETTLE_TIMEOUT_MS);
226
+ }
227
+ if (state === void 0) throw new Error(`[walletwright] ${wallet} never reached its unlock screen (gave up after ${formatTimeout(45e3)})`);
228
+ return page;
229
+ };
230
+ const unlock = async (page, password) => {
231
+ 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
+ await field.fill(password);
237
+ await (submit === void 0 ? field.press("Enter") : submit(page, field));
238
+ if (!await field.waitFor({
239
+ state: "hidden",
240
+ timeout: PASSWORD_CLEARED_TIMEOUT_MS
241
+ }).then(() => true).catch(() => false)) throw new Error(`[walletwright] ${wallet} unlock failed (password screen still visible after ${formatTimeout(PASSWORD_CLEARED_TIMEOUT_MS)})`);
242
+ };
243
+ return {
244
+ reachUnlockScreen,
245
+ unlock
246
+ };
247
+ };
248
+
249
+ //#endregion
250
+ //#region src/wallets/metamask/onboarding.ts
251
+ const SRP_RETYPE_TIMEOUT_MS = 4e4;
252
+ const SRP_CONFIRM_TIMEOUT_MS = 5e3;
253
+ const { reachUnlockScreen: reachUnlockScreen$4, unlock: unlock$4 } = createUnlockScreen({
254
+ entry: "home.html",
255
+ wallet: "MetaMask"
256
+ });
257
+ const importWallet$2 = async (page, seedPhrase, password) => {
258
+ await page.getByTestId("onboarding-import-wallet").click();
259
+ await page.getByTestId("onboarding-import-with-srp-button").click();
260
+ const srpField = page.getByTestId("srp-input-import__srp-note");
261
+ const confirmEnabled = page.locator("[data-testid=\"import-srp-confirm\"]:not([disabled])");
262
+ if (await waitUntil(async () => {
263
+ await srpField.click();
264
+ await srpField.fill("");
265
+ await srpField.pressSequentially(seedPhrase, { delay: 50 });
266
+ return confirmEnabled.waitFor({
267
+ state: "visible",
268
+ timeout: SRP_CONFIRM_TIMEOUT_MS
269
+ }).then(() => true).catch(() => void 0);
270
+ }, {
271
+ intervalMs: 0,
272
+ timeoutMs: SRP_RETYPE_TIMEOUT_MS
273
+ }) === void 0) throw new Error("[walletwright] MetaMask rejected the seed phrase (confirm stayed disabled)");
274
+ await confirmEnabled.click();
275
+ await page.getByTestId("create-password-new-input").fill(password);
276
+ await page.getByTestId("create-password-confirm-input").fill(password);
277
+ await page.getByTestId("create-password-terms").click();
278
+ await page.locator("[data-testid=\"create-password-submit\"]:not([disabled])").click();
279
+ await page.getByTestId("passkey-maybe-later-button").click({ timeout: 1e4 }).catch(() => {});
280
+ await page.getByTestId("metametrics-i-agree").click({ timeout: 1e4 }).catch(() => {});
281
+ };
282
+
283
+ //#endregion
284
+ //#region src/wallets/metamask/actions/settings.ts
285
+ const settings = {
286
+ lock: async ({ home }) => {
287
+ await home.getByTestId("account-options-menu-button").click();
288
+ await home.getByTestId("global-menu-lock").filter({ visible: true }).first().click();
289
+ await home.locator("input[type=\"password\"]").waitFor({
290
+ state: "visible",
291
+ timeout: 15e3
292
+ });
293
+ },
294
+ unlock: async ({ home, password }) => {
295
+ await unlock$4(home, password);
296
+ }
297
+ };
298
+
299
+ //#endregion
300
+ //#region src/wallets/metamask/approve.ts
301
+ /**
302
+ * How long a confirm or cancel button gets to become clickable, once the page is already showing
303
+ * one. Generous because a loaded CI runner renders MetaMask's footer slowly.
304
+ */
305
+ const BUTTON_TIMEOUT_MS = 45e3;
306
+ /** The retry only covers a notice that rendered late, so it does not need the full budget again. */
307
+ 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
+ */
314
+ const acceptThirdPartyNotice = async (popup) => {
315
+ const modal = popup.locator(".snap-install-warning");
316
+ if (await modal.count().catch(() => 0) === 0) return;
317
+ await modal.locator("p").last().scrollIntoViewIfNeeded().catch(() => {});
318
+ await modal.getByRole("button", { name: "Accept" }).click({ timeout: 3e3 }).catch(() => {});
319
+ };
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
+ */
329
+ 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
+ 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();
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.
337
+ */
338
+ const approvalControls = (popup) => confirmButton(popup).or(cancelButton(popup));
339
+ const approve = async (popup) => {
340
+ const button = confirmButton(popup);
341
+ await acceptThirdPartyNotice(popup);
342
+ try {
343
+ await button.click({ timeout: BUTTON_TIMEOUT_MS });
344
+ } catch (error) {
345
+ await acceptThirdPartyNotice(popup);
346
+ await button.click({ timeout: RETRY_TIMEOUT_MS }).catch(() => {
347
+ throw error;
348
+ });
349
+ }
350
+ };
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
+ };
355
+
356
+ //#endregion
357
+ //#region src/internal/utils.ts
358
+ const DEFAULT_CACHE_DIR = ".walletwright";
359
+ /** MetaMask/Phantom approval-popup URL token; Slush overrides it with `notificationMatch: "isPopup=1"`. */
360
+ const DEFAULT_NOTIFICATION_MATCH = "notification.html";
361
+ /** Stable per-setup profile directory name, so the same wallet+seed+password reuses one cache. */
362
+ 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) => {
373
+ const resolved = path.resolve(extensionPath);
374
+ const abs = (() => {
375
+ try {
376
+ return realpathSync(resolved);
377
+ } catch {
378
+ return resolved;
379
+ }
380
+ })();
381
+ let key;
382
+ 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 {}
386
+ const source = key === void 0 || key === "" ? Buffer.from(abs, "utf8") : Buffer.from(key, "base64");
387
+ const hex = createHash("sha256").update(source).digest("hex").slice(0, 32);
388
+ return Array.from(hex, (nibble) => String.fromCodePoint(97 + Number.parseInt(nibble, 16))).join("");
389
+ };
390
+ /**
391
+ * Approval popups open as `about:blank` and then navigate, so `waitForEvent('page', { predicate })`
392
+ * misses them, poll the open pages instead. `match` is the URL token identifying the popup
393
+ * (`notification.html` for MetaMask/Phantom; `isPopup=1` for Slush's single-page `index.html`).
394
+ */
395
+ const isApprovalPopup = (page, extensionId, match) => page.url().startsWith(`chrome-extension://${extensionId}`) && page.url().includes(match) && !page.isClosed();
396
+ const isVisible = async (locator) => {
397
+ try {
398
+ return await locator.isVisible();
399
+ } catch {
400
+ return false;
401
+ }
402
+ };
403
+ const hasVisibleButton = (page) => isVisible(page.locator("button").first());
404
+ const findNotificationPopup = ({ approvalControls, context, extensionId, match = DEFAULT_NOTIFICATION_MATCH, timeoutMs = 1e4 }) => waitUntil(async () => {
405
+ const popup = context.pages().find((page) => isApprovalPopup(page, extensionId, match));
406
+ if (!popup) return;
407
+ await popup.waitForLoadState("domcontentloaded").catch(() => {});
408
+ return (approvalControls === void 0 ? await hasVisibleButton(popup) : await isVisible(approvalControls(popup).first())) ? popup : void 0;
409
+ }, { timeoutMs });
410
+ /** MetaMask's own popup dimensions, which its layout is built for. */
411
+ const APPROVAL_WINDOW = {
412
+ height: 592,
413
+ width: 360
414
+ };
415
+ /** Far enough from the edge that a popup this size lands fully on any usable display. */
416
+ 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
+ */
425
+ const placeApprovalWindow = async (page) => {
426
+ await page.setViewportSize(APPROVAL_WINDOW).catch(() => {});
427
+ try {
428
+ const session = await page.context().newCDPSession(page);
429
+ const { targetInfo } = await session.send("Target.getTargetInfo");
430
+ const { windowId } = await session.send("Browser.getWindowForTarget", { targetId: targetInfo.targetId });
431
+ await session.send("Browser.setWindowBounds", {
432
+ bounds: {
433
+ left: APPROVAL_WINDOW_OFFSET,
434
+ top: APPROVAL_WINDOW_OFFSET
435
+ },
436
+ windowId
437
+ });
438
+ await session.detach();
439
+ } catch {}
440
+ };
441
+ /** Where Chrome persists an extension's `chrome.storage.local` inside a browser profile. */
442
+ 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
+
445
+ //#endregion
446
+ //#region src/wallets/metamask/onboarding-patch.ts
447
+ const asState = (value) => typeof value === "object" && value !== null ? { ...value } : void 0;
448
+ const markOnboarded = (onboarding) => ({
449
+ ...onboarding,
450
+ completedOnboarding: true,
451
+ firstTimeFlowType: typeof onboarding.firstTimeFlowType === "string" ? onboarding.firstTimeFlowType : "import",
452
+ onboardingTabs: {}
453
+ });
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
+ */
466
+ const markMetaMaskOnboarded = async (profileDir, extensionId) => {
467
+ const dbDir = extensionStateDir(profileDir, extensionId);
468
+ if (!existsSync(dbDir)) throw new Error(`[walletwright] MetaMask wrote no extension state to ${dbDir}`);
469
+ const db = new ClassicLevel(dbDir, {
470
+ createIfMissing: false,
471
+ keyEncoding: "utf8",
472
+ valueEncoding: "utf8"
473
+ });
474
+ try {
475
+ await db.open();
476
+ const perController = await db.get("OnboardingController").catch(() => null);
477
+ if (perController !== null && 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`);
480
+ if (onboarding.completedOnboarding !== true) await db.put("OnboardingController", JSON.stringify(markOnboarded(onboarding)));
481
+ return;
482
+ }
483
+ const raw = await db.get("data").catch(() => null);
484
+ if (raw === null || 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);
491
+ await db.put("data", JSON.stringify(nested === void 0 ? {
492
+ ...state,
493
+ OnboardingController: patched
494
+ } : {
495
+ ...state,
496
+ data: {
497
+ ...nested,
498
+ OnboardingController: patched
499
+ }
500
+ }));
501
+ } finally {
502
+ await db.close();
503
+ }
504
+ };
505
+
506
+ //#endregion
507
+ //#region src/wallets/metamask.ts
508
+ const DEFAULT_VERSION = "13.35.1";
509
+ /**
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).
517
+ */
518
+ const RELEASE_SHA256 = { "13.35.1": "4e0f8626df0ae9fb15f5f3ad6784a0b518f3ede067b2b0d4f539f9f457c5049c" };
519
+ /** Download inputs for a MetaMask release, split out so the integrity wiring is unit-testable. */
520
+ const metamaskDownload = (cacheDir, version) => ({
521
+ cacheDir,
522
+ kind: "zip",
523
+ name: `metamask-chrome-${version}`,
524
+ sha256: RELEASE_SHA256[version],
525
+ url: `https://github.com/MetaMask/metamask-extension/releases/download/v${version}/metamask-chrome-${version}.zip`
526
+ });
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
+ */
539
+ const ACCOUNT_SYNC_HOST = "user-storage.api.cx.metamask.io";
540
+ const metamask = {
541
+ actions: {
542
+ accounts,
543
+ network,
544
+ settings
545
+ },
546
+ approvalControls,
547
+ approve,
548
+ ecosystems: ["evm", "svm"],
549
+ extensionName: "MetaMask",
550
+ prepareContext: async (context) => {
551
+ await context.route(`**://${ACCOUNT_SYNC_HOST}/**`, (route) => route.abort());
552
+ },
553
+ prepareExtension: (cacheDir, version = DEFAULT_VERSION) => downloadAndExtractExtension(metamaskDownload(cacheDir, version)),
554
+ finalizeCache: markMetaMaskOnboarded,
555
+ importWallet: importWallet$2,
556
+ onboardingPage: "home.html",
557
+ reachUnlockScreen: reachUnlockScreen$4,
558
+ reject,
559
+ unlock: unlock$4
560
+ };
561
+
562
+ //#endregion
563
+ //#region src/wallets/phantom.ts
564
+ const PHANTOM_EXTENSION_ID = "bfnaelmomeimhlpmgjnjophhpkkoljpa";
565
+ const { reachUnlockScreen: reachUnlockScreen$3, unlock: unlock$3 } = createUnlockScreen({
566
+ entry: "popup.html",
567
+ isUnlocked: async (page) => {
568
+ return await page.locator("button").first().isVisible().catch(() => false);
569
+ },
570
+ submit: async (page, field) => {
571
+ const unlockButton = page.getByRole("button", { name: /* @__PURE__ */ new RegExp("unlock", "iv") });
572
+ await (await unlockButton.isVisible().catch(() => false) ? unlockButton.click() : field.press("Enter"));
573
+ },
574
+ wallet: "Phantom"
575
+ });
576
+ const phantom = {
577
+ ecosystems: ["evm", "svm"],
578
+ extensionName: "Phantom",
579
+ headlessApprovals: true,
580
+ importWallet: async (page, seedPhrase, password) => {
581
+ const testId = (id) => page.locator(`[data-testid="${id}"]`);
582
+ await page.locator("text=I already have a wallet").click();
583
+ await sleep(1e3);
584
+ await page.locator("text=Import Recovery Phrase").click();
585
+ await sleep(1500);
586
+ const words = seedPhrase.trim().split(/* @__PURE__ */ new RegExp("\\s+", "v"));
587
+ for (let i = 0; i < words.length; i++) await testId(`secret-recovery-phrase-word-input-${i}`).fill(words[i] ?? "");
588
+ await testId("onboarding-form-submit-button").click();
589
+ await testId("onboarding-form-secondary-button").waitFor({
590
+ state: "visible",
591
+ timeout: 6e4
592
+ });
593
+ await testId("onboarding-form-submit-button").click();
594
+ await testId("onboarding-form-password-input").fill(password);
595
+ await testId("onboarding-form-confirm-password-input").fill(password);
596
+ await testId("onboarding-form-terms-of-service-checkbox").click();
597
+ await testId("onboarding-form-submit-button").click();
598
+ await sleep(2500);
599
+ const getStarted = page.locator("text=Get Started");
600
+ if (await getStarted.isVisible().catch(() => false)) await getStarted.click();
601
+ await sleep(1500);
602
+ },
603
+ onboardingPage: "onboarding.html",
604
+ prepareExtension: (cacheDir) => prepareWebStoreExtension({
605
+ cacheDir,
606
+ extensionId: PHANTOM_EXTENSION_ID,
607
+ name: "phantom-chrome-latest"
608
+ }),
609
+ reachUnlockScreen: reachUnlockScreen$3,
610
+ unlock: unlock$3,
611
+ approve: async (popup) => {
612
+ await popup.getByTestId("primary-button").click({ timeout: 15e3 });
613
+ },
614
+ reject: async (popup) => {
615
+ await popup.getByTestId("secondary-button").click({ timeout: 15e3 });
616
+ }
617
+ };
618
+
619
+ //#endregion
620
+ //#region src/wallets/rabby.ts
621
+ const ROUTE_TIMEOUT_MS = 3e4;
622
+ const SEED_PASTE_TIMEOUT_MS = 3e3;
623
+ const APPROVAL_TIMEOUT_MS = 3e4;
624
+ const RABBY_EXTENSION_ID = "acmacodkjbdgmoleebolmdjonilkdbch";
625
+ const ONBOARDING_ROUTE = "index.html#/new-user/guide";
626
+ const { reachUnlockScreen: reachUnlockScreen$2, unlock: unlock$2 } = createUnlockScreen({
627
+ entry: "index.html",
628
+ wallet: "Rabby"
629
+ });
630
+ const clickText = async (page, text, timeoutMs = 3e4) => {
631
+ const target = page.getByText(text, { exact: true }).first();
632
+ await target.waitFor({
633
+ state: "visible",
634
+ timeout: timeoutMs
635
+ });
636
+ await target.click();
637
+ };
638
+ /**
639
+ * Rabby routes with a hash router, so `waitForURL` never settles (a hash change fires no navigation
640
+ * event). Poll the URL instead. Anchoring each onboarding step on its route matters because several
641
+ * screens share the same `input[type="password"]` selector.
642
+ */
643
+ const waitForRoute = async (page, fragment, timeoutMs = ROUTE_TIMEOUT_MS) => {
644
+ await waitUntilOrThrow(() => page.url().includes(fragment), {
645
+ message: `Rabby never reached ${fragment}`,
646
+ timeoutMs
647
+ });
648
+ };
649
+ const importWallet$1 = async (page, seedPhrase, password) => {
650
+ await clickText(page, "I already have an address");
651
+ await clickText(page, "Seed Phrase or Private Key");
652
+ await waitForRoute(page, "seed-or-key");
653
+ const words = page.locator("input[type=\"password\"]");
654
+ await words.first().waitFor({
655
+ state: "visible",
656
+ timeout: 3e4
657
+ });
658
+ await page.context().grantPermissions(["clipboard-read", "clipboard-write"]).catch(() => {});
659
+ const list = seedPhrase.trim().split(/* @__PURE__ */ new RegExp("\\s+", "v"));
660
+ const lastFilled = async () => {
661
+ return (await words.nth(list.length - 1).inputValue().catch(() => "")).trim().length > 0;
662
+ };
663
+ await words.first().click();
664
+ await page.evaluate((phrase) => navigator.clipboard.writeText(phrase), seedPhrase.trim());
665
+ await page.keyboard.press("ControlOrMeta+v");
666
+ if (await waitUntil(lastFilled, { timeoutMs: SEED_PASTE_TIMEOUT_MS }) === void 0) for (let index = 0; index < list.length; index++) {
667
+ const box = words.nth(index);
668
+ await box.click();
669
+ await box.fill(list[index] ?? "");
670
+ }
671
+ await page.getByRole("button", {
672
+ exact: true,
673
+ name: "Next"
674
+ }).first().click();
675
+ await waitForRoute(page, "set-password");
676
+ const passwords = page.locator("input[type=\"password\"]");
677
+ await passwords.nth(1).waitFor({
678
+ state: "visible",
679
+ timeout: 3e4
680
+ });
681
+ await passwords.nth(0).fill(password);
682
+ await passwords.nth(1).fill(password);
683
+ await page.getByRole("button", {
684
+ exact: true,
685
+ name: "Confirm"
686
+ }).first().click();
687
+ await page.getByText("Address Imported", { exact: false }).first().waitFor({
688
+ state: "visible",
689
+ timeout: 3e4
690
+ });
691
+ await sleep(1e3);
692
+ };
693
+ const CONFIRM_LABELS = [
694
+ "Connect",
695
+ "Sign",
696
+ "Confirm"
697
+ ];
698
+ 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
+ */
711
+ const clickApprovalButton = async (popup, labels) => {
712
+ let lastClicked = "";
713
+ return await waitUntil(async () => {
714
+ if (popup.isClosed()) return true;
715
+ const clicked = await popup.evaluate((arg) => {
716
+ const target = [...document.querySelectorAll("button")].find((button) => {
717
+ const text = (button.textContent ?? "").trim();
718
+ return arg.names.includes(text) && !button.disabled && text !== arg.skip;
719
+ });
720
+ target?.click();
721
+ return target ? (target.textContent ?? "").trim() : "";
722
+ }, {
723
+ names: [...labels],
724
+ skip: lastClicked
725
+ }).catch(() => "");
726
+ if (clicked) lastClicked = clicked;
727
+ }, {
728
+ intervalMs: 300,
729
+ timeoutMs: APPROVAL_TIMEOUT_MS
730
+ }) === true || popup.isClosed();
731
+ };
732
+ const rabby = {
733
+ approve: async (popup) => {
734
+ if (!await clickApprovalButton(popup, CONFIRM_LABELS)) throw new Error("[walletwright] Rabby approval: no confirm button became actionable");
735
+ },
736
+ ecosystems: ["evm"],
737
+ extensionName: "Rabby",
738
+ headlessApprovals: true,
739
+ importWallet: importWallet$1,
740
+ onboardingPage: ONBOARDING_ROUTE,
741
+ prepareExtension: (cacheDir) => prepareWebStoreExtension({
742
+ cacheDir,
743
+ extensionId: RABBY_EXTENSION_ID,
744
+ name: "rabby-chrome-latest"
745
+ }),
746
+ reachUnlockScreen: reachUnlockScreen$2,
747
+ reject: async (popup) => {
748
+ if (!await clickApprovalButton(popup, CANCEL_LABELS)) throw new Error("[walletwright] Rabby reject: no cancel button became actionable");
749
+ },
750
+ unlock: unlock$2
751
+ };
752
+
753
+ //#endregion
754
+ //#region src/wallets/slush.ts
755
+ const SLUSH_EXTENSION_ID = "opcgpfmipidbgpenhmajoajpbobppdil";
756
+ const HOME_ROUTE = "#/tokens";
757
+ const IMPORT_HOME_TIMEOUT_MS = 3e4;
758
+ const atHome = async (page) => await page.evaluate(() => globalThis.location.hash).catch(() => "") === HOME_ROUTE;
759
+ const fclick = async (page, text, timeoutMs = 15e3) => {
760
+ const target = page.getByText(text, { exact: true }).first();
761
+ if (!await target.waitFor({
762
+ state: "visible",
763
+ timeout: timeoutMs
764
+ }).then(() => true).catch(() => false)) return false;
765
+ await target.scrollIntoViewIfNeeded().catch(() => {});
766
+ await target.click({
767
+ force: true,
768
+ timeout: timeoutMs
769
+ });
770
+ return true;
771
+ };
772
+ const { reachUnlockScreen: reachUnlockScreen$1, unlock: unlock$1 } = createUnlockScreen({
773
+ entry: "index.html",
774
+ isUnlocked: atHome,
775
+ submit: async (page) => {
776
+ await fclick(page, "Unlock");
777
+ },
778
+ wallet: "Slush"
779
+ });
780
+ const slush = {
781
+ approve: async (popup, password) => {
782
+ await sleep(2e3);
783
+ if (!(await fclick(popup, "Approve", 4e3) || await fclick(popup, "Sign"))) throw new Error("[walletwright] Slush approval: neither Approve nor Sign was actionable");
784
+ await sleep(1500);
785
+ const input = popup.locator("input[type=\"password\"]");
786
+ if (await input.isVisible().catch(() => false)) {
787
+ await input.fill(password);
788
+ await fclick(popup, "Unlock");
789
+ }
790
+ },
791
+ ecosystems: ["sui"],
792
+ extensionName: "Slush",
793
+ importWallet: async (page, seedPhrase, password) => {
794
+ await fclick(page, "More options");
795
+ await sleep(1e3);
796
+ await fclick(page, "Import existing from passphrase");
797
+ await sleep(1500);
798
+ const words = seedPhrase.trim().split(/* @__PURE__ */ new RegExp("\\s+", "v"));
799
+ for (let i = 0; i < words.length; i++) await page.locator(`input[placeholder="Word ${i + 1}"]`).fill(words[i] ?? "");
800
+ await page.getByRole("button", { name: "Next" }).click();
801
+ await sleep(2e3);
802
+ await page.locator("input[placeholder=\"Password\"]").fill(password);
803
+ await page.locator("input[placeholder=\"Confirm Password\"]").fill(password);
804
+ await page.getByRole("button", { name: "Next" }).click();
805
+ await sleep(2e3);
806
+ await page.getByRole("button", { name: "Next" }).click().catch(() => {});
807
+ await waitUntilOrThrow(() => atHome(page), {
808
+ intervalMs: 1e3,
809
+ message: `Slush import never reached the wallet home (${HOME_ROUTE})`,
810
+ timeoutMs: IMPORT_HOME_TIMEOUT_MS
811
+ });
812
+ },
813
+ notificationMatch: "isPopup=1",
814
+ onboardingPage: "index.html",
815
+ prepareContext: async (context) => {
816
+ await context.route("**://*.slush.app/**", (route) => route.fulfill({
817
+ body: JSON.stringify({ data: {} }),
818
+ contentType: "application/json",
819
+ status: 200
820
+ }));
821
+ },
822
+ prepareExtension: (cacheDir) => prepareWebStoreExtension({
823
+ cacheDir,
824
+ extensionId: SLUSH_EXTENSION_ID,
825
+ name: "slush-chrome-latest"
826
+ }),
827
+ reachUnlockScreen: reachUnlockScreen$1,
828
+ reject: async (popup) => {
829
+ await sleep(2e3);
830
+ if (!await fclick(popup, "Reject")) throw new Error("[walletwright] Slush reject: no Reject control was actionable");
831
+ },
832
+ unlock: unlock$1
833
+ };
834
+
835
+ //#endregion
836
+ //#region src/wallets/solflare.ts
837
+ const SOLFLARE_EXTENSION_ID = "bhhhlbepdkbapadjdnnojkbgioiodbic";
838
+ const { reachUnlockScreen, unlock } = createUnlockScreen({
839
+ entry: "wallet.html",
840
+ wallet: "Solflare"
841
+ });
842
+ const importWallet = async (page, seedPhrase, password) => {
843
+ await page.getByTestId("btn-import-existing-wallet").click({ timeout: 3e4 });
844
+ await page.getByText("Recovery phrase", { exact: true }).first().click({ timeout: 3e4 });
845
+ const words = seedPhrase.trim().split(/* @__PURE__ */ new RegExp("\\s+", "v"));
846
+ await page.getByTestId("input-recovery-phrase-1").waitFor({
847
+ state: "visible",
848
+ timeout: 3e4
849
+ });
850
+ for (let index = 0; index < words.length; index++) await page.getByTestId(`input-recovery-phrase-${index + 1}`).fill(words[index] ?? "");
851
+ await page.getByTestId("btn-continue").click({ timeout: 3e4 });
852
+ await page.getByTestId("input-new-password").waitFor({
853
+ state: "visible",
854
+ timeout: 3e4
855
+ });
856
+ await page.getByTestId("input-new-password").fill(password);
857
+ await page.getByTestId("input-repeat-password").fill(password);
858
+ await page.getByTestId("btn-continue").click({ timeout: 3e4 });
859
+ await page.getByTestId("btn-quick-setup").click({ timeout: 6e4 });
860
+ await page.getByTestId("btn-explore").click({ timeout: 6e4 });
861
+ };
862
+ const solflare = {
863
+ approve: async (popup) => {
864
+ await popup.getByTestId("btn-connect").or(popup.getByTestId("btn-approve")).first().click({ timeout: 3e4 });
865
+ },
866
+ ecosystems: ["svm"],
867
+ extensionName: "Solflare",
868
+ importWallet,
869
+ notificationMatch: "confirm_popup.html",
870
+ onboardingPage: "wallet.html",
871
+ prepareExtension: (cacheDir) => prepareWebStoreExtension({
872
+ cacheDir,
873
+ extensionId: SOLFLARE_EXTENSION_ID,
874
+ name: "solflare-chrome-latest"
875
+ }),
876
+ reachUnlockScreen,
877
+ reject: async (popup) => {
878
+ await popup.getByTestId("btn-cancel").or(popup.getByTestId("btn-reject")).first().click({ timeout: 3e4 });
879
+ },
880
+ unlock
881
+ };
882
+
883
+ //#endregion
884
+ //#region src/wallets/index.ts
885
+ const wallets = {
886
+ metamask,
887
+ phantom,
888
+ rabby,
889
+ slush,
890
+ solflare
891
+ };
892
+ /** Narrow an arbitrary string (CLI flag, setup file field) to a supported wallet. */
893
+ const isWalletKind = (value) => Object.hasOwn(wallets, value);
894
+ const walletKinds = Object.keys(wallets).filter(isWalletKind);
895
+ /** Wallet kinds that can drive the given ecosystem (e.g. `"evm"` → MetaMask, Phantom). */
896
+ const walletKindsByEcosystem = (ecosystem) => walletKinds.filter((kind) => wallets[kind].ecosystems.includes(ecosystem));
897
+
898
+ //#endregion
899
+ //#region src/internal/chromium.ts
900
+ /**
901
+ * How walletwright launches Chromium with a wallet extension loaded. Shared by the cache build and
902
+ * the test run so the two cannot drift: an extension that loads for onboarding but not for the
903
+ * tests (or the reverse) fails far from its cause.
904
+ */
905
+ const extensionContextOptions = (extensionPath, headless) => ({
906
+ args: [`--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`],
907
+ channel: "chromium",
908
+ headless
909
+ });
910
+
911
+ //#endregion
912
+ //#region src/internal/cache.ts
913
+ const NAVIGATION_TIMEOUT_MS = 15e3;
914
+ const FLUSH_SETTLE_MS = 3e3;
915
+ 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
+ */
923
+ const hasPersistedState = async (profileDir, extensionId) => {
924
+ const stores = [extensionStateDir(profileDir, extensionId), path.join(profileDir, "Default", "IndexedDB", `chrome-extension_${extensionId}_0.indexeddb.leveldb`)];
925
+ return (await Promise.allSettled(stores.map((dir) => readdir(dir)))).some((listing) => listing.status === "fulfilled" && listing.value.length > 0);
926
+ };
927
+ /**
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`.
933
+ */
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, {
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));
945
+ try {
946
+ await definition.prepareContext?.(context);
947
+ const extensionId = extensionIdFromPath(extensionPath);
948
+ const page = context.pages().find((candidate) => candidate.url() === "about:blank") ?? await context.newPage();
949
+ await gotoWithRetry(page, `chrome-extension://${extensionId}/${definition.onboardingPage}`, {
950
+ label: `${definition.extensionName} onboarding page`,
951
+ timeoutMs: NAVIGATION_TIMEOUT_MS
952
+ });
953
+ await sleep(2e3);
954
+ await definition.importWallet(page, setup.seedPhrase, setup.password);
955
+ await sleep(FLUSH_SETTLE_MS);
956
+ await waitUntilOrThrow(() => hasPersistedState(profileDir, extensionId), {
957
+ intervalMs: 500,
958
+ message: `${definition.extensionName} onboarding wrote no wallet state into ${profileDir}`,
959
+ timeoutMs: STATE_WRITE_TIMEOUT_MS
960
+ });
961
+ await context.close();
962
+ await definition.finalizeCache?.(profileDir, extensionId);
963
+ return profileDir;
964
+ } catch (error) {
965
+ await context.close().catch(() => {});
966
+ throw error;
967
+ }
968
+ };
969
+
970
+ //#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-Bqz3Mchl.mjs.map