@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.
- package/CHANGELOG.md +24 -0
- package/README.md +19 -15
- package/dist/{cache-BaWu5vyM.mjs → cache-ztGj4R8m.mjs} +315 -397
- package/dist/cache-ztGj4R8m.mjs.map +1 -0
- package/dist/chain.d.mts +3 -3
- package/dist/cli.d.mts +4 -4
- package/dist/cli.mjs +52 -39
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +17 -9
- package/dist/index.mjs +226 -75
- package/dist/index.mjs.map +1 -1
- package/dist/mock-standard.d.mts +5 -0
- package/dist/mock-standard.mjs +9 -7
- package/dist/mock-standard.mjs.map +1 -1
- package/dist/mock.d.mts +2 -2
- package/dist/mock.mjs +5 -3
- package/dist/mock.mjs.map +1 -1
- package/dist/{types-BXDYOEaq.d.mts → types-CjNm7Tik.d.mts} +63 -19
- package/package.json +8 -8
- package/dist/cache-BaWu5vyM.mjs.map +0 -1
|
@@ -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/
|
|
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
|
-
*
|
|
219
|
-
*
|
|
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
|
|
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
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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/
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
237
|
+
return await operation();
|
|
349
238
|
} catch (error) {
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
357
|
-
|
|
358
|
-
|
|
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/
|
|
254
|
+
//#region src/internal/profile.ts
|
|
362
255
|
const DEFAULT_CACHE_DIR = ".walletwright";
|
|
363
|
-
|
|
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
|
-
/**
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
389
|
-
*
|
|
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
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
/**
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
const
|
|
416
|
-
await
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
/**
|
|
504
|
-
const
|
|
505
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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
|
|
936
|
-
*
|
|
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
|
|
939
|
-
|
|
940
|
-
const
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
|
|
950
|
-
const
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
await
|
|
961
|
-
|
|
962
|
-
|
|
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
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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
|
|
971
|
-
|
|
972
|
-
} catch
|
|
973
|
-
|
|
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
|
|
882
|
+
await definition.prepareContext?.(context);
|
|
883
|
+
return {
|
|
884
|
+
context,
|
|
885
|
+
extensionId: await extensionIdFromPath(extensionPath)
|
|
886
|
+
};
|
|
977
887
|
} catch (error) {
|
|
978
|
-
|
|
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
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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
|
|
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 =
|
|
999
|
-
const extensionPath = await dependencies.prepareExtension(
|
|
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
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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 {
|
|
1040
|
-
//# sourceMappingURL=cache-
|
|
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
|