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