@walletwright/core 0.4.0 → 0.5.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.
@@ -1,89 +1,11 @@
1
1
  import { access, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { chromium } from "@playwright/test";
4
- import { createHash } from "node:crypto";
5
- import AdmZip from "adm-zip";
6
3
  import { ClassicLevel } from "classic-level";
7
4
  import { z } from "zod";
5
+ import { createHash } from "node:crypto";
6
+ import { chromium } from "@playwright/test";
7
+ import AdmZip from "adm-zip";
8
8
 
9
- //#region src/internal/download.ts
10
- const ZIP_SIGNATURE = Buffer.from([
11
- 80,
12
- 75,
13
- 3,
14
- 4
15
- ]);
16
- /**
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
- */
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. */
32
- const downloadAndExtractExtension = async (options) => {
33
- const { cacheDir, kind, name, sha256, url } = options;
34
- const cacheRoot = path.resolve(cacheDir);
35
- const outDir = path.resolve(cacheDir, name);
36
- if (outDir === cacheRoot || !outDir.startsWith(cacheRoot + path.sep)) throw new Error(`[walletwright] invalid extension name: ${name}`);
37
- if (await pathExists$1(path.join(outDir, "manifest.json"))) return outDir;
38
- await mkdir(cacheDir, { recursive: true });
39
- const response = await fetch(url, { redirect: "follow" });
40
- if (!response.ok) throw new Error(`[walletwright] failed to download ${url}: ${response.status} ${response.statusText}`);
41
- const bytes = Buffer.from(await response.arrayBuffer());
42
- if (sha256 !== void 0) {
43
- const actual = createHash("sha256").update(bytes).digest("hex");
44
- if (actual !== sha256.toLowerCase()) throw new Error(`[walletwright] ${name} failed integrity check: expected ${sha256}, got ${actual}`);
45
- }
46
- let zipBytes = bytes;
47
- if (kind === "crx") {
48
- const start = bytes.indexOf(ZIP_SIGNATURE);
49
- if (start === -1) throw new Error(`[walletwright] ${url} is not a valid CRX (no ZIP header found)`);
50
- zipBytes = bytes.subarray(start);
51
- }
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(() => {});
73
- }
74
- };
75
- /** Build the Chrome Web Store CRX download URL for an extension id. */
76
- const chromeWebStoreCrxUrl = (extensionId) => `https://clients2.google.com/service/update2/crx?response=redirect&prodversion=130.0&acceptformat=crx2,crx3&x=id%3D${extensionId}%26uc`;
77
- /** Download and extract a Chrome Web Store extension (latest) into `<cacheDir>/<name>`. */
78
- const prepareWebStoreExtension = (options) => downloadAndExtractExtension({
79
- cacheDir: options.cacheDir,
80
- kind: "crx",
81
- name: options.name,
82
- sha256: void 0,
83
- url: chromeWebStoreCrxUrl(options.extensionId)
84
- });
85
-
86
- //#endregion
87
9
  //#region src/wallets/metamask/actions/accounts.ts
88
10
  const openAccountMenu = async (home) => {
89
11
  await home.getByTestId("account-menu-icon").click();
@@ -169,6 +91,22 @@ const add = async ({ home }, config) => {
169
91
  };
170
92
  const network = { add };
171
93
 
94
+ //#endregion
95
+ //#region src/wallets/metamask/actions/settings.ts
96
+ const settings = {
97
+ lock: async ({ home }) => {
98
+ await home.getByTestId("account-options-menu-button").click();
99
+ await home.getByTestId("global-menu-lock").filter({ visible: true }).first().click();
100
+ await home.locator("input[type=\"password\"]").waitFor({
101
+ state: "visible",
102
+ timeout: 15e3
103
+ });
104
+ },
105
+ unlock: async ({ unlock }) => {
106
+ await unlock();
107
+ }
108
+ };
109
+
172
110
  //#endregion
173
111
  //#region src/internal/wait.ts
174
112
  const DEFAULT_INTERVAL_MS = 200;
@@ -206,74 +144,65 @@ const gotoWithRetry = async (page, url, { intervalMs = DEFAULT_NAVIGATION_INTERV
206
144
  };
207
145
 
208
146
  //#endregion
209
- //#region src/internal/unlock-screen.ts
210
- const PASSWORD_FIELD = "input[type=\"password\"]";
211
- const NAVIGATION_TIMEOUT_MS$1 = 15e3;
212
- const FIRST_SETTLE_TIMEOUT_MS = 2e4;
213
- const RELOAD_ATTEMPTS = 5;
214
- const RELOAD_SETTLE_TIMEOUT_MS = 5e3;
215
- const PROBE_INTERVAL_MS = 500;
216
- const PASSWORD_CLEARED_TIMEOUT_MS = 15e3;
147
+ //#region src/wallets/metamask/approve.ts
217
148
  /**
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.
149
+ * How long a confirm or cancel button gets to become clickable, once the page is already showing
150
+ * one. Generous because a loaded CI runner renders MetaMask's footer slowly.
221
151
  */
222
- const UNLOCK_SETTLE_TIMEOUT_MS = 5e3;
152
+ const BUTTON_TIMEOUT_MS = 45e3;
153
+ /** The retry only covers a notice that rendered late, so it does not need the full budget again. */
154
+ const RETRY_TIMEOUT_MS = 1e4;
155
+ /** Snap notices intercept footer clicks and disable Accept until their content is scrolled. */
156
+ const acceptThirdPartyNotice = async (popup) => {
157
+ const modal = popup.locator(".snap-install-warning");
158
+ if (await modal.count().catch(() => 0) === 0) return;
159
+ await modal.locator("p").last().scrollIntoViewIfNeeded().catch(() => {});
160
+ await modal.getByRole("button", { name: "Accept" }).click({ timeout: 3e3 }).catch(() => {});
161
+ };
162
+ /** Resolve the confirm test id used by standard, legacy, and Snap request footers. */
163
+ 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();
164
+ 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();
165
+ /** Distinguishes a pending request from MetaMask's requestless notification home screen. */
166
+ const approvalControls = (popup) => confirmButton(popup).or(cancelButton(popup));
223
167
  /**
224
- * The shared unlock flow: open the wallet's own page, wait for it to settle into a state we can
225
- * name, and type the password. Every wallet differs only in its entry page, how it submits, and
226
- * whether it can come back already unlocked.
168
+ * Click one of the popup's footer buttons. The third-party notice covers that whole footer, so both
169
+ * confirm and cancel are intercepted while it is up, and both need the dismiss-then-retry: without it
170
+ * a click times out against a button that is rendered and enabled, which reads as a stale selector.
227
171
  */
228
- const createUnlockScreen = ({ entry, isUnlocked, submit, wallet }) => {
229
- const settled = async (page) => {
230
- if (await page.locator(PASSWORD_FIELD).isVisible().catch(() => false)) return "locked";
231
- return await isUnlocked?.(page).catch(() => false) === true ? "unlocked" : void 0;
232
- };
233
- const settleWithin = (page, timeoutMs) => waitUntil(() => settled(page), {
234
- intervalMs: PROBE_INTERVAL_MS,
235
- timeoutMs
236
- });
237
- const reachUnlockScreen = async (context, extensionId) => {
238
- const page = await context.newPage();
239
- await gotoWithRetry(page, `chrome-extension://${extensionId}/${entry}`, {
240
- label: `${wallet} ${entry}`,
241
- timeoutMs: NAVIGATION_TIMEOUT_MS$1
172
+ const clickFooterButton = async (popup, button) => {
173
+ await acceptThirdPartyNotice(popup);
174
+ try {
175
+ await button.click({ timeout: BUTTON_TIMEOUT_MS });
176
+ } catch (error) {
177
+ await acceptThirdPartyNotice(popup);
178
+ await button.click({ timeout: RETRY_TIMEOUT_MS }).catch(() => {
179
+ throw error;
242
180
  });
243
- let state = await settleWithin(page, FIRST_SETTLE_TIMEOUT_MS);
244
- for (let attempt = 0; attempt < RELOAD_ATTEMPTS && state === void 0; attempt++) {
245
- await page.reload().catch(() => {});
246
- state = await settleWithin(page, RELOAD_SETTLE_TIMEOUT_MS);
247
- }
248
- if (state === void 0) throw new Error(`[walletwright] ${wallet} never reached its unlock screen (gave up after ${formatTimeout(45e3)})`);
249
- return page;
250
- };
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`);
255
- const field = page.locator(PASSWORD_FIELD);
256
- await field.fill(password);
257
- await (submit === void 0 ? field.press("Enter") : submit(page, field));
258
- if (!await field.waitFor({
259
- state: "hidden",
260
- timeout: PASSWORD_CLEARED_TIMEOUT_MS
261
- }).then(() => true).catch(() => false)) throw new Error(`[walletwright] ${wallet} unlock failed (password screen still visible after ${formatTimeout(PASSWORD_CLEARED_TIMEOUT_MS)})`);
262
- };
263
- return {
264
- reachUnlockScreen,
265
- unlock
266
- };
181
+ }
182
+ };
183
+ /** How long a connect page may keep Connect disabled before it counts as rendered too early. */
184
+ const STALE_CONNECT_MS = 3e3;
185
+ /**
186
+ * MetaMask can render a connect page before enabling the requested network (seen with Solana): no
187
+ * networks under Permissions, Connect disabled, never recomputed. The request is still pending, so
188
+ * a reload renders it from current state. Connect route only: elsewhere a disabled confirm is busy.
189
+ */
190
+ const reloadStaleConnectPage = async (popup) => {
191
+ if (!popup.url().includes("#/connect/")) return;
192
+ const connect = popup.getByTestId("confirm-btn");
193
+ if (await waitUntil(() => connect.isEnabled({ timeout: 500 }).catch(() => false), { timeoutMs: STALE_CONNECT_MS }) === void 0) await popup.reload();
194
+ };
195
+ const approve = async (popup) => {
196
+ await reloadStaleConnectPage(popup);
197
+ await clickFooterButton(popup, confirmButton(popup));
267
198
  };
199
+ /** The cancel counterpart of `approve`; same union-by-DOM-order resolution, not written order. */
200
+ const reject = (popup) => clickFooterButton(popup, cancelButton(popup));
268
201
 
269
202
  //#endregion
270
203
  //#region src/wallets/metamask/onboarding.ts
271
204
  const SRP_RETYPE_TIMEOUT_MS = 4e4;
272
205
  const SRP_CONFIRM_TIMEOUT_MS = 5e3;
273
- const { reachUnlockScreen: reachUnlockScreen$4, unlock: unlock$4 } = createUnlockScreen({
274
- entry: "home.html",
275
- wallet: "MetaMask"
276
- });
277
206
  const importWallet$2 = async (page, seedPhrase, password) => {
278
207
  await page.getByTestId("onboarding-import-wallet").click();
279
208
  await page.getByTestId("onboarding-import-with-srp-button").click();
@@ -301,135 +230,82 @@ const importWallet$2 = async (page, seedPhrase, password) => {
301
230
  };
302
231
 
303
232
  //#endregion
304
- //#region src/wallets/metamask/actions/settings.ts
305
- const settings = {
306
- lock: async ({ home }) => {
307
- await home.getByTestId("account-options-menu-button").click();
308
- await home.getByTestId("global-menu-lock").filter({ visible: true }).first().click();
309
- await home.locator("input[type=\"password\"]").waitFor({
310
- state: "visible",
311
- timeout: 15e3
312
- });
313
- },
314
- unlock: async ({ home, password }) => {
315
- await unlock$4(home, password);
316
- }
317
- };
318
-
319
- //#endregion
320
- //#region src/wallets/metamask/approve.ts
321
- /**
322
- * How long a confirm or cancel button gets to become clickable, once the page is already showing
323
- * one. Generous because a loaded CI runner renders MetaMask's footer slowly.
324
- */
325
- const BUTTON_TIMEOUT_MS = 45e3;
326
- /** The retry only covers a notice that rendered late, so it does not need the full budget again. */
327
- const RETRY_TIMEOUT_MS = 1e4;
328
- /** Snap notices intercept footer clicks and disable Accept until their content is scrolled. */
329
- const acceptThirdPartyNotice = async (popup) => {
330
- const modal = popup.locator(".snap-install-warning");
331
- if (await modal.count().catch(() => 0) === 0) return;
332
- await modal.locator("p").last().scrollIntoViewIfNeeded().catch(() => {});
333
- await modal.getByRole("button", { name: "Accept" }).click({ timeout: 3e3 }).catch(() => {});
334
- };
335
- /** Resolve the confirm test id used by standard, legacy, and Snap request footers. */
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();
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));
340
- /**
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.
344
- */
345
- const clickFooterButton = async (popup, button) => {
346
- await acceptThirdPartyNotice(popup);
233
+ //#region src/internal/fs.ts
234
+ /** Run `operation`, answering `fallback` instead of throwing when its target does not exist. */
235
+ const unlessMissing = async (operation, fallback) => {
347
236
  try {
348
- await button.click({ timeout: BUTTON_TIMEOUT_MS });
237
+ return await operation();
349
238
  } catch (error) {
350
- await acceptThirdPartyNotice(popup);
351
- await button.click({ timeout: RETRY_TIMEOUT_MS }).catch(() => {
352
- throw error;
353
- });
239
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return fallback;
240
+ throw error;
354
241
  }
355
242
  };
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));
243
+ const pathExists = (target) => unlessMissing(async () => {
244
+ await stat(target);
245
+ return true;
246
+ }, false);
247
+ /** `rename`, reporting whether `from` existed to be moved rather than throwing when it did not. */
248
+ const renameIfExists = (from, to) => unlessMissing(async () => {
249
+ await rename(from, to);
250
+ return true;
251
+ }, false);
359
252
 
360
253
  //#endregion
361
- //#region src/internal/utils.ts
254
+ //#region src/internal/profile.ts
362
255
  const DEFAULT_CACHE_DIR = ".walletwright";
363
- /** MetaMask/Phantom approval-popup URL token; Slush overrides it with `notificationMatch: "isPopup=1"`. */
364
- const DEFAULT_NOTIFICATION_MATCH = "notification.html";
256
+ const PREVIOUS_SUFFIX = ".previous";
365
257
  /** Stable per-setup profile directory name, so the same wallet+seed+password reuses one cache. */
366
258
  const profileKey = (setup) => createHash("sha256").update(`${setup.wallet}:${setup.version ?? "default"}:${setup.seedPhrase}:${setup.password}`).digest("hex").slice(0, 20);
367
- /** Mirror Chrome's extension-id derivation; resolve symlinks because Chrome hashes the real path. */
368
- const extensionIdFromPath = async (extensionPath) => {
369
- const resolved = path.resolve(extensionPath);
370
- let abs = resolved;
371
- try {
372
- abs = await realpath(resolved);
373
- } catch {
374
- abs = resolved;
375
- }
376
- let key;
377
- try {
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;
259
+ /** Where Chrome persists an extension's `chrome.storage.local` inside a browser profile. */
260
+ const extensionStateDir = (profileDir, extensionId) => path.join(profileDir, "Default", "Local Extension Settings", extensionId);
261
+ const previousProfileDir = (profileDir) => `${profileDir}${PREVIOUS_SUFFIX}`;
262
+ /** Put back a profile that an interrupted publication left at its recovery path. */
263
+ const restorePreviousProfile = async (profileDir) => {
264
+ const previous = previousProfileDir(profileDir);
265
+ if (await pathExists(profileDir)) {
266
+ await rm(previous, {
267
+ force: true,
268
+ recursive: true
269
+ });
270
+ return;
382
271
  }
383
- const source = key === void 0 || key === "" ? Buffer.from(abs, "utf8") : Buffer.from(key, "base64");
384
- const hex = createHash("sha256").update(source).digest("hex").slice(0, 32);
385
- return Array.from(hex, (nibble) => String.fromCodePoint(97 + Number.parseInt(nibble, 16))).join("");
272
+ await renameIfExists(previous, profileDir);
386
273
  };
387
274
  /**
388
- * Approval popups open as `about:blank` and then navigate, so `waitForEvent('page', { predicate })`
389
- * misses them, poll the open pages instead. `match` is the URL token identifying the popup
390
- * (`notification.html` for MetaMask/Phantom; `isPopup=1` for Slush's single-page `index.html`).
275
+ * The cache directory and this setup's profile inside it, with any interrupted publication already
276
+ * recovered, so building and launching both start from the same answer.
391
277
  */
392
- const isApprovalPopup = (page, extensionId, match) => page.url().startsWith(`chrome-extension://${extensionId}`) && page.url().includes(match) && !page.isClosed();
393
- const isVisible = async (locator) => {
394
- try {
395
- return await locator.isVisible();
396
- } catch {
397
- return false;
398
- }
399
- };
400
- const hasVisibleButton = (page) => isVisible(page.locator("button").first());
401
- const findNotificationPopup = ({ approvalControls, context, extensionId, match = DEFAULT_NOTIFICATION_MATCH, timeoutMs = 1e4 }) => waitUntil(async () => {
402
- const popup = context.pages().find((page) => isApprovalPopup(page, extensionId, match));
403
- if (!popup) return;
404
- await popup.waitForLoadState("domcontentloaded").catch(() => {});
405
- return (approvalControls === void 0 ? await hasVisibleButton(popup) : await isVisible(approvalControls(popup).first())) ? popup : void 0;
406
- }, { timeoutMs });
407
- /** MetaMask's own popup dimensions, which its layout is built for. */
408
- const APPROVAL_WINDOW = {
409
- height: 592,
410
- width: 360
278
+ const locateProfile = async (setup) => {
279
+ const cacheDir = path.resolve(setup.cacheDir ?? DEFAULT_CACHE_DIR);
280
+ const profileDir = path.join(cacheDir, profileKey(setup));
281
+ await restorePreviousProfile(profileDir);
282
+ return {
283
+ cacheDir,
284
+ profileDir
285
+ };
411
286
  };
412
- /** Far enough from the edge that a popup this size lands fully on any usable display. */
413
- const APPROVAL_WINDOW_OFFSET = 50;
414
- /** Move approval popups on-screen; headless and unsupported CDP targets are ignored. */
415
- const placeApprovalWindow = async (page) => {
416
- await page.setViewportSize(APPROVAL_WINDOW).catch(() => {});
287
+ /** Swap `staging` in as the profile, keeping the prior one at its recovery path until it lands. */
288
+ const publishProfile = async (staging, profileDir) => {
289
+ await restorePreviousProfile(profileDir);
290
+ const previous = previousProfileDir(profileDir);
291
+ const movedPrevious = await renameIfExists(profileDir, previous);
417
292
  try {
418
- const session = await page.context().newCDPSession(page);
419
- const { targetInfo } = await session.send("Target.getTargetInfo");
420
- const { windowId } = await session.send("Browser.getWindowForTarget", { targetId: targetInfo.targetId });
421
- await session.send("Browser.setWindowBounds", {
422
- bounds: {
423
- left: APPROVAL_WINDOW_OFFSET,
424
- top: APPROVAL_WINDOW_OFFSET
425
- },
426
- windowId
427
- });
428
- await session.detach();
429
- } catch {}
293
+ await rename(staging, profileDir);
294
+ } catch (error) {
295
+ if (movedPrevious) try {
296
+ await rename(previous, profileDir);
297
+ } catch (rollbackError) {
298
+ const publishMessage = error instanceof Error ? error.message : String(error);
299
+ const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
300
+ throw new Error(`[walletwright] failed to publish cache at ${profileDir}: ${publishMessage}; rollback failed: ${rollbackMessage}`, { cause: rollbackError });
301
+ }
302
+ throw error;
303
+ }
304
+ if (movedPrevious) await rm(previous, {
305
+ force: true,
306
+ recursive: true
307
+ });
430
308
  };
431
- /** Where Chrome persists an extension's `chrome.storage.local` inside a browser profile. */
432
- const extensionStateDir = (profileDir, extensionId) => path.join(profileDir, "Default", "Local Extension Settings", extensionId);
433
309
 
434
310
  //#endregion
435
311
  //#region src/wallets/metamask/onboarding-patch.ts
@@ -500,10 +376,9 @@ const RELEASE_SHA256 = { "13.35.1": "4e0f8626df0ae9fb15f5f3ad6784a0b518f3ede067b
500
376
  */
501
377
  const DEFAULT_VERSION = "13.35.1";
502
378
  const releaseSha256 = (version) => Object.entries(RELEASE_SHA256).find(([pinned]) => pinned === version)?.[1];
503
- /** Download inputs for a MetaMask release, split out so the integrity wiring is unit-testable. */
504
- const metamaskDownload = (cacheDir, version) => ({
505
- cacheDir,
506
- kind: "zip",
379
+ /** The archive for a MetaMask release, split out so the integrity wiring is unit-testable. */
380
+ const metamaskRelease = (version) => ({
381
+ format: "zip",
507
382
  name: `metamask-chrome-${version}`,
508
383
  sha256: releaseSha256(version),
509
384
  url: `https://github.com/MetaMask/metamask-extension/releases/download/v${version}/metamask-chrome-${version}.zip`
@@ -526,26 +401,17 @@ const metamask = {
526
401
  prepareContext: async (context) => {
527
402
  await context.route(`**://${ACCOUNT_SYNC_HOST}/**`, (route) => route.abort());
528
403
  },
529
- prepareExtension: (cacheDir, version = DEFAULT_VERSION) => downloadAndExtractExtension(metamaskDownload(cacheDir, version)),
530
- reachUnlockScreen: reachUnlockScreen$4,
531
404
  reject,
532
- unlock: unlock$4
405
+ source: {
406
+ defaultVersion: DEFAULT_VERSION,
407
+ kind: "release",
408
+ release: metamaskRelease
409
+ },
410
+ unlockScreen: { entry: "home.html" }
533
411
  };
534
412
 
535
413
  //#endregion
536
414
  //#region src/wallets/phantom.ts
537
- const PHANTOM_EXTENSION_ID = "bfnaelmomeimhlpmgjnjophhpkkoljpa";
538
- const { reachUnlockScreen: reachUnlockScreen$3, unlock: unlock$3 } = createUnlockScreen({
539
- entry: "popup.html",
540
- isUnlocked: async (page) => {
541
- return await page.locator("button").first().isVisible().catch(() => false);
542
- },
543
- submit: async (page, field) => {
544
- const unlockButton = page.getByRole("button", { name: new RegExp("unlock", "iv") });
545
- await (await unlockButton.isVisible().catch(() => false) ? unlockButton.click() : field.press("Enter"));
546
- },
547
- wallet: "Phantom"
548
- });
549
415
  const phantom = {
550
416
  approve: async (popup) => {
551
417
  await popup.getByTestId("primary-button").click({ timeout: 15e3 });
@@ -577,16 +443,23 @@ const phantom = {
577
443
  await sleep(1500);
578
444
  },
579
445
  onboardingPage: "onboarding.html",
580
- prepareExtension: (cacheDir) => prepareWebStoreExtension({
581
- cacheDir,
582
- extensionId: PHANTOM_EXTENSION_ID,
583
- name: "phantom-chrome-latest"
584
- }),
585
- reachUnlockScreen: reachUnlockScreen$3,
586
446
  reject: async (popup) => {
587
447
  await popup.getByTestId("secondary-button").click({ timeout: 15e3 });
588
448
  },
589
- unlock: unlock$3
449
+ source: {
450
+ id: "bfnaelmomeimhlpmgjnjophhpkkoljpa",
451
+ kind: "webStore"
452
+ },
453
+ unlockScreen: {
454
+ entry: "popup.html",
455
+ isUnlocked: async (page) => {
456
+ return await page.locator("button").first().isVisible().catch(() => false);
457
+ },
458
+ submit: async (page, field) => {
459
+ const unlockButton = page.getByRole("button", { name: new RegExp("unlock", "iv") });
460
+ await (await unlockButton.isVisible().catch(() => false) ? unlockButton.click() : field.press("Enter"));
461
+ }
462
+ }
590
463
  };
591
464
 
592
465
  //#endregion
@@ -594,12 +467,7 @@ const phantom = {
594
467
  const ROUTE_TIMEOUT_MS = 3e4;
595
468
  const SEED_PASTE_TIMEOUT_MS = 3e3;
596
469
  const APPROVAL_TIMEOUT_MS = 3e4;
597
- const RABBY_EXTENSION_ID = "acmacodkjbdgmoleebolmdjonilkdbch";
598
470
  const ONBOARDING_ROUTE = "index.html#/new-user/guide";
599
- const { reachUnlockScreen: reachUnlockScreen$2, unlock: unlock$2 } = createUnlockScreen({
600
- entry: "index.html",
601
- wallet: "Rabby"
602
- });
603
471
  const clickText = async (page, text, timeoutMs = 3e4) => {
604
472
  const target = page.getByText(text, { exact: true }).first();
605
473
  await target.waitFor({
@@ -676,11 +544,11 @@ const clickApprovalButton = async (popup, labels) => {
676
544
  if (popup.isClosed()) return true;
677
545
  const clicked = await popup.evaluate((arg) => {
678
546
  const target = [...document.querySelectorAll("button")].find((button) => {
679
- const text = (button.textContent ?? "").trim();
547
+ const text = button.textContent.trim();
680
548
  return arg.names.includes(text) && !button.disabled && !arg.skip.includes(text);
681
549
  });
682
550
  target?.click();
683
- return target ? (target.textContent ?? "").trim() : "";
551
+ return target ? target.textContent.trim() : "";
684
552
  }, {
685
553
  names: [...labels],
686
554
  skip: [...clickedLabels]
@@ -700,21 +568,18 @@ const rabby = {
700
568
  headlessApprovals: true,
701
569
  importWallet: importWallet$1,
702
570
  onboardingPage: ONBOARDING_ROUTE,
703
- prepareExtension: (cacheDir) => prepareWebStoreExtension({
704
- cacheDir,
705
- extensionId: RABBY_EXTENSION_ID,
706
- name: "rabby-chrome-latest"
707
- }),
708
- reachUnlockScreen: reachUnlockScreen$2,
709
571
  reject: async (popup) => {
710
572
  if (!await clickApprovalButton(popup, CANCEL_LABELS)) throw new Error("[walletwright] Rabby reject: no cancel button became actionable");
711
573
  },
712
- unlock: unlock$2
574
+ source: {
575
+ id: "acmacodkjbdgmoleebolmdjonilkdbch",
576
+ kind: "webStore"
577
+ },
578
+ unlockScreen: { entry: "index.html" }
713
579
  };
714
580
 
715
581
  //#endregion
716
582
  //#region src/wallets/slush.ts
717
- const SLUSH_EXTENSION_ID = "opcgpfmipidbgpenhmajoajpbobppdil";
718
583
  const HOME_ROUTE = "#/tokens";
719
584
  const IMPORT_HOME_TIMEOUT_MS = 3e4;
720
585
  const PERSIST_TIMEOUT_MS = 6e4;
@@ -777,14 +642,6 @@ const fclick = async (page, text, timeoutMs = 15e3) => {
777
642
  });
778
643
  return true;
779
644
  };
780
- const { reachUnlockScreen: reachUnlockScreen$1, unlock: unlock$1 } = createUnlockScreen({
781
- entry: "index.html",
782
- isUnlocked: atHome,
783
- submit: async (page) => {
784
- await fclick(page, "Unlock");
785
- },
786
- wallet: "Slush"
787
- });
788
645
  const slush = {
789
646
  approve: async (popup, password) => {
790
647
  await sleep(2e3);
@@ -832,26 +689,25 @@ const slush = {
832
689
  status: 200
833
690
  }));
834
691
  },
835
- prepareExtension: (cacheDir) => prepareWebStoreExtension({
836
- cacheDir,
837
- extensionId: SLUSH_EXTENSION_ID,
838
- name: "slush-chrome-latest"
839
- }),
840
- reachUnlockScreen: reachUnlockScreen$1,
841
692
  reject: async (popup) => {
842
693
  await sleep(2e3);
843
694
  if (!await fclick(popup, "Reject")) throw new Error("[walletwright] Slush reject: no Reject control was actionable");
844
695
  },
845
- unlock: unlock$1
696
+ source: {
697
+ id: "opcgpfmipidbgpenhmajoajpbobppdil",
698
+ kind: "webStore"
699
+ },
700
+ unlockScreen: {
701
+ entry: "index.html",
702
+ isUnlocked: atHome,
703
+ submit: async (page) => {
704
+ await fclick(page, "Unlock");
705
+ }
706
+ }
846
707
  };
847
708
 
848
709
  //#endregion
849
710
  //#region src/wallets/solflare.ts
850
- const SOLFLARE_EXTENSION_ID = "bhhhlbepdkbapadjdnnojkbgioiodbic";
851
- const { reachUnlockScreen, unlock } = createUnlockScreen({
852
- entry: "wallet.html",
853
- wallet: "Solflare"
854
- });
855
711
  const importWallet = async (page, seedPhrase, password) => {
856
712
  await page.getByTestId("btn-import-existing-wallet").click({ timeout: 3e4 });
857
713
  await page.getByText("Recovery phrase", { exact: true }).first().click({ timeout: 3e4 });
@@ -881,16 +737,14 @@ const solflare = {
881
737
  importWallet,
882
738
  notificationMatch: "confirm_popup.html",
883
739
  onboardingPage: "wallet.html",
884
- prepareExtension: (cacheDir) => prepareWebStoreExtension({
885
- cacheDir,
886
- extensionId: SOLFLARE_EXTENSION_ID,
887
- name: "solflare-chrome-latest"
888
- }),
889
- reachUnlockScreen,
890
740
  reject: async (popup) => {
891
741
  await popup.getByTestId("btn-cancel").or(popup.getByTestId("btn-reject")).first().click({ timeout: 3e4 });
892
742
  },
893
- unlock
743
+ source: {
744
+ id: "bhhhlbepdkbapadjdnnojkbgioiodbic",
745
+ kind: "webStore"
746
+ },
747
+ unlockScreen: { entry: "wallet.html" }
894
748
  };
895
749
 
896
750
  //#endregion
@@ -904,108 +758,172 @@ const wallets = {
904
758
  };
905
759
  /** Narrow an arbitrary string (CLI flag, setup file field) to a supported wallet. */
906
760
  const isWalletKind = (value) => Object.hasOwn(wallets, value);
761
+ /** Every supported wallet, in registry order. */
907
762
  const walletKinds = Object.keys(wallets).filter(isWalletKind);
908
763
  /** Wallet kinds that can drive the given ecosystem (e.g. `"evm"` → MetaMask, Phantom). */
909
764
  const walletKindsByEcosystem = (ecosystem) => walletKinds.filter((kind) => wallets[kind].ecosystems.includes(ecosystem));
910
765
 
911
766
  //#endregion
912
- //#region src/internal/chromium.ts
913
- /**
914
- * How walletwright launches Chromium with a wallet extension loaded. Shared by the cache build and
915
- * the test run so the two cannot drift: an extension that loads for onboarding but not for the
916
- * tests (or the reverse) fails far from its cause.
917
- */
918
- const extensionContextOptions = (extensionPath, headless) => ({
919
- args: [`--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`],
920
- channel: "chromium",
921
- headless
922
- });
923
-
924
- //#endregion
925
- //#region src/internal/cache.ts
926
- const NAVIGATION_TIMEOUT_MS = 15e3;
927
- const FLUSH_SETTLE_MS = 3e3;
928
- const STATE_WRITE_TIMEOUT_MS = 1e4;
929
- /** Wallet state may live in `chrome.storage.local` or extension IndexedDB. */
930
- const hasPersistedState = async (profileDir, extensionId) => {
931
- const stores = [extensionStateDir(profileDir, extensionId), path.join(profileDir, "Default", "IndexedDB", `chrome-extension_${extensionId}_0.indexeddb.leveldb`)];
932
- return (await Promise.allSettled(stores.map((dir) => readdir(dir)))).some((listing) => listing.status === "fulfilled" && listing.value.length > 0);
933
- };
767
+ //#region src/internal/download.ts
768
+ const ZIP_SIGNATURE = Buffer.from([
769
+ 80,
770
+ 75,
771
+ 3,
772
+ 4
773
+ ]);
934
774
  /**
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`.
775
+ * Prefix for in-progress extractions. It has to live inside `cacheDir` so publishing is a `rename`
776
+ * on one filesystem (a cross-device rename fails with EXDEV), and it starts with a dot so it can
777
+ * never collide with a wallet's `name`.
937
778
  */
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;
779
+ const STAGING_PREFIX = ".staging-";
780
+ /** Download and validate an extension archive, then publish its extraction atomically from staging. */
781
+ const downloadAndExtractExtension = async (options) => {
782
+ const { cacheDir, format, name, sha256, url } = options;
783
+ const cacheRoot = path.resolve(cacheDir);
784
+ const outDir = path.resolve(cacheDir, name);
785
+ if (outDir === cacheRoot || !outDir.startsWith(cacheRoot + path.sep)) throw new Error(`[walletwright] invalid extension name: ${name}`);
786
+ if (await pathExists(path.join(outDir, "manifest.json"))) return outDir;
787
+ await mkdir(cacheDir, { recursive: true });
788
+ const response = await fetch(url, { redirect: "follow" });
789
+ if (!response.ok) throw new Error(`[walletwright] failed to download ${url}: ${response.status} ${response.statusText}`);
790
+ const bytes = Buffer.from(await response.arrayBuffer());
791
+ if (sha256 !== void 0) {
792
+ const actual = createHash("sha256").update(bytes).digest("hex");
793
+ if (actual !== sha256.toLowerCase()) throw new Error(`[walletwright] ${name} failed integrity check: expected ${sha256}, got ${actual}`);
947
794
  }
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, {
795
+ let zipBytes = bytes;
796
+ if (format === "crx") {
797
+ const start = bytes.indexOf(ZIP_SIGNATURE);
798
+ if (start === -1) throw new Error(`[walletwright] ${url} is not a valid CRX (no ZIP header found)`);
799
+ zipBytes = bytes.subarray(start);
800
+ }
801
+ const staging = await mkdtemp(path.join(cacheRoot, STAGING_PREFIX));
802
+ try {
803
+ const zip = new AdmZip(zipBytes);
804
+ for (const entry of zip.getEntries()) {
805
+ const target = path.resolve(staging, entry.entryName);
806
+ if (target !== staging && !target.startsWith(staging + path.sep)) throw new Error(`[walletwright] refusing to extract ${entry.entryName}: escapes ${outDir}`);
807
+ }
808
+ zip.extractAllTo(staging, true);
809
+ if (!await pathExists(path.join(staging, "manifest.json"))) throw new Error(`[walletwright] extracted ${name} but no manifest.json found in ${outDir}`);
810
+ if (await pathExists(path.join(outDir, "manifest.json"))) return outDir;
811
+ await rm(outDir, {
954
812
  force: true,
955
813
  recursive: true
956
814
  });
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;
815
+ await rename(staging, outDir);
816
+ return outDir;
817
+ } finally {
818
+ await rm(staging, {
819
+ force: true,
820
+ recursive: true
821
+ }).catch(() => {});
963
822
  }
964
823
  };
965
- const publishProfile = async (staging, profileDir) => {
966
- await restorePreviousProfile(profileDir);
967
- const previous = previousProfileDir(profileDir);
968
- let movedPrevious = false;
824
+ /** Build the Chrome Web Store CRX download URL for an extension id. */
825
+ const chromeWebStoreCrxUrl = (extensionId) => `https://clients2.google.com/service/update2/crx?response=redirect&prodversion=130.0&acceptformat=crx2,crx3&x=id%3D${extensionId}%26uc`;
826
+
827
+ //#endregion
828
+ //#region src/internal/extension.ts
829
+ /**
830
+ * The archive a setup runs. A release source honours `version`; a Web Store source cannot pin one,
831
+ * so asking for it is an error rather than a setting that forks the profile cache for the same build.
832
+ */
833
+ const extensionArchive = (setup) => {
834
+ const { extensionName, source } = wallets[setup.wallet];
835
+ if (source.kind === "release") return source.release(setup.version ?? source.defaultVersion);
836
+ if (setup.version !== void 0) throw new Error(`[walletwright] ${extensionName} runs its Chrome Web Store build, which cannot be pinned; remove \`version\` from its WalletSetup.`);
837
+ return {
838
+ format: "crx",
839
+ name: `${setup.wallet}-chrome-latest`,
840
+ sha256: void 0,
841
+ url: chromeWebStoreCrxUrl(source.id)
842
+ };
843
+ };
844
+ /** Download and unpack the setup's extension into `cacheDir`, returning its path. */
845
+ const prepareExtension = async (setup, cacheDir) => {
846
+ return await downloadAndExtractExtension({
847
+ cacheDir,
848
+ ...extensionArchive(setup)
849
+ });
850
+ };
851
+ /** Mirror Chrome's extension-id derivation; resolve symlinks because Chrome hashes the real path. */
852
+ const extensionIdFromPath = async (extensionPath) => {
853
+ const resolved = path.resolve(extensionPath);
854
+ const abs = await realpath(resolved).catch(() => resolved);
855
+ let key;
969
856
  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;
857
+ const text = await readFile(path.join(abs, "manifest.json"), "utf8");
858
+ key = z.object({ key: z.string().optional() }).parse(JSON.parse(text)).key;
859
+ } catch {
860
+ key = void 0;
974
861
  }
862
+ const source = key === void 0 || key === "" ? Buffer.from(abs, "utf8") : Buffer.from(key, "base64");
863
+ const hex = createHash("sha256").update(source).digest("hex").slice(0, 32);
864
+ return Array.from(hex, (nibble) => String.fromCodePoint(97 + Number.parseInt(nibble, 16))).join("");
865
+ };
866
+
867
+ //#endregion
868
+ //#region src/internal/browser.ts
869
+ const launchChromium = chromium.launchPersistentContext.bind(chromium);
870
+ /**
871
+ * Launch Chromium with the wallet's extension loaded and its `prepareContext` applied. The cache
872
+ * build and the test run both open the wallet here, so a route stubbed or an extension loaded for
873
+ * one but not the other cannot fail far from its cause. Closes the context if preparing it fails.
874
+ */
875
+ const openWalletContext = async ({ definition, extensionPath, headless, launchPersistentContext = launchChromium, userDataDir }) => {
876
+ const context = await launchPersistentContext(userDataDir, {
877
+ args: [`--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`],
878
+ channel: "chromium",
879
+ headless
880
+ });
975
881
  try {
976
- await rename(staging, profileDir);
882
+ await definition.prepareContext?.(context);
883
+ return {
884
+ context,
885
+ extensionId: await extensionIdFromPath(extensionPath)
886
+ };
977
887
  } 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
- }
888
+ await context.close().catch(() => {});
985
889
  throw error;
986
890
  }
987
- if (movedPrevious) await rm(previous, {
988
- force: true,
989
- recursive: true
990
- });
991
891
  };
992
- const defaultBuildCacheDependencies = {
993
- launchPersistentContext: chromium.launchPersistentContext.bind(chromium),
994
- prepareExtension: (definition, cacheDir, version) => definition.prepareExtension(cacheDir, version)
892
+
893
+ //#endregion
894
+ //#region src/internal/cache.ts
895
+ const NAVIGATION_TIMEOUT_MS = 15e3;
896
+ const FLUSH_SETTLE_MS = 3e3;
897
+ const STATE_WRITE_TIMEOUT_MS = 1e4;
898
+ /**
899
+ * Prefix for an in-progress build. Inside `cacheDir` so publishing is a `rename` on one filesystem,
900
+ * dot-prefixed so it can never be mistaken for a 20-hex-character `profileKey`.
901
+ */
902
+ const BUILD_PREFIX = ".building-";
903
+ /** Wallet state may live in `chrome.storage.local` or extension IndexedDB. */
904
+ const hasPersistedState = async (profileDir, extensionId) => {
905
+ const stores = [extensionStateDir(profileDir, extensionId), path.join(profileDir, "Default", "IndexedDB", `chrome-extension_${extensionId}_0.indexeddb.leveldb`)];
906
+ return (await Promise.allSettled(stores.map((dir) => readdir(dir)))).some((listing) => listing.status === "fulfilled" && listing.value.length > 0);
995
907
  };
996
- const buildCacheWithDependencies = async (setup, options = {}, dependencies = defaultBuildCacheDependencies) => {
908
+ const defaultDependencies = { prepareExtension };
909
+ /** Build in staging and retain the prior cache until the new profile publishes successfully. */
910
+ const buildCacheWithDependencies = async (setup, { headless = false } = {}, dependencies = defaultDependencies) => {
997
911
  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);
912
+ const { cacheDir, profileDir } = await locateProfile(setup);
913
+ const extensionPath = await dependencies.prepareExtension(setup, cacheDir);
1002
914
  await mkdir(cacheDir, { recursive: true });
1003
915
  const staging = await mkdtemp(path.join(cacheDir, BUILD_PREFIX));
1004
916
  let context;
1005
917
  try {
1006
- context = await dependencies.launchPersistentContext(staging, extensionContextOptions(extensionPath, options.headless === true));
1007
- await definition.prepareContext?.(context);
1008
- const extensionId = await extensionIdFromPath(extensionPath);
918
+ const opened = await openWalletContext({
919
+ definition,
920
+ extensionPath,
921
+ headless,
922
+ launchPersistentContext: dependencies.launchPersistentContext,
923
+ userDataDir: staging
924
+ });
925
+ context = opened.context;
926
+ const { extensionId } = opened;
1009
927
  const page = context.pages().find((candidate) => candidate.url() === "about:blank") ?? await context.newPage();
1010
928
  await gotoWithRetry(page, `chrome-extension://${extensionId}/${definition.onboardingPage}`, {
1011
929
  label: `${definition.extensionName} onboarding page`,
@@ -1036,5 +954,5 @@ const buildCacheWithDependencies = async (setup, options = {}, dependencies = de
1036
954
  const buildCache = (setup, options) => buildCacheWithDependencies(setup, options);
1037
955
 
1038
956
  //#endregion
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
957
+ export { walletKinds as a, locateProfile as c, gotoWithRetry as d, waitUntil as f, isWalletKind as i, pathExists as l, openWalletContext as n, walletKindsByEcosystem as o, prepareExtension as r, wallets as s, buildCache as t, formatTimeout as u };
958
+ //# sourceMappingURL=cache-ztGj4R8m.mjs.map