@walletwright/core 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/README.md +2 -2
- package/dist/{cache-Bwv2i-hv.mjs → cache-BaWu5vyM.mjs} +242 -174
- package/dist/cache-BaWu5vyM.mjs.map +1 -0
- package/dist/cli.d.mts +23 -11
- package/dist/cli.mjs +97 -55
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +20 -30
- package/dist/index.mjs +88 -86
- package/dist/index.mjs.map +1 -1
- package/dist/mock-standard.d.mts +4 -19
- package/dist/mock-standard.mjs +16 -16
- package/dist/mock-standard.mjs.map +1 -1
- package/dist/mock.d.mts +3 -7
- package/dist/mock.mjs +14 -12
- package/dist/mock.mjs.map +1 -1
- package/dist/{types-CWxz_dJe.d.mts → types-CFQpWZhv.d.mts} +7 -30
- package/package.json +6 -5
- package/dist/cache-Bwv2i-hv.mjs.map +0 -1
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { mkdir, readdir, rm } from "node:fs/promises";
|
|
1
|
+
import { access, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { chromium } from "@playwright/test";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
|
-
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
6
5
|
import AdmZip from "adm-zip";
|
|
7
6
|
import { ClassicLevel } from "classic-level";
|
|
7
|
+
import { z } from "zod";
|
|
8
8
|
|
|
9
9
|
//#region src/internal/download.ts
|
|
10
10
|
const ZIP_SIGNATURE = Buffer.from([
|
|
@@ -14,16 +14,27 @@ const ZIP_SIGNATURE = Buffer.from([
|
|
|
14
14
|
4
|
|
15
15
|
]);
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* Prefix for in-progress extractions. It has to live inside `cacheDir` so publishing is a `rename`
|
|
18
|
+
* on one filesystem (a cross-device rename fails with EXDEV), and it starts with a dot so it can
|
|
19
|
+
* never collide with a wallet's `name`.
|
|
20
20
|
*/
|
|
21
|
+
const STAGING_PREFIX = ".staging-";
|
|
22
|
+
const pathExists$1 = async (target) => {
|
|
23
|
+
try {
|
|
24
|
+
await stat(target);
|
|
25
|
+
return true;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
/** Download and validate an extension archive, then publish its extraction atomically from staging. */
|
|
21
32
|
const downloadAndExtractExtension = async (options) => {
|
|
22
33
|
const { cacheDir, kind, name, sha256, url } = options;
|
|
23
34
|
const cacheRoot = path.resolve(cacheDir);
|
|
24
35
|
const outDir = path.resolve(cacheDir, name);
|
|
25
36
|
if (outDir === cacheRoot || !outDir.startsWith(cacheRoot + path.sep)) throw new Error(`[walletwright] invalid extension name: ${name}`);
|
|
26
|
-
if (
|
|
37
|
+
if (await pathExists$1(path.join(outDir, "manifest.json"))) return outDir;
|
|
27
38
|
await mkdir(cacheDir, { recursive: true });
|
|
28
39
|
const response = await fetch(url, { redirect: "follow" });
|
|
29
40
|
if (!response.ok) throw new Error(`[walletwright] failed to download ${url}: ${response.status} ${response.statusText}`);
|
|
@@ -38,19 +49,28 @@ const downloadAndExtractExtension = async (options) => {
|
|
|
38
49
|
if (start === -1) throw new Error(`[walletwright] ${url} is not a valid CRX (no ZIP header found)`);
|
|
39
50
|
zipBytes = bytes.subarray(start);
|
|
40
51
|
}
|
|
41
|
-
await
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (
|
|
52
|
+
const staging = await mkdtemp(path.join(cacheRoot, STAGING_PREFIX));
|
|
53
|
+
try {
|
|
54
|
+
const zip = new AdmZip(zipBytes);
|
|
55
|
+
for (const entry of zip.getEntries()) {
|
|
56
|
+
const target = path.resolve(staging, entry.entryName);
|
|
57
|
+
if (target !== staging && !target.startsWith(staging + path.sep)) throw new Error(`[walletwright] refusing to extract ${entry.entryName}: escapes ${outDir}`);
|
|
58
|
+
}
|
|
59
|
+
zip.extractAllTo(staging, true);
|
|
60
|
+
if (!await pathExists$1(path.join(staging, "manifest.json"))) throw new Error(`[walletwright] extracted ${name} but no manifest.json found in ${outDir}`);
|
|
61
|
+
if (await pathExists$1(path.join(outDir, "manifest.json"))) return outDir;
|
|
62
|
+
await rm(outDir, {
|
|
63
|
+
force: true,
|
|
64
|
+
recursive: true
|
|
65
|
+
});
|
|
66
|
+
await rename(staging, outDir);
|
|
67
|
+
return outDir;
|
|
68
|
+
} finally {
|
|
69
|
+
await rm(staging, {
|
|
70
|
+
force: true,
|
|
71
|
+
recursive: true
|
|
72
|
+
}).catch(() => {});
|
|
50
73
|
}
|
|
51
|
-
zip.extractAllTo(outDir, true);
|
|
52
|
-
if (!existsSync(path.join(outDir, "manifest.json"))) throw new Error(`[walletwright] extracted ${name} but no manifest.json found in ${outDir}`);
|
|
53
|
-
return outDir;
|
|
54
74
|
};
|
|
55
75
|
/** Build the Chrome Web Store CRX download URL for an extension id. */
|
|
56
76
|
const chromeWebStoreCrxUrl = (extensionId) => `https://clients2.google.com/service/update2/crx?response=redirect&prodversion=130.0&acceptformat=crx2,crx3&x=id%3D${extensionId}%26uc`;
|
|
@@ -101,7 +121,7 @@ const importPrivateKey = async ({ home }, privateKey) => {
|
|
|
101
121
|
});
|
|
102
122
|
await closeAccountMenu(home);
|
|
103
123
|
};
|
|
104
|
-
const rename = async ({ home }, options) => {
|
|
124
|
+
const rename$1 = async ({ home }, options) => {
|
|
105
125
|
await openAccountMenu(home);
|
|
106
126
|
await home.getByTestId("multichain-account-cell-end-accessory").nth(options.index).click();
|
|
107
127
|
await home.getByTestId("multichain-account-menu-item-rename").click();
|
|
@@ -120,7 +140,7 @@ const switchTo = async ({ home }, index) => {
|
|
|
120
140
|
const accounts = {
|
|
121
141
|
add: add$1,
|
|
122
142
|
importPrivateKey,
|
|
123
|
-
rename,
|
|
143
|
+
rename: rename$1,
|
|
124
144
|
switch: switchTo
|
|
125
145
|
};
|
|
126
146
|
|
|
@@ -158,12 +178,7 @@ const sleep = (ms) => new Promise((resolve) => {
|
|
|
158
178
|
});
|
|
159
179
|
/** Render a timeout for an error message, so what a failure claims matches what it waited. */
|
|
160
180
|
const formatTimeout = (ms) => `${Math.round(ms / 100) / 10}s`;
|
|
161
|
-
/**
|
|
162
|
-
* Poll `check` until it yields anything other than `undefined`/`false`, then hand that value back.
|
|
163
|
-
* Returns `undefined` when the budget elapses, so only a caller that can genuinely continue without
|
|
164
|
-
* the condition (an optional approval popup, say) branches on the result; everything else uses
|
|
165
|
-
* `waitUntilOrThrow` and gets a named failure instead of a wallet that looks ready and isn't.
|
|
166
|
-
*/
|
|
181
|
+
/** Poll until `check` returns a value, or `undefined` when the timeout expires. */
|
|
167
182
|
const waitUntil = async (check, { intervalMs = DEFAULT_INTERVAL_MS, timeoutMs }) => {
|
|
168
183
|
const deadline = Date.now() + timeoutMs;
|
|
169
184
|
for (;;) {
|
|
@@ -200,6 +215,12 @@ const RELOAD_SETTLE_TIMEOUT_MS = 5e3;
|
|
|
200
215
|
const PROBE_INTERVAL_MS = 500;
|
|
201
216
|
const PASSWORD_CLEARED_TIMEOUT_MS = 15e3;
|
|
202
217
|
/**
|
|
218
|
+
* Short on purpose. `unlock` is normally handed a page `reachUnlockScreen` already settled, so this
|
|
219
|
+
* budget only matters when it is called on its own (the `settings.unlock` path), where a wallet with a
|
|
220
|
+
* late-mounting UI needs a poll rather than a single read but a dead page should still fail fast.
|
|
221
|
+
*/
|
|
222
|
+
const UNLOCK_SETTLE_TIMEOUT_MS = 5e3;
|
|
223
|
+
/**
|
|
203
224
|
* The shared unlock flow: open the wallet's own page, wait for it to settle into a state we can
|
|
204
225
|
* name, and type the password. Every wallet differs only in its entry page, how it submits, and
|
|
205
226
|
* whether it can come back already unlocked.
|
|
@@ -228,11 +249,10 @@ const createUnlockScreen = ({ entry, isUnlocked, submit, wallet }) => {
|
|
|
228
249
|
return page;
|
|
229
250
|
};
|
|
230
251
|
const unlock = async (page, password) => {
|
|
252
|
+
const state = await settleWithin(page, UNLOCK_SETTLE_TIMEOUT_MS);
|
|
253
|
+
if (state === "unlocked") return;
|
|
254
|
+
if (state === void 0) throw new Error(`[walletwright] ${wallet} showed neither an unlock screen nor an unlocked wallet`);
|
|
231
255
|
const field = page.locator(PASSWORD_FIELD);
|
|
232
|
-
if (!await field.isVisible().catch(() => false)) {
|
|
233
|
-
if (await isUnlocked?.(page).catch(() => false) === true) return;
|
|
234
|
-
throw new Error(`[walletwright] ${wallet} showed neither an unlock screen nor an unlocked wallet`);
|
|
235
|
-
}
|
|
236
256
|
await field.fill(password);
|
|
237
257
|
await (submit === void 0 ? field.press("Enter") : submit(page, field));
|
|
238
258
|
if (!await field.waitFor({
|
|
@@ -305,39 +325,24 @@ const settings = {
|
|
|
305
325
|
const BUTTON_TIMEOUT_MS = 45e3;
|
|
306
326
|
/** The retry only covers a notice that rendered late, so it does not need the full budget again. */
|
|
307
327
|
const RETRY_TIMEOUT_MS = 1e4;
|
|
308
|
-
/**
|
|
309
|
-
* First-time requests that MetaMask routes through a protocol Snap (e.g. adding a custom chain)
|
|
310
|
-
* open a "Third-party software notice" modal over the confirm footer, and every click is
|
|
311
|
-
* intercepted until it is accepted. Its buttons carry no testids, only text, and Accept stays
|
|
312
|
-
* disabled until the notice is scrolled to the bottom.
|
|
313
|
-
*/
|
|
328
|
+
/** Snap notices intercept footer clicks and disable Accept until their content is scrolled. */
|
|
314
329
|
const acceptThirdPartyNotice = async (popup) => {
|
|
315
330
|
const modal = popup.locator(".snap-install-warning");
|
|
316
331
|
if (await modal.count().catch(() => 0) === 0) return;
|
|
317
332
|
await modal.locator("p").last().scrollIntoViewIfNeeded().catch(() => {});
|
|
318
333
|
await modal.getByRole("button", { name: "Accept" }).click({ timeout: 3e3 }).catch(() => {});
|
|
319
334
|
};
|
|
320
|
-
/**
|
|
321
|
-
* Confirm buttons differ by request type: connect popups use `confirm-btn`, signature and
|
|
322
|
-
* transaction popups use `confirm-footer-button`, older builds used `page-container-footer-next`,
|
|
323
|
-
* and requests routed through a protocol Snap (e.g. Solana) use
|
|
324
|
-
* `confirm-<type>-confirm-snap-footer-button`, matched by suffix so every snap confirmation type is
|
|
325
|
-
* covered. A popup renders exactly one of these buttons; the `.or()` union resolves whichever one
|
|
326
|
-
* is present by DOM order, not by the order it's written in below, so one locator covers every
|
|
327
|
-
* popup.
|
|
328
|
-
*/
|
|
335
|
+
/** Resolve the confirm test id used by standard, legacy, and Snap request footers. */
|
|
329
336
|
const confirmButton = (popup) => popup.getByTestId("confirm-btn").or(popup.getByTestId("confirm-footer-button")).or(popup.locator("[data-testid=\"page-container-footer-next\"]")).or(popup.locator("[data-testid$=\"-confirm-snap-footer-button\"]")).first();
|
|
330
337
|
const cancelButton = (popup) => popup.getByTestId("cancel-btn").or(popup.getByTestId("confirm-footer-cancel-button")).or(popup.locator("[data-testid=\"page-container-footer-cancel\"]")).or(popup.locator("[data-testid$=\"-cancel-snap-footer-button\"]")).first();
|
|
338
|
+
/** Distinguishes a pending request from MetaMask's requestless notification home screen. */
|
|
339
|
+
const approvalControls = (popup) => confirmButton(popup).or(cancelButton(popup));
|
|
331
340
|
/**
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
* screen prove nothing. A CI run handed that home screen to `approve` and waited out the full click
|
|
336
|
-
* budget against a confirm button that was never going to exist.
|
|
341
|
+
* Click one of the popup's footer buttons. The third-party notice covers that whole footer, so both
|
|
342
|
+
* confirm and cancel are intercepted while it is up, and both need the dismiss-then-retry: without it
|
|
343
|
+
* a click times out against a button that is rendered and enabled, which reads as a stale selector.
|
|
337
344
|
*/
|
|
338
|
-
const
|
|
339
|
-
const approve = async (popup) => {
|
|
340
|
-
const button = confirmButton(popup);
|
|
345
|
+
const clickFooterButton = async (popup, button) => {
|
|
341
346
|
await acceptThirdPartyNotice(popup);
|
|
342
347
|
try {
|
|
343
348
|
await button.click({ timeout: BUTTON_TIMEOUT_MS });
|
|
@@ -348,10 +353,9 @@ const approve = async (popup) => {
|
|
|
348
353
|
});
|
|
349
354
|
}
|
|
350
355
|
};
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
};
|
|
356
|
+
const approve = (popup) => clickFooterButton(popup, confirmButton(popup));
|
|
357
|
+
/** The cancel counterpart of `approve`; same union-by-DOM-order resolution, not written order. */
|
|
358
|
+
const reject = (popup) => clickFooterButton(popup, cancelButton(popup));
|
|
355
359
|
|
|
356
360
|
//#endregion
|
|
357
361
|
//#region src/internal/utils.ts
|
|
@@ -360,29 +364,22 @@ const DEFAULT_CACHE_DIR = ".walletwright";
|
|
|
360
364
|
const DEFAULT_NOTIFICATION_MATCH = "notification.html";
|
|
361
365
|
/** Stable per-setup profile directory name, so the same wallet+seed+password reuses one cache. */
|
|
362
366
|
const profileKey = (setup) => createHash("sha256").update(`${setup.wallet}:${setup.version ?? "default"}:${setup.seedPhrase}:${setup.password}`).digest("hex").slice(0, 20);
|
|
363
|
-
/**
|
|
364
|
-
|
|
365
|
-
* and the service worker starts lazily). Chrome derives the id from the sha256 of either the
|
|
366
|
-
* manifest's public `key` (if present, e.g. Phantom → its fixed Web Store id) or the absolute load
|
|
367
|
-
* path (no key, e.g. MetaMask), taking the first 16 bytes and mapping each nibble 0-f → a-p.
|
|
368
|
-
*
|
|
369
|
-
* The path is resolved through symlinks (`realpathSync`) because Chrome hashes the *real* path, a
|
|
370
|
-
* cache under a symlinked dir (e.g. macOS `/tmp` → `/private/tmp`) would otherwise yield a wrong id.
|
|
371
|
-
*/
|
|
372
|
-
const extensionIdFromPath = (extensionPath) => {
|
|
367
|
+
/** Mirror Chrome's extension-id derivation; resolve symlinks because Chrome hashes the real path. */
|
|
368
|
+
const extensionIdFromPath = async (extensionPath) => {
|
|
373
369
|
const resolved = path.resolve(extensionPath);
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
})();
|
|
370
|
+
let abs = resolved;
|
|
371
|
+
try {
|
|
372
|
+
abs = await realpath(resolved);
|
|
373
|
+
} catch {
|
|
374
|
+
abs = resolved;
|
|
375
|
+
}
|
|
381
376
|
let key;
|
|
382
377
|
try {
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
} catch {
|
|
378
|
+
const text = await readFile(path.join(abs, "manifest.json"), "utf8");
|
|
379
|
+
key = z.object({ key: z.string().optional() }).parse(JSON.parse(text)).key;
|
|
380
|
+
} catch {
|
|
381
|
+
key = void 0;
|
|
382
|
+
}
|
|
386
383
|
const source = key === void 0 || key === "" ? Buffer.from(abs, "utf8") : Buffer.from(key, "base64");
|
|
387
384
|
const hex = createHash("sha256").update(source).digest("hex").slice(0, 32);
|
|
388
385
|
return Array.from(hex, (nibble) => String.fromCodePoint(97 + Number.parseInt(nibble, 16))).join("");
|
|
@@ -414,14 +411,7 @@ const APPROVAL_WINDOW = {
|
|
|
414
411
|
};
|
|
415
412
|
/** Far enough from the edge that a popup this size lands fully on any usable display. */
|
|
416
413
|
const APPROVAL_WINDOW_OFFSET = 50;
|
|
417
|
-
/**
|
|
418
|
-
* Put a wallet-spawned approval window somewhere it can be clicked. It can open partly off-screen
|
|
419
|
-
* on a small or virtual display, and Playwright will not click what is out of view, so a confirm
|
|
420
|
-
* button that is rendered and enabled still times out. Synpress carries the same workaround.
|
|
421
|
-
*
|
|
422
|
-
* Best-effort throughout: headless has no window to move, and nothing here is required for a window
|
|
423
|
-
* that already sits on screen.
|
|
424
|
-
*/
|
|
414
|
+
/** Move approval popups on-screen; headless and unsupported CDP targets are ignored. */
|
|
425
415
|
const placeApprovalWindow = async (page) => {
|
|
426
416
|
await page.setViewportSize(APPROVAL_WINDOW).catch(() => {});
|
|
427
417
|
try {
|
|
@@ -440,32 +430,29 @@ const placeApprovalWindow = async (page) => {
|
|
|
440
430
|
};
|
|
441
431
|
/** Where Chrome persists an extension's `chrome.storage.local` inside a browser profile. */
|
|
442
432
|
const extensionStateDir = (profileDir, extensionId) => path.join(profileDir, "Default", "Local Extension Settings", extensionId);
|
|
443
|
-
const hasNotificationPopup = (context, extensionId, match = DEFAULT_NOTIFICATION_MATCH) => context.pages().some((page) => isApprovalPopup(page, extensionId, match));
|
|
444
433
|
|
|
445
434
|
//#endregion
|
|
446
435
|
//#region src/wallets/metamask/onboarding-patch.ts
|
|
447
|
-
const
|
|
436
|
+
const onboardingStateSchema = z.record(z.string(), z.json());
|
|
437
|
+
const parseState = (text, label) => {
|
|
438
|
+
const result = onboardingStateSchema.safeParse(JSON.parse(text));
|
|
439
|
+
if (!result.success) throw new Error(`[walletwright] MetaMask's ${label} state is not an object`, { cause: result.error });
|
|
440
|
+
return result.data;
|
|
441
|
+
};
|
|
448
442
|
const markOnboarded = (onboarding) => ({
|
|
449
443
|
...onboarding,
|
|
450
444
|
completedOnboarding: true,
|
|
451
445
|
firstTimeFlowType: typeof onboarding.firstTimeFlowType === "string" ? onboarding.firstTimeFlowType : "import",
|
|
452
446
|
onboardingTabs: {}
|
|
453
447
|
});
|
|
454
|
-
/**
|
|
455
|
-
* Force `completedOnboarding=true` in MetaMask's persisted state so the cached wallet boots straight
|
|
456
|
-
* to the unlock screen instead of getting stuck on the "wallet is ready" screen (whose "Open wallet"
|
|
457
|
-
* action goes through the MV3 service worker and is unreliable under automation).
|
|
458
|
-
*
|
|
459
|
-
* MetaMask 13.13.x stored all state under a single `data` key; 13.3x stores each controller under its
|
|
460
|
-
* own key (`OnboardingController`). Both are handled. Must run while the browser is closed.
|
|
461
|
-
*
|
|
462
|
-
* Every branch that can't find the state it means to patch throws: leaving the cache unpatched means
|
|
463
|
-
* a wallet that boots to "wallet is ready" and ignores every dapp request, which is far harder to
|
|
464
|
-
* diagnose from a spec than a failed cache build.
|
|
465
|
-
*/
|
|
448
|
+
/** Patch both MetaMask 13.x onboarding layouts while the browser has released LevelDB. */
|
|
466
449
|
const markMetaMaskOnboarded = async (profileDir, extensionId) => {
|
|
467
450
|
const dbDir = extensionStateDir(profileDir, extensionId);
|
|
468
|
-
|
|
451
|
+
try {
|
|
452
|
+
await access(dbDir);
|
|
453
|
+
} catch (error) {
|
|
454
|
+
throw new Error(`[walletwright] MetaMask wrote no extension state to ${dbDir}`, { cause: error });
|
|
455
|
+
}
|
|
469
456
|
const db = new ClassicLevel(dbDir, {
|
|
470
457
|
createIfMissing: false,
|
|
471
458
|
keyEncoding: "utf8",
|
|
@@ -475,19 +462,18 @@ const markMetaMaskOnboarded = async (profileDir, extensionId) => {
|
|
|
475
462
|
await db.open();
|
|
476
463
|
const perController = await db.get("OnboardingController");
|
|
477
464
|
if (perController !== void 0 && perController !== "") {
|
|
478
|
-
const onboarding =
|
|
479
|
-
if (onboarding === void 0) throw new Error(`[walletwright] MetaMask's OnboardingController state is not an object`);
|
|
465
|
+
const onboarding = parseState(perController, "OnboardingController");
|
|
480
466
|
if (onboarding.completedOnboarding !== true) await db.put("OnboardingController", JSON.stringify(markOnboarded(onboarding)));
|
|
481
467
|
return;
|
|
482
468
|
}
|
|
483
469
|
const raw = await db.get("data");
|
|
484
470
|
if (raw === void 0 || raw === "") throw new Error(`[walletwright] MetaMask persisted no onboarding state in ${dbDir} (neither an OnboardingController key nor a data key)`);
|
|
485
|
-
const state =
|
|
486
|
-
|
|
487
|
-
const nested =
|
|
488
|
-
const
|
|
489
|
-
if (
|
|
490
|
-
const patched = markOnboarded(
|
|
471
|
+
const state = parseState(raw, "\"data\"");
|
|
472
|
+
const nestedResult = onboardingStateSchema.safeParse(state.data);
|
|
473
|
+
const nested = nestedResult.success ? nestedResult.data : void 0;
|
|
474
|
+
const controllerResult = onboardingStateSchema.safeParse((nested ?? state).OnboardingController);
|
|
475
|
+
if (!controllerResult.success) throw new Error(`[walletwright] MetaMask's "data" state holds no OnboardingController`);
|
|
476
|
+
const patched = markOnboarded(controllerResult.data);
|
|
491
477
|
await db.put("data", JSON.stringify(nested === void 0 ? {
|
|
492
478
|
...state,
|
|
493
479
|
OnboardingController: patched
|
|
@@ -505,37 +491,24 @@ const markMetaMaskOnboarded = async (profileDir, extensionId) => {
|
|
|
505
491
|
|
|
506
492
|
//#endregion
|
|
507
493
|
//#region src/wallets/metamask.ts
|
|
508
|
-
|
|
494
|
+
/** Release hashes for the pinned default. Add an entry before changing `DEFAULT_VERSION`. */
|
|
495
|
+
const RELEASE_SHA256 = { "13.35.1": "4e0f8626df0ae9fb15f5f3ad6784a0b518f3ede067b2b0d4f539f9f457c5049c" };
|
|
509
496
|
/**
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
* curl -sL https://github.com/MetaMask/metamask-extension/releases/download/v<v>/metamask-chrome-<v>.zip | shasum -a 256
|
|
514
|
-
*
|
|
515
|
-
* A version a caller pins through `setup.version` has no entry here and downloads unverified, and
|
|
516
|
-
* the Web-Store wallets can't be pinned at all (that endpoint always serves the current version).
|
|
497
|
+
* Typed as a key of `RELEASE_SHA256`, so bumping the default without adding its hash above is a
|
|
498
|
+
* compile error. `download.ts` skips verification for an unpinned `sha256`, so the same bump would
|
|
499
|
+
* otherwise silently downgrade every default MetaMask download to unverified.
|
|
517
500
|
*/
|
|
518
|
-
const
|
|
501
|
+
const DEFAULT_VERSION = "13.35.1";
|
|
502
|
+
const releaseSha256 = (version) => Object.entries(RELEASE_SHA256).find(([pinned]) => pinned === version)?.[1];
|
|
519
503
|
/** Download inputs for a MetaMask release, split out so the integrity wiring is unit-testable. */
|
|
520
504
|
const metamaskDownload = (cacheDir, version) => ({
|
|
521
505
|
cacheDir,
|
|
522
506
|
kind: "zip",
|
|
523
507
|
name: `metamask-chrome-${version}`,
|
|
524
|
-
sha256:
|
|
508
|
+
sha256: releaseSha256(version),
|
|
525
509
|
url: `https://github.com/MetaMask/metamask-extension/releases/download/v${version}/metamask-chrome-${version}.zip`
|
|
526
510
|
});
|
|
527
|
-
/**
|
|
528
|
-
* MetaMask's backup-and-sync restores account names for whichever SRP a profile holds, and test
|
|
529
|
-
* seeds are shared (the public `test test … junk` one by thousands), so a synced profile reports a
|
|
530
|
-
* stranger's account names in place of the ones a test just set: CI runs showed accounts called
|
|
531
|
-
* `dev1` and `personal`, holding a real balance, where `Account 2` was expected. It only happens
|
|
532
|
-
* where the sync actually lands, which makes it look like a flake. MetaMask's own e2e suite mocks
|
|
533
|
-
* its external services for the same reason.
|
|
534
|
-
*
|
|
535
|
-
* Just the one host that stores the names. Cutting the auth stack around it (`authentication`,
|
|
536
|
-
* `oidc`) reaches further than intended: other features authenticate through it too, and a wallet
|
|
537
|
-
* that cannot authenticate can leave a request's confirm button disabled with nothing to explain it.
|
|
538
|
-
*/
|
|
511
|
+
/** MetaMask's account-name sync mutates shared test-seed profiles, so its own e2e suite blocks this host. */
|
|
539
512
|
const ACCOUNT_SYNC_HOST = "user-storage.api.cx.metamask.io";
|
|
540
513
|
const metamask = {
|
|
541
514
|
actions: {
|
|
@@ -696,34 +669,23 @@ const CONFIRM_LABELS = [
|
|
|
696
669
|
"Confirm"
|
|
697
670
|
];
|
|
698
671
|
const CANCEL_LABELS = ["Cancel", "Reject"];
|
|
699
|
-
/**
|
|
700
|
-
* Rabby's approval window is focus-fragile: it unmounts its contents a few seconds after losing
|
|
701
|
-
* focus, and Playwright's click (which waits for actionability, then for the click to settle) loses
|
|
702
|
-
* the window mid-action. Rabby reads the vanished window as a dismissal, so the dapp gets
|
|
703
|
-
* "User rejected the request" even though the confirm button was visible and enabled. Dispatching
|
|
704
|
-
* the click inside the page skips focus, actionability, and post-click bookkeeping entirely. Rabby
|
|
705
|
-
* is a plain React app, so `evaluate` works here (unlike MetaMask, which scuttles the realm).
|
|
706
|
-
*
|
|
707
|
-
* Signing is a two-step footer: "Sign" swaps itself for "Confirm", which must be clicked too, and
|
|
708
|
-
* both start disabled while Rabby analyses the request. So keep clicking whichever labelled button
|
|
709
|
-
* is currently enabled until the window closes, which is the only signal the request was answered.
|
|
710
|
-
*/
|
|
672
|
+
/** Rabby's focus-fragile popup requires in-page clicks; signing then requires a second Confirm click. */
|
|
711
673
|
const clickApprovalButton = async (popup, labels) => {
|
|
712
|
-
|
|
674
|
+
const clickedLabels = /* @__PURE__ */ new Set();
|
|
713
675
|
return await waitUntil(async () => {
|
|
714
676
|
if (popup.isClosed()) return true;
|
|
715
677
|
const clicked = await popup.evaluate((arg) => {
|
|
716
678
|
const target = [...document.querySelectorAll("button")].find((button) => {
|
|
717
679
|
const text = (button.textContent ?? "").trim();
|
|
718
|
-
return arg.names.includes(text) && !button.disabled &&
|
|
680
|
+
return arg.names.includes(text) && !button.disabled && !arg.skip.includes(text);
|
|
719
681
|
});
|
|
720
682
|
target?.click();
|
|
721
683
|
return target ? (target.textContent ?? "").trim() : "";
|
|
722
684
|
}, {
|
|
723
685
|
names: [...labels],
|
|
724
|
-
skip:
|
|
686
|
+
skip: [...clickedLabels]
|
|
725
687
|
}).catch(() => "");
|
|
726
|
-
if (clicked)
|
|
688
|
+
if (clicked) clickedLabels.add(clicked);
|
|
727
689
|
}, {
|
|
728
690
|
intervalMs: 300,
|
|
729
691
|
timeoutMs: APPROVAL_TIMEOUT_MS
|
|
@@ -755,7 +717,53 @@ const rabby = {
|
|
|
755
717
|
const SLUSH_EXTENSION_ID = "opcgpfmipidbgpenhmajoajpbobppdil";
|
|
756
718
|
const HOME_ROUTE = "#/tokens";
|
|
757
719
|
const IMPORT_HOME_TIMEOUT_MS = 3e4;
|
|
720
|
+
const PERSIST_TIMEOUT_MS = 6e4;
|
|
721
|
+
const ACCOUNTS_DATABASE = "signaldb-accounts";
|
|
722
|
+
const PREFERENCES_DATABASE = "signaldb-preferences";
|
|
723
|
+
const ITEMS_STORE = "items";
|
|
758
724
|
const atHome = async (page) => await page.evaluate(() => globalThis.location.hash).catch(() => "") === HOME_ROUTE;
|
|
725
|
+
/**
|
|
726
|
+
* Slush writes the imported account, its provider and the preferences row that selects it in
|
|
727
|
+
* separate transactions, so reaching the home route does not mean the profile is durable yet.
|
|
728
|
+
* Caching between those writes yields a profile that unlocks to an account-less wallet.
|
|
729
|
+
*/
|
|
730
|
+
const walletPersisted = (page) => page.evaluate(async ({ accountsDatabase, preferencesDatabase, storeName }) => {
|
|
731
|
+
const readItems = async (databaseName) => {
|
|
732
|
+
if (!(await indexedDB.databases()).some((database) => database.name === databaseName)) return [];
|
|
733
|
+
const database = await new Promise((resolve, reject) => {
|
|
734
|
+
const request = indexedDB.open(databaseName);
|
|
735
|
+
request.addEventListener("success", () => {
|
|
736
|
+
resolve(request.result);
|
|
737
|
+
});
|
|
738
|
+
request.addEventListener("error", () => {
|
|
739
|
+
reject(request.error ?? /* @__PURE__ */ new Error(`Unable to open ${databaseName}`));
|
|
740
|
+
});
|
|
741
|
+
});
|
|
742
|
+
try {
|
|
743
|
+
if (!database.objectStoreNames.contains(storeName)) return [];
|
|
744
|
+
const request = database.transaction(storeName, "readonly").objectStore(storeName).getAll();
|
|
745
|
+
return await new Promise((resolve, reject) => {
|
|
746
|
+
request.addEventListener("success", () => {
|
|
747
|
+
resolve(request.result.filter((item) => typeof item === "object" && item !== null));
|
|
748
|
+
});
|
|
749
|
+
request.addEventListener("error", () => {
|
|
750
|
+
reject(request.error ?? /* @__PURE__ */ new Error(`Unable to read ${storeName}`));
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
} finally {
|
|
754
|
+
database.close();
|
|
755
|
+
}
|
|
756
|
+
};
|
|
757
|
+
const accounts = await readItems(accountsDatabase);
|
|
758
|
+
const accountIds = /* @__PURE__ */ new Set();
|
|
759
|
+
for (const account of accounts) if ("id" in account && typeof account.id === "string") accountIds.add(account.id);
|
|
760
|
+
if (accountIds.size === 0) return false;
|
|
761
|
+
return (await readItems(preferencesDatabase)).some((preference) => "currentAccountId" in preference && typeof preference.currentAccountId === "string" && accountIds.has(preference.currentAccountId));
|
|
762
|
+
}, {
|
|
763
|
+
accountsDatabase: ACCOUNTS_DATABASE,
|
|
764
|
+
preferencesDatabase: PREFERENCES_DATABASE,
|
|
765
|
+
storeName: ITEMS_STORE
|
|
766
|
+
});
|
|
759
767
|
const fclick = async (page, text, timeoutMs = 15e3) => {
|
|
760
768
|
const target = page.getByText(text, { exact: true }).first();
|
|
761
769
|
if (!await target.waitFor({
|
|
@@ -796,7 +804,7 @@ const slush = {
|
|
|
796
804
|
await fclick(page, "Import existing from passphrase");
|
|
797
805
|
await sleep(1500);
|
|
798
806
|
const words = seedPhrase.trim().split(new RegExp("\\s+", "v"));
|
|
799
|
-
for (let i = 0; i < words.length; i++) await page.
|
|
807
|
+
for (let i = 0; i < words.length; i++) await page.getByLabel(`Word ${i + 1}`, { exact: true }).fill(words[i] ?? "");
|
|
800
808
|
await page.getByRole("button", { name: "Next" }).click();
|
|
801
809
|
await sleep(2e3);
|
|
802
810
|
await page.locator("input[placeholder=\"Password\"]").fill(password);
|
|
@@ -809,6 +817,11 @@ const slush = {
|
|
|
809
817
|
message: `Slush import never reached the wallet home (${HOME_ROUTE})`,
|
|
810
818
|
timeoutMs: IMPORT_HOME_TIMEOUT_MS
|
|
811
819
|
});
|
|
820
|
+
await waitUntilOrThrow(() => walletPersisted(page), {
|
|
821
|
+
intervalMs: 1e3,
|
|
822
|
+
message: "Slush import never persisted an account and its selected preference",
|
|
823
|
+
timeoutMs: PERSIST_TIMEOUT_MS
|
|
824
|
+
});
|
|
812
825
|
},
|
|
813
826
|
notificationMatch: "isPopup=1",
|
|
814
827
|
onboardingPage: "index.html",
|
|
@@ -913,38 +926,86 @@ const extensionContextOptions = (extensionPath, headless) => ({
|
|
|
913
926
|
const NAVIGATION_TIMEOUT_MS = 15e3;
|
|
914
927
|
const FLUSH_SETTLE_MS = 3e3;
|
|
915
928
|
const STATE_WRITE_TIMEOUT_MS = 1e4;
|
|
916
|
-
/**
|
|
917
|
-
* Chrome stores an extension's `chrome.storage.local` under `Local Extension Settings/<id>` and its
|
|
918
|
-
* IndexedDB (what some single-page wallets persist their vault to) under
|
|
919
|
-
* `IndexedDB/chrome-extension_<id>_0…`. Bytes in either one mean onboarding actually landed in the
|
|
920
|
-
* profile; bytes in neither mean the "cache" is empty, and every later run would fail at unlock with
|
|
921
|
-
* no hint that the build was the thing that went wrong.
|
|
922
|
-
*/
|
|
929
|
+
/** Wallet state may live in `chrome.storage.local` or extension IndexedDB. */
|
|
923
930
|
const hasPersistedState = async (profileDir, extensionId) => {
|
|
924
931
|
const stores = [extensionStateDir(profileDir, extensionId), path.join(profileDir, "Default", "IndexedDB", `chrome-extension_${extensionId}_0.indexeddb.leveldb`)];
|
|
925
932
|
return (await Promise.allSettled(stores.map((dir) => readdir(dir)))).some((listing) => listing.status === "fulfilled" && listing.value.length > 0);
|
|
926
933
|
};
|
|
927
934
|
/**
|
|
928
|
-
*
|
|
929
|
-
*
|
|
930
|
-
* directory. Idempotent per (wallet, version, seed, password).
|
|
931
|
-
*
|
|
932
|
-
* `headless` only affects this build step; the tests choose their own mode via `launchWallet`.
|
|
935
|
+
* Prefix for an in-progress build. Inside `cacheDir` so publishing is a `rename` on one filesystem,
|
|
936
|
+
* dot-prefixed so it can never be mistaken for a 20-hex-character `profileKey`.
|
|
933
937
|
*/
|
|
934
|
-
const
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
938
|
+
const BUILD_PREFIX = ".building-";
|
|
939
|
+
const PREVIOUS_SUFFIX = ".previous";
|
|
940
|
+
const pathExists = async (target) => {
|
|
941
|
+
try {
|
|
942
|
+
await stat(target);
|
|
943
|
+
return true;
|
|
944
|
+
} catch (error) {
|
|
945
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
|
|
946
|
+
throw error;
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
const previousProfileDir = (profileDir) => `${profileDir}${PREVIOUS_SUFFIX}`;
|
|
950
|
+
const restorePreviousProfile = async (profileDir) => {
|
|
951
|
+
const previous = previousProfileDir(profileDir);
|
|
952
|
+
if (await pathExists(profileDir)) {
|
|
953
|
+
await rm(previous, {
|
|
954
|
+
force: true,
|
|
955
|
+
recursive: true
|
|
956
|
+
});
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
try {
|
|
960
|
+
await rename(previous, profileDir);
|
|
961
|
+
} catch (error) {
|
|
962
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
const publishProfile = async (staging, profileDir) => {
|
|
966
|
+
await restorePreviousProfile(profileDir);
|
|
967
|
+
const previous = previousProfileDir(profileDir);
|
|
968
|
+
let movedPrevious = false;
|
|
969
|
+
try {
|
|
970
|
+
await rename(profileDir, previous);
|
|
971
|
+
movedPrevious = true;
|
|
972
|
+
} catch (error) {
|
|
973
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
|
974
|
+
}
|
|
975
|
+
try {
|
|
976
|
+
await rename(staging, profileDir);
|
|
977
|
+
} catch (error) {
|
|
978
|
+
if (movedPrevious) try {
|
|
979
|
+
await rename(previous, profileDir);
|
|
980
|
+
} catch (rollbackError) {
|
|
981
|
+
const publishMessage = error instanceof Error ? error.message : String(error);
|
|
982
|
+
const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
|
|
983
|
+
throw new Error(`[walletwright] failed to publish cache at ${profileDir}: ${publishMessage}; rollback failed: ${rollbackMessage}`, { cause: rollbackError });
|
|
984
|
+
}
|
|
985
|
+
throw error;
|
|
986
|
+
}
|
|
987
|
+
if (movedPrevious) await rm(previous, {
|
|
940
988
|
force: true,
|
|
941
989
|
recursive: true
|
|
942
990
|
});
|
|
943
|
-
|
|
944
|
-
|
|
991
|
+
};
|
|
992
|
+
const defaultBuildCacheDependencies = {
|
|
993
|
+
launchPersistentContext: chromium.launchPersistentContext.bind(chromium),
|
|
994
|
+
prepareExtension: (definition, cacheDir, version) => definition.prepareExtension(cacheDir, version)
|
|
995
|
+
};
|
|
996
|
+
const buildCacheWithDependencies = async (setup, options = {}, dependencies = defaultBuildCacheDependencies) => {
|
|
997
|
+
const definition = wallets[setup.wallet];
|
|
998
|
+
const cacheDir = path.resolve(setup.cacheDir ?? ".walletwright");
|
|
999
|
+
const extensionPath = await dependencies.prepareExtension(definition, cacheDir, setup.version);
|
|
1000
|
+
const profileDir = path.join(cacheDir, profileKey(setup));
|
|
1001
|
+
await restorePreviousProfile(profileDir);
|
|
1002
|
+
await mkdir(cacheDir, { recursive: true });
|
|
1003
|
+
const staging = await mkdtemp(path.join(cacheDir, BUILD_PREFIX));
|
|
1004
|
+
let context;
|
|
945
1005
|
try {
|
|
1006
|
+
context = await dependencies.launchPersistentContext(staging, extensionContextOptions(extensionPath, options.headless === true));
|
|
946
1007
|
await definition.prepareContext?.(context);
|
|
947
|
-
const extensionId = extensionIdFromPath(extensionPath);
|
|
1008
|
+
const extensionId = await extensionIdFromPath(extensionPath);
|
|
948
1009
|
const page = context.pages().find((candidate) => candidate.url() === "about:blank") ?? await context.newPage();
|
|
949
1010
|
await gotoWithRetry(page, `chrome-extension://${extensionId}/${definition.onboardingPage}`, {
|
|
950
1011
|
label: `${definition.extensionName} onboarding page`,
|
|
@@ -953,20 +1014,27 @@ const buildCache = async (setup, options = {}) => {
|
|
|
953
1014
|
await sleep(2e3);
|
|
954
1015
|
await definition.importWallet(page, setup.seedPhrase, setup.password);
|
|
955
1016
|
await sleep(FLUSH_SETTLE_MS);
|
|
956
|
-
await waitUntilOrThrow(() => hasPersistedState(
|
|
1017
|
+
await waitUntilOrThrow(() => hasPersistedState(staging, extensionId), {
|
|
957
1018
|
intervalMs: 500,
|
|
958
|
-
message: `${definition.extensionName} onboarding wrote no wallet state into ${
|
|
1019
|
+
message: `${definition.extensionName} onboarding wrote no wallet state into ${staging}`,
|
|
959
1020
|
timeoutMs: STATE_WRITE_TIMEOUT_MS
|
|
960
1021
|
});
|
|
961
1022
|
await context.close();
|
|
962
|
-
await definition.finalizeCache?.(
|
|
1023
|
+
await definition.finalizeCache?.(staging, extensionId);
|
|
1024
|
+
await publishProfile(staging, profileDir);
|
|
963
1025
|
return profileDir;
|
|
964
1026
|
} catch (error) {
|
|
965
|
-
await context
|
|
1027
|
+
await context?.close().catch(() => {});
|
|
966
1028
|
throw error;
|
|
1029
|
+
} finally {
|
|
1030
|
+
await rm(staging, {
|
|
1031
|
+
force: true,
|
|
1032
|
+
recursive: true
|
|
1033
|
+
});
|
|
967
1034
|
}
|
|
968
1035
|
};
|
|
1036
|
+
const buildCache = (setup, options) => buildCacheWithDependencies(setup, options);
|
|
969
1037
|
|
|
970
1038
|
//#endregion
|
|
971
|
-
export {
|
|
972
|
-
//# sourceMappingURL=cache-
|
|
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
|