@walletwright/core 0.2.1 → 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 +42 -0
- package/README.md +2 -2
- package/dist/{cache-Bwv2i-hv.mjs → cache-CHP-6eU0.mjs} +144 -148
- package/dist/cache-CHP-6eU0.mjs.map +1 -0
- 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 +82 -83
- package/dist/index.mjs.map +1 -1
- package/dist/mock-standard.d.mts +4 -19
- package/dist/mock-standard.mjs +6 -9
- package/dist/mock-standard.mjs.map +1 -1
- package/dist/mock.d.mts +1 -6
- package/dist/mock.mjs +6 -7
- package/dist/mock.mjs.map +1 -1
- package/dist/{types-CWxz_dJe.d.mts → types-CFQpWZhv.d.mts} +7 -30
- package/package.json +1 -1
- package/dist/cache-Bwv2i-hv.mjs.map +0 -1
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as walletKindsByEcosystem, c as DEFAULT_NOTIFICATION_MATCH, d as placeApprovalWindow, f as profileKey, i as isWalletKind, l as extensionIdFromPath, m as waitUntil, n as restorePreviousProfile, o as wallets, p as formatTimeout, r as extensionContextOptions, s as DEFAULT_CACHE_DIR, t as buildCache, u as findNotificationPopup } from "./cache-CHP-6eU0.mjs";
|
|
2
2
|
import { cp, mkdtemp, rm } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { chromium, test } from "@playwright/test";
|
|
@@ -11,6 +11,22 @@ const POPUP_CLOSE_TIMEOUT_MS = 15e3;
|
|
|
11
11
|
const OPTIONAL_POPUP_TIMEOUT_MS = 1e4;
|
|
12
12
|
/** The MV3 service worker spawns a popup slowly once the wallet's own UI has been driven. */
|
|
13
13
|
const REQUIRED_POPUP_TIMEOUT_MS = 3e4;
|
|
14
|
+
/**
|
|
15
|
+
* Drive `popup` with `settle` and return the page that actually took the approval. A popup that
|
|
16
|
+
* closes mid-settle is the wallet respawning the request, so re-acquire once and drive the new one.
|
|
17
|
+
*/
|
|
18
|
+
const settleApproval = async (settle, popup, acquire) => {
|
|
19
|
+
try {
|
|
20
|
+
await settle(popup);
|
|
21
|
+
return popup;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (!popup.isClosed()) throw error;
|
|
24
|
+
const fresh = await acquire();
|
|
25
|
+
if (!fresh) throw error;
|
|
26
|
+
await settle(fresh);
|
|
27
|
+
return fresh;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
14
30
|
/** Build the runtime controller that drives an unlocked wallet against the dapp under test. */
|
|
15
31
|
const createWallet = ({ context, definition, extensionId, home, password }) => {
|
|
16
32
|
const match = definition.notificationMatch ?? "notification.html";
|
|
@@ -20,60 +36,39 @@ const createWallet = ({ context, definition, extensionId, home, password }) => {
|
|
|
20
36
|
home,
|
|
21
37
|
password
|
|
22
38
|
};
|
|
23
|
-
/**
|
|
24
|
-
* Make the dapp the active tab. While an extension page holds that spot, MetaMask renders a new
|
|
25
|
-
* approval inline in it rather than opening a notification window, and the window that does open
|
|
26
|
-
* shows the wallet home instead of the request. The wallet's own page can end up fronted both
|
|
27
|
-
* after an action and after a previous approval settles.
|
|
28
|
-
*/
|
|
39
|
+
/** MetaMask opens requests inline while its extension page owns focus. */
|
|
29
40
|
const frontDapp = async () => {
|
|
30
41
|
await context.pages().find((page) => new RegExp("^https?:", "v").test(page.url()) && !page.isClosed())?.bringToFront().catch(() => {});
|
|
31
42
|
};
|
|
32
|
-
/**
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
/** Find and place the approval window before its controls are driven. */
|
|
44
|
+
const acquireApproval = async (optional) => {
|
|
45
|
+
const popup = await findNotificationPopup({
|
|
46
|
+
approvalControls: definition.approvalControls,
|
|
47
|
+
context,
|
|
48
|
+
extensionId,
|
|
49
|
+
match,
|
|
50
|
+
timeoutMs: optional ? OPTIONAL_POPUP_TIMEOUT_MS : REQUIRED_POPUP_TIMEOUT_MS
|
|
51
|
+
});
|
|
52
|
+
if (popup) await placeApprovalWindow(popup);
|
|
53
|
+
return popup;
|
|
54
|
+
};
|
|
55
|
+
/** Drive the pending approval popup with `settle`, then wait for that popup to close. */
|
|
41
56
|
const resolvePopup = async (settle, { optional = false }) => {
|
|
42
57
|
await frontDapp();
|
|
43
|
-
const
|
|
44
|
-
const popup = await
|
|
58
|
+
const acquire = () => acquireApproval(optional);
|
|
59
|
+
const popup = await acquire();
|
|
45
60
|
if (!popup) {
|
|
46
61
|
if (optional) return;
|
|
47
62
|
const open = context.pages().map((page) => page.url());
|
|
48
63
|
throw new Error(`[walletwright] approval popup did not appear. Open pages: ${open.join(" | ") || "none"}`);
|
|
49
64
|
}
|
|
50
|
-
await
|
|
51
|
-
|
|
52
|
-
await settle(popup);
|
|
53
|
-
} catch (error) {
|
|
54
|
-
if (!popup.isClosed()) throw error;
|
|
55
|
-
const fresh = await find();
|
|
56
|
-
if (!fresh) throw error;
|
|
57
|
-
await settle(fresh);
|
|
58
|
-
}
|
|
59
|
-
const closed = await waitUntil(() => !hasNotificationPopup(context, extensionId, match), { timeoutMs: POPUP_CLOSE_TIMEOUT_MS });
|
|
60
|
-
if (!optional && closed === void 0) throw new Error(`[walletwright] approval popup did not close after ${formatTimeout(POPUP_CLOSE_TIMEOUT_MS)} (the approval may not have registered)`);
|
|
65
|
+
const driven = await settleApproval(settle, popup, acquire);
|
|
66
|
+
if (await waitUntil(() => driven.isClosed() || void 0, { timeoutMs: POPUP_CLOSE_TIMEOUT_MS }) === void 0) throw new Error(`[walletwright] approval popup did not close after ${formatTimeout(POPUP_CLOSE_TIMEOUT_MS)} (the approval may not have registered)`);
|
|
61
67
|
};
|
|
62
68
|
const unsupported = (name) => /* @__PURE__ */ new Error(`[walletwright] ${definition.extensionName} does not support ${name}()`);
|
|
63
69
|
const approve = (options = {}) => resolvePopup((popup) => definition.approve(popup, password), options);
|
|
64
|
-
const reject =
|
|
65
|
-
|
|
66
|
-
if (!rejectPopup) throw unsupported("reject");
|
|
67
|
-
await resolvePopup((popup) => rejectPopup(popup), options);
|
|
68
|
-
};
|
|
69
|
-
/**
|
|
70
|
-
* Bind an optional capability, or fail loudly naming the wallet and action. A wallet declares only
|
|
71
|
-
* what has been driven against the real extension, so an undeclared action is a real gap rather
|
|
72
|
-
* than something to swallow. Extra args (e.g. a network config) forward after the context.
|
|
73
|
-
*
|
|
74
|
-
* After the action, focus returns to the dapp (see `frontDapp`), since actions drive the wallet's
|
|
75
|
-
* own page via `bringToFront`.
|
|
76
|
-
*/
|
|
70
|
+
const reject = (options = {}) => resolvePopup((popup) => definition.reject(popup), options);
|
|
71
|
+
/** Bind a verified wallet action and restore focus to the dapp when it finishes. */
|
|
77
72
|
const action = (fn, name) => async (...args) => {
|
|
78
73
|
if (!fn) throw unsupported(name);
|
|
79
74
|
if (home.isClosed()) throw new Error(`[walletwright] wallet home page is closed; cannot run ${name}()`);
|
|
@@ -111,50 +106,64 @@ const createWallet = ({ context, definition, extensionId, home, password }) => {
|
|
|
111
106
|
|
|
112
107
|
//#endregion
|
|
113
108
|
//#region src/internal/launch.ts
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
109
|
+
const closeLaunch = async (context, runDir) => {
|
|
110
|
+
const closed = await Promise.allSettled([context.close()]);
|
|
111
|
+
const removed = await Promise.allSettled([rm(runDir, {
|
|
112
|
+
force: true,
|
|
113
|
+
recursive: true
|
|
114
|
+
})]);
|
|
115
|
+
const closeResult = closed[0];
|
|
116
|
+
const removeResult = removed[0];
|
|
117
|
+
if (closeResult?.status === "rejected" && removeResult?.status === "rejected") throw new AggregateError([closeResult.reason, removeResult.reason], `[walletwright] failed to close the browser and remove ${runDir}`);
|
|
118
|
+
if (closeResult?.status === "rejected") throw closeResult.reason;
|
|
119
|
+
if (removeResult?.status === "rejected") throw removeResult.reason;
|
|
120
|
+
};
|
|
121
|
+
/** Close Chromium's blank tab and the extension's auto-opened tab. */
|
|
120
122
|
const closeStrayPages = async (context, home) => {
|
|
121
123
|
const isStray = (page) => page !== home && !page.isClosed() && (page.url() === "about:blank" || page.url().startsWith("chrome-extension://"));
|
|
122
124
|
const closing = [];
|
|
123
125
|
for (const page of context.pages()) if (isStray(page)) closing.push(page.close());
|
|
124
126
|
await Promise.allSettled(closing);
|
|
125
127
|
};
|
|
126
|
-
/**
|
|
127
|
-
* Launch a fresh persistent context from the onboarded cache and return an unlocked wallet
|
|
128
|
-
* controller. Headed by default; `headless` needs the wallet to declare `headlessApprovals`, since
|
|
129
|
-
* some wallets create an approval window headless that is never exposed as a page. Outside
|
|
130
|
-
* Playwright fixtures, `context.close()` is yours.
|
|
131
|
-
*/
|
|
128
|
+
/** Launch and unlock a disposable profile copy. Headless mode requires verified wallet support. */
|
|
132
129
|
const launchWallet = async (setup, { headless = false } = {}) => {
|
|
133
130
|
const definition = wallets[setup.wallet];
|
|
134
131
|
if (headless && definition.headlessApprovals !== true) throw new Error(`[walletwright] ${definition.extensionName} has no verified headless approval flow; run this suite headed (\`use: { headless: false }\`, or \`--headed\`).`);
|
|
135
132
|
const cacheDir = path.resolve(setup.cacheDir ?? ".walletwright");
|
|
136
133
|
const profileDir = path.join(cacheDir, profileKey(setup));
|
|
134
|
+
await restorePreviousProfile(profileDir);
|
|
137
135
|
if (!existsSync(profileDir)) throw new Error(`[walletwright] no cache for this setup at ${profileDir}. Build it first with buildCache() or \`walletwright cache\`.`);
|
|
138
136
|
const extensionPath = await definition.prepareExtension(cacheDir, setup.version);
|
|
137
|
+
const extensionId = extensionIdFromPath(extensionPath);
|
|
138
|
+
/** Cleanup must cover copy and launch failures that occur before a context exists. */
|
|
139
139
|
const runDir = await mkdtemp(path.join(os.tmpdir(), "walletwright-"));
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
await rm(runDir, {
|
|
144
|
-
force: true,
|
|
145
|
-
recursive: true
|
|
146
|
-
}).catch(() => {});
|
|
140
|
+
const removeRunDir = () => rm(runDir, {
|
|
141
|
+
force: true,
|
|
142
|
+
recursive: true
|
|
147
143
|
});
|
|
148
|
-
const
|
|
144
|
+
const handleContextClose = async () => {
|
|
145
|
+
try {
|
|
146
|
+
await removeRunDir();
|
|
147
|
+
} catch {}
|
|
148
|
+
};
|
|
149
|
+
let context;
|
|
149
150
|
try {
|
|
150
|
-
await
|
|
151
|
-
|
|
151
|
+
await cp(profileDir, runDir, { recursive: true });
|
|
152
|
+
context = await chromium.launchPersistentContext(runDir, extensionContextOptions(extensionPath, headless));
|
|
153
|
+
const launched = context;
|
|
154
|
+
launched.on("close", handleContextClose);
|
|
155
|
+
await definition.prepareContext?.(launched);
|
|
156
|
+
const home = await definition.reachUnlockScreen(launched, extensionId);
|
|
152
157
|
await definition.unlock(home, setup.password);
|
|
153
|
-
await closeStrayPages(
|
|
158
|
+
await closeStrayPages(launched, home);
|
|
154
159
|
return {
|
|
155
|
-
|
|
160
|
+
close: async () => {
|
|
161
|
+
launched.off("close", handleContextClose);
|
|
162
|
+
await closeLaunch(launched, runDir);
|
|
163
|
+
},
|
|
164
|
+
context: launched,
|
|
156
165
|
wallet: createWallet({
|
|
157
|
-
context,
|
|
166
|
+
context: launched,
|
|
158
167
|
definition,
|
|
159
168
|
extensionId,
|
|
160
169
|
home,
|
|
@@ -162,26 +171,16 @@ const launchWallet = async (setup, { headless = false } = {}) => {
|
|
|
162
171
|
})
|
|
163
172
|
};
|
|
164
173
|
} catch (error) {
|
|
165
|
-
|
|
174
|
+
context?.off("close", handleContextClose);
|
|
175
|
+
await context?.close().catch(() => {});
|
|
176
|
+
await removeRunDir().catch(() => {});
|
|
166
177
|
throw error;
|
|
167
178
|
}
|
|
168
179
|
};
|
|
169
180
|
|
|
170
181
|
//#endregion
|
|
171
182
|
//#region src/fixtures.ts
|
|
172
|
-
/**
|
|
173
|
-
* Build a Playwright `test` whose `context`/`page` run inside a persistent Chromium with the wallet
|
|
174
|
-
* loaded and unlocked, exposing a `wallet` fixture to approve connect/sign popups.
|
|
175
|
-
*
|
|
176
|
-
* ```ts
|
|
177
|
-
* const test = createWalletFixtures(setup);
|
|
178
|
-
* test("connect", async ({ page, wallet }) => {
|
|
179
|
-
* await page.goto("/");
|
|
180
|
-
* await page.getByRole("button", { name: "Connect" }).click();
|
|
181
|
-
* await wallet.connectToDapp();
|
|
182
|
-
* });
|
|
183
|
-
* ```
|
|
184
|
-
*/
|
|
183
|
+
/** Create Playwright fixtures backed by one unlocked wallet launch per test. */
|
|
185
184
|
const createWalletFixtures = (setup) => test.extend({
|
|
186
185
|
context: async ({ walletLaunch }, use) => {
|
|
187
186
|
await use(walletLaunch.context);
|
|
@@ -192,10 +191,10 @@ const createWalletFixtures = (setup) => test.extend({
|
|
|
192
191
|
walletLaunch: async ({ headless }, use) => {
|
|
193
192
|
const launched = await launchWallet(setup, { headless });
|
|
194
193
|
await use(launched);
|
|
195
|
-
await launched.
|
|
194
|
+
await launched.close();
|
|
196
195
|
}
|
|
197
196
|
});
|
|
198
197
|
|
|
199
198
|
//#endregion
|
|
200
|
-
export { buildCache, createWalletFixtures, launchWallet, walletKindsByEcosystem, wallets };
|
|
199
|
+
export { buildCache, createWalletFixtures, isWalletKind, launchWallet, walletKindsByEcosystem, wallets };
|
|
201
200
|
//# sourceMappingURL=index.mjs.map
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":["base"],"sources":["../src/internal/controller.ts","../src/internal/launch.ts","../src/fixtures.ts"],"sourcesContent":["import type { BrowserContext, Page } from \"@playwright/test\";\n\nimport type { Wallet, WalletActionContext, WalletDefinition } from \"../types\";\n\nimport {\n DEFAULT_NOTIFICATION_MATCH,\n findNotificationPopup,\n hasNotificationPopup,\n placeApprovalWindow,\n} from \"./utils\";\nimport { formatTimeout, waitUntil } from \"./wait\";\n\nconst POPUP_CLOSE_TIMEOUT_MS = 15_000;\n/** Optional approvals keep a short wait: \"no popup\" is a normal outcome, and the wait is latency. */\nconst OPTIONAL_POPUP_TIMEOUT_MS = 10_000;\n/** The MV3 service worker spawns a popup slowly once the wallet's own UI has been driven. */\nconst REQUIRED_POPUP_TIMEOUT_MS = 30_000;\ntype ResolveOptions = { optional?: boolean };\n\ntype CreateWalletOptions = {\n context: BrowserContext;\n definition: WalletDefinition;\n extensionId: string;\n /** The wallet's own page, left open after unlock, that the actions drive. */\n home: Page;\n password: string;\n};\n\n/** Build the runtime controller that drives an unlocked wallet against the dapp under test. */\nexport const createWallet = ({\n context,\n definition,\n extensionId,\n home,\n password,\n}: CreateWalletOptions): Wallet => {\n const match = definition.notificationMatch ?? DEFAULT_NOTIFICATION_MATCH;\n const ctx: WalletActionContext = { context, extensionId, home, password };\n\n /**\n * Make the dapp the active tab. While an extension page holds that spot, MetaMask renders a new\n * approval inline in it rather than opening a notification window, and the window that does open\n * shows the wallet home instead of the request. The wallet's own page can end up fronted both\n * after an action and after a previous approval settles.\n */\n const frontDapp = async (): Promise<void> => {\n const dapp = context.pages().find((page) => /^https?:/v.test(page.url()) && !page.isClosed());\n await dapp?.bringToFront().catch(() => {});\n };\n\n /** Wait for the approval window the wallet opens for a pending request. */\n const findApproval = (optional: boolean): Promise<Page | undefined> =>\n findNotificationPopup({\n approvalControls: definition.approvalControls,\n context,\n extensionId,\n match,\n timeoutMs: optional ? OPTIONAL_POPUP_TIMEOUT_MS : REQUIRED_POPUP_TIMEOUT_MS,\n });\n\n /** Drive the pending approval popup with `settle`, then wait for it to close. */\n const resolvePopup = async (\n settle: (popup: Page) => Promise<void>,\n { optional = false }: ResolveOptions,\n ): Promise<void> => {\n await frontDapp();\n const find = () => findApproval(optional);\n const popup = await find();\n if (!popup) {\n if (optional) {\n return; // e.g. Phantom auto-approves an already-trusted site (no popup)\n }\n const open = context.pages().map((page) => page.url());\n throw new Error(\n `[walletwright] approval popup did not appear. Open pages: ${open.join(\" | \") || \"none\"}`,\n );\n }\n await placeApprovalWindow(popup); // it can open partly off a small or virtual display\n try {\n await settle(popup);\n } catch (error) {\n if (!popup.isClosed()) {\n throw error;\n }\n const fresh = await find();\n if (!fresh) {\n throw error;\n }\n await settle(fresh);\n }\n\n const closed = await waitUntil(() => !hasNotificationPopup(context, extensionId, match), {\n timeoutMs: POPUP_CLOSE_TIMEOUT_MS,\n });\n if (!optional && closed === undefined) {\n throw new Error(\n `[walletwright] approval popup did not close after ${formatTimeout(POPUP_CLOSE_TIMEOUT_MS)} (the approval may not have registered)`,\n );\n }\n };\n\n const unsupported = (name: string): Error =>\n new Error(`[walletwright] ${definition.extensionName} does not support ${name}()`);\n\n const approve = (options: ResolveOptions = {}) =>\n resolvePopup((popup) => definition.approve(popup, password), options);\n\n const reject = async (options: ResolveOptions = {}): Promise<void> => {\n const { reject: rejectPopup } = definition;\n if (!rejectPopup) {\n throw unsupported(\"reject\");\n }\n await resolvePopup((popup) => rejectPopup(popup), options);\n };\n\n /**\n * Bind an optional capability, or fail loudly naming the wallet and action. A wallet declares only\n * what has been driven against the real extension, so an undeclared action is a real gap rather\n * than something to swallow. Extra args (e.g. a network config) forward after the context.\n *\n * After the action, focus returns to the dapp (see `frontDapp`), since actions drive the wallet's\n * own page via `bringToFront`.\n */\n const action =\n <A extends ReadonlyArray<unknown>>(\n fn: ((ctx: WalletActionContext, ...args: A) => Promise<void>) | undefined,\n name: string,\n ) =>\n async (...args: A): Promise<void> => {\n if (!fn) {\n throw unsupported(name);\n }\n if (home.isClosed()) {\n throw new Error(`[walletwright] wallet home page is closed; cannot run ${name}()`);\n }\n await home.bringToFront().catch(() => {});\n await fn(ctx, ...args);\n await frontDapp();\n };\n\n return {\n accounts: {\n add: action(definition.actions?.accounts?.add, \"accounts.add\"),\n importPrivateKey: action(\n definition.actions?.accounts?.importPrivateKey,\n \"accounts.importPrivateKey\",\n ),\n rename: action(definition.actions?.accounts?.rename, \"accounts.rename\"),\n switch: action(definition.actions?.accounts?.switch, \"accounts.switch\"),\n },\n approve,\n confirmSignature: () => approve(),\n confirmTransaction: () => approve(),\n connectToDapp: () => approve({ optional: true }),\n extensionId,\n home,\n network: {\n add: action(definition.actions?.network?.add, \"network.add\"),\n switch: action(definition.actions?.network?.switch, \"network.switch\"),\n },\n reject,\n rejectConnection: () => reject(),\n rejectSignature: () => reject(),\n rejectTransaction: () => reject(),\n settings: {\n lock: action(definition.actions?.settings?.lock, \"settings.lock\"),\n unlock: action(definition.actions?.settings?.unlock, \"settings.unlock\"),\n },\n };\n};\n","import { existsSync } from \"node:fs\";\nimport { cp, mkdtemp, rm } from \"node:fs/promises\";\nimport os from \"node:os\";\nimport path from \"node:path\";\n\nimport { type BrowserContext, chromium, type Page } from \"@playwright/test\";\n\nimport type { Wallet, WalletSetup } from \"../types\";\nimport { wallets } from \"../wallets/index\";\n\nimport { extensionContextOptions } from \"./chromium\";\nimport { createWallet } from \"./controller\";\nimport { DEFAULT_CACHE_DIR, extensionIdFromPath, profileKey } from \"./utils\";\n\nexport type LaunchedWallet = {\n context: BrowserContext;\n wallet: Wallet;\n};\n\n/**\n * Drop the tabs nobody drives: Chromium's initial `about:blank` and the extension's own auto-opened\n * tab. We navigate a tab of our own to the wallet instead of adopting that one (it's unreliable, see\n * `reachUnlockScreen`), which otherwise leaves a second, still-locked wallet tab open all run.\n * Approval popups are unaffected: they're matched by URL, not by being the only extension page.\n */\nconst closeStrayPages = async (context: BrowserContext, home: Page): Promise<void> => {\n const isStray = (page: Page): boolean =>\n page !== home &&\n !page.isClosed() &&\n (page.url() === \"about:blank\" || page.url().startsWith(\"chrome-extension://\"));\n\n const closing: Array<Promise<void>> = [];\n for (const page of context.pages()) {\n if (isStray(page)) {\n closing.push(page.close());\n }\n }\n await Promise.allSettled(closing);\n};\n\n/**\n * Launch a fresh persistent context from the onboarded cache and return an unlocked wallet\n * controller. Headed by default; `headless` needs the wallet to declare `headlessApprovals`, since\n * some wallets create an approval window headless that is never exposed as a page. Outside\n * Playwright fixtures, `context.close()` is yours.\n */\nexport const launchWallet = async (\n setup: WalletSetup,\n { headless = false }: { headless?: boolean } = {},\n): Promise<LaunchedWallet> => {\n const definition = wallets[setup.wallet];\n if (headless && definition.headlessApprovals !== true) {\n throw new Error(\n `[walletwright] ${definition.extensionName} has no verified headless approval flow; run this suite headed (\\`use: { headless: false }\\`, or \\`--headed\\`).`,\n );\n }\n const cacheDir = path.resolve(setup.cacheDir ?? DEFAULT_CACHE_DIR);\n const profileDir = path.join(cacheDir, profileKey(setup));\n if (!existsSync(profileDir)) {\n throw new Error(\n `[walletwright] no cache for this setup at ${profileDir}. Build it first with buildCache() or \\`walletwright cache\\`.`,\n );\n }\n\n const extensionPath = await definition.prepareExtension(cacheDir, setup.version);\n\n const runDir = await mkdtemp(path.join(os.tmpdir(), \"walletwright-\"));\n await cp(profileDir, runDir, { recursive: true });\n\n const context = await chromium.launchPersistentContext(\n runDir,\n extensionContextOptions(extensionPath, headless),\n );\n\n context.on(\"close\", async () => {\n await rm(runDir, { force: true, recursive: true }).catch(() => {});\n });\n\n const extensionId = extensionIdFromPath(extensionPath);\n\n try {\n await definition.prepareContext?.(context);\n const home = await definition.reachUnlockScreen(context, extensionId);\n await definition.unlock(home, setup.password);\n await closeStrayPages(context, home);\n\n return {\n context,\n wallet: createWallet({\n context,\n definition,\n extensionId,\n home,\n password: setup.password,\n }),\n };\n } catch (error) {\n await context.close().catch(() => {});\n throw error;\n }\n};\n","import { test as base } from \"@playwright/test\";\n\nimport { type LaunchedWallet, launchWallet } from \"./internal/launch\";\nimport type { Wallet, WalletSetup } from \"./types\";\n\nexport type WalletFixtures = {\n wallet: Wallet;\n};\n\n/** One launch per test, which the `context` and `wallet` fixtures then read from. */\ntype LaunchFixture = {\n walletLaunch: LaunchedWallet;\n};\n\n/**\n * Build a Playwright `test` whose `context`/`page` run inside a persistent Chromium with the wallet\n * loaded and unlocked, exposing a `wallet` fixture to approve connect/sign popups.\n *\n * ```ts\n * const test = createWalletFixtures(setup);\n * test(\"connect\", async ({ page, wallet }) => {\n * await page.goto(\"/\");\n * await page.getByRole(\"button\", { name: \"Connect\" }).click();\n * await wallet.connectToDapp();\n * });\n * ```\n */\nexport const createWalletFixtures = (setup: WalletSetup) =>\n base.extend<LaunchFixture & WalletFixtures>({\n context: async ({ walletLaunch }, use) => {\n await use(walletLaunch.context);\n },\n wallet: async ({ walletLaunch }, use) => {\n await use(walletLaunch.wallet);\n },\n walletLaunch: async ({ headless }, use) => {\n const launched = await launchWallet(setup, { headless });\n await use(launched);\n await launched.context.close();\n },\n });\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["base"],"sources":["../src/internal/controller.ts","../src/internal/launch.ts","../src/fixtures.ts"],"sourcesContent":["import type { BrowserContext, Page } from \"@playwright/test\";\n\nimport type { Wallet, WalletActionContext, WalletDefinition } from \"../types\";\n\nimport { DEFAULT_NOTIFICATION_MATCH, findNotificationPopup, placeApprovalWindow } from \"./utils\";\nimport { formatTimeout, waitUntil } from \"./wait\";\n\nconst POPUP_CLOSE_TIMEOUT_MS = 15_000;\n/** Optional approvals keep a short wait: \"no popup\" is a normal outcome, and the wait is latency. */\nconst OPTIONAL_POPUP_TIMEOUT_MS = 10_000;\n/** The MV3 service worker spawns a popup slowly once the wallet's own UI has been driven. */\nconst REQUIRED_POPUP_TIMEOUT_MS = 30_000;\ntype ResolveOptions = { optional?: boolean };\n\n/**\n * Drive `popup` with `settle` and return the page that actually took the approval. A popup that\n * closes mid-settle is the wallet respawning the request, so re-acquire once and drive the new one.\n */\nconst settleApproval = async (\n settle: (popup: Page) => Promise<void>,\n popup: Page,\n acquire: () => Promise<Page | undefined>,\n): Promise<Page> => {\n try {\n await settle(popup);\n return popup;\n } catch (error) {\n if (!popup.isClosed()) {\n throw error;\n }\n const fresh = await acquire();\n if (!fresh) {\n throw error;\n }\n await settle(fresh);\n return fresh;\n }\n};\n\ntype CreateWalletOptions = {\n context: BrowserContext;\n definition: WalletDefinition;\n extensionId: string;\n /** The wallet's own page, left open after unlock, that the actions drive. */\n home: Page;\n password: string;\n};\n\n/** Build the runtime controller that drives an unlocked wallet against the dapp under test. */\nexport const createWallet = ({\n context,\n definition,\n extensionId,\n home,\n password,\n}: CreateWalletOptions): Wallet => {\n const match = definition.notificationMatch ?? DEFAULT_NOTIFICATION_MATCH;\n const ctx: WalletActionContext = { context, extensionId, home, password };\n\n /** MetaMask opens requests inline while its extension page owns focus. */\n const frontDapp = async (): Promise<void> => {\n const dapp = context.pages().find((page) => /^https?:/v.test(page.url()) && !page.isClosed());\n await dapp?.bringToFront().catch(() => {});\n };\n\n /** Find and place the approval window before its controls are driven. */\n const acquireApproval = async (optional: boolean): Promise<Page | undefined> => {\n const popup = await findNotificationPopup({\n approvalControls: definition.approvalControls,\n context,\n extensionId,\n match,\n timeoutMs: optional ? OPTIONAL_POPUP_TIMEOUT_MS : REQUIRED_POPUP_TIMEOUT_MS,\n });\n if (popup) {\n await placeApprovalWindow(popup);\n }\n return popup;\n };\n\n /** Drive the pending approval popup with `settle`, then wait for that popup to close. */\n const resolvePopup = async (\n settle: (popup: Page) => Promise<void>,\n { optional = false }: ResolveOptions,\n ): Promise<void> => {\n await frontDapp();\n const acquire = () => acquireApproval(optional);\n const popup = await acquire();\n if (!popup) {\n if (optional) {\n return; // e.g. Phantom auto-approves an already-trusted site (no popup)\n }\n const open = context.pages().map((page) => page.url());\n throw new Error(\n `[walletwright] approval popup did not appear. Open pages: ${open.join(\" | \") || \"none\"}`,\n );\n }\n\n const driven = await settleApproval(settle, popup, acquire);\n\n /** Wait for the exact popup we drove; chained popups may reuse the same URL. */\n const closed = await waitUntil(() => driven.isClosed() || undefined, {\n timeoutMs: POPUP_CLOSE_TIMEOUT_MS,\n });\n if (closed === undefined) {\n throw new Error(\n `[walletwright] approval popup did not close after ${formatTimeout(POPUP_CLOSE_TIMEOUT_MS)} (the approval may not have registered)`,\n );\n }\n };\n\n const unsupported = (name: string): Error =>\n new Error(`[walletwright] ${definition.extensionName} does not support ${name}()`);\n\n const approve = (options: ResolveOptions = {}) =>\n resolvePopup((popup) => definition.approve(popup, password), options);\n\n const reject = (options: ResolveOptions = {}) =>\n resolvePopup((popup) => definition.reject(popup), options);\n\n /** Bind a verified wallet action and restore focus to the dapp when it finishes. */\n const action =\n <A extends ReadonlyArray<unknown>>(\n fn: ((ctx: WalletActionContext, ...args: A) => Promise<void>) | undefined,\n name: string,\n ) =>\n async (...args: A): Promise<void> => {\n if (!fn) {\n throw unsupported(name);\n }\n if (home.isClosed()) {\n throw new Error(`[walletwright] wallet home page is closed; cannot run ${name}()`);\n }\n await home.bringToFront().catch(() => {});\n await fn(ctx, ...args);\n await frontDapp();\n };\n\n return {\n accounts: {\n add: action(definition.actions?.accounts?.add, \"accounts.add\"),\n importPrivateKey: action(\n definition.actions?.accounts?.importPrivateKey,\n \"accounts.importPrivateKey\",\n ),\n rename: action(definition.actions?.accounts?.rename, \"accounts.rename\"),\n switch: action(definition.actions?.accounts?.switch, \"accounts.switch\"),\n },\n approve,\n confirmSignature: () => approve(),\n confirmTransaction: () => approve(),\n connectToDapp: () => approve({ optional: true }),\n extensionId,\n home,\n network: {\n add: action(definition.actions?.network?.add, \"network.add\"),\n switch: action(definition.actions?.network?.switch, \"network.switch\"),\n },\n reject,\n rejectConnection: () => reject(),\n rejectSignature: () => reject(),\n rejectTransaction: () => reject(),\n settings: {\n lock: action(definition.actions?.settings?.lock, \"settings.lock\"),\n unlock: action(definition.actions?.settings?.unlock, \"settings.unlock\"),\n },\n };\n};\n","import { existsSync } from \"node:fs\";\nimport { cp, mkdtemp, rm } from \"node:fs/promises\";\nimport os from \"node:os\";\nimport path from \"node:path\";\n\nimport { type BrowserContext, chromium, type Page } from \"@playwright/test\";\n\nimport type { Wallet, WalletSetup } from \"../types\";\nimport { wallets } from \"../wallets/index\";\n\nimport { restorePreviousProfile } from \"./cache\";\nimport { extensionContextOptions } from \"./chromium\";\nimport { createWallet } from \"./controller\";\nimport { DEFAULT_CACHE_DIR, extensionIdFromPath, profileKey } from \"./utils\";\n\nexport type LaunchedWallet = {\n /**\n * Close the browser and remove the throwaway profile copy, in that order. Prefer this over\n * `context.close()`: the copy is a full onboarded profile (tens of MB), and awaiting its removal is\n * the only way to know it is gone before the process exits.\n */\n close: () => Promise<void>;\n context: BrowserContext;\n wallet: Wallet;\n};\n\nconst closeLaunch = async (context: BrowserContext, runDir: string): Promise<void> => {\n const closed = await Promise.allSettled([context.close()]);\n const removed = await Promise.allSettled([rm(runDir, { force: true, recursive: true })]);\n const closeResult = closed[0];\n const removeResult = removed[0];\n\n if (closeResult?.status === \"rejected\" && removeResult?.status === \"rejected\") {\n throw new AggregateError(\n [closeResult.reason, removeResult.reason],\n `[walletwright] failed to close the browser and remove ${runDir}`,\n );\n }\n if (closeResult?.status === \"rejected\") {\n throw closeResult.reason;\n }\n if (removeResult?.status === \"rejected\") {\n throw removeResult.reason;\n }\n};\n\n/** Close Chromium's blank tab and the extension's auto-opened tab. */\nconst closeStrayPages = async (context: BrowserContext, home: Page): Promise<void> => {\n const isStray = (page: Page): boolean =>\n page !== home &&\n !page.isClosed() &&\n (page.url() === \"about:blank\" || page.url().startsWith(\"chrome-extension://\"));\n\n const closing: Array<Promise<void>> = [];\n for (const page of context.pages()) {\n if (isStray(page)) {\n closing.push(page.close());\n }\n }\n await Promise.allSettled(closing);\n};\n\n/** Launch and unlock a disposable profile copy. Headless mode requires verified wallet support. */\nexport const launchWallet = async (\n setup: WalletSetup,\n { headless = false }: { headless?: boolean } = {},\n): Promise<LaunchedWallet> => {\n const definition = wallets[setup.wallet];\n if (headless && definition.headlessApprovals !== true) {\n throw new Error(\n `[walletwright] ${definition.extensionName} has no verified headless approval flow; run this suite headed (\\`use: { headless: false }\\`, or \\`--headed\\`).`,\n );\n }\n const cacheDir = path.resolve(setup.cacheDir ?? DEFAULT_CACHE_DIR);\n const profileDir = path.join(cacheDir, profileKey(setup));\n await restorePreviousProfile(profileDir);\n if (!existsSync(profileDir)) {\n throw new Error(\n `[walletwright] no cache for this setup at ${profileDir}. Build it first with buildCache() or \\`walletwright cache\\`.`,\n );\n }\n\n const extensionPath = await definition.prepareExtension(cacheDir, setup.version);\n\n const extensionId = extensionIdFromPath(extensionPath);\n\n /** Cleanup must cover copy and launch failures that occur before a context exists. */\n const runDir = await mkdtemp(path.join(os.tmpdir(), \"walletwright-\"));\n const removeRunDir = (): Promise<void> => rm(runDir, { force: true, recursive: true });\n const handleContextClose = async (): Promise<void> => {\n try {\n await removeRunDir();\n } catch {\n // Playwright does not await close-event listeners; `close()` owns strict cleanup.\n }\n };\n\n let context: BrowserContext | undefined;\n try {\n await cp(profileDir, runDir, { recursive: true });\n\n context = await chromium.launchPersistentContext(\n runDir,\n extensionContextOptions(extensionPath, headless),\n );\n const launched = context;\n launched.on(\"close\", handleContextClose);\n\n await definition.prepareContext?.(launched);\n const home = await definition.reachUnlockScreen(launched, extensionId);\n await definition.unlock(home, setup.password);\n await closeStrayPages(launched, home);\n\n return {\n close: async () => {\n launched.off(\"close\", handleContextClose);\n await closeLaunch(launched, runDir);\n },\n context: launched,\n wallet: createWallet({\n context: launched,\n definition,\n extensionId,\n home,\n password: setup.password,\n }),\n };\n } catch (error) {\n context?.off(\"close\", handleContextClose);\n await context?.close().catch(() => {});\n await removeRunDir().catch(() => {});\n throw error;\n }\n};\n\nexport { closeLaunch };\n","import { test as base } from \"@playwright/test\";\n\nimport { type LaunchedWallet, launchWallet } from \"./internal/launch\";\nimport type { Wallet, WalletSetup } from \"./types\";\n\nexport type WalletFixtures = {\n wallet: Wallet;\n};\n\n/** One launch per test, which the `context` and `wallet` fixtures then read from. */\ntype LaunchFixture = {\n walletLaunch: LaunchedWallet;\n};\n\n/** Create Playwright fixtures backed by one unlocked wallet launch per test. */\nexport const createWalletFixtures = (setup: WalletSetup) =>\n base.extend<LaunchFixture & WalletFixtures>({\n context: async ({ walletLaunch }, use) => {\n await use(walletLaunch.context);\n },\n wallet: async ({ walletLaunch }, use) => {\n await use(walletLaunch.wallet);\n },\n walletLaunch: async ({ headless }, use) => {\n const launched = await launchWallet(setup, { headless });\n await use(launched);\n await launched.close();\n },\n });\n"],"mappings":"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"}
|
package/dist/mock-standard.d.mts
CHANGED
|
@@ -1,18 +1,7 @@
|
|
|
1
1
|
import { BrowserContext, Page } from "@playwright/test";
|
|
2
2
|
//#region src/mock-standard.d.ts
|
|
3
|
-
/**
|
|
4
|
-
* A headless Wallet-Standard fake for Solana dapp tests that don't need a real extension. It
|
|
5
|
-
* hand-rolls the Wallet-Standard registration protocol (no browser-side library) and answers
|
|
6
|
-
* `standard:connect` plus `solana:signMessage`, signing with a real ed25519 key so signatures
|
|
7
|
-
* verify. Isolated from the EVM `mock.ts`; `@walletwright/core/mock-standard` is its own entry
|
|
8
|
-
* point.
|
|
9
|
-
*
|
|
10
|
-
* ed25519 signing runs Node-side (viem is secp256k1-only), reusing the same exposeFunction bridge
|
|
11
|
-
* pattern as the EVM mock. No extra dependency: Node's built-in `crypto` signs ed25519 natively.
|
|
12
|
-
*/
|
|
13
|
-
type MockEcosystem = "svm";
|
|
3
|
+
/** Headless Wallet-Standard Solana mock that signs messages with Node's ed25519 implementation. */
|
|
14
4
|
type MockStandardWalletOptions = {
|
|
15
|
-
ecosystem?: MockEcosystem;
|
|
16
5
|
/** Wallet name announced over Wallet Standard. */
|
|
17
6
|
name?: string;
|
|
18
7
|
/** 32-byte ed25519 seed as hex. Defaults to a fixed test seed so the address is stable. */
|
|
@@ -25,17 +14,13 @@ type MockStandardAccount = {
|
|
|
25
14
|
};
|
|
26
15
|
declare const encodeBase58: (bytes: Uint8Array) => string;
|
|
27
16
|
declare const privateKeyFromSeed: (seed: Buffer) => import("node:crypto").KeyObject;
|
|
17
|
+
/** `message` is required: defaulting it would sign the empty message instead of rejecting. */
|
|
28
18
|
type BridgeRequest = {
|
|
29
|
-
message
|
|
19
|
+
message: Array<number>;
|
|
30
20
|
method: string;
|
|
31
21
|
};
|
|
32
22
|
declare const createStandardHandler: (seed: Buffer) => ({ message, method }: BridgeRequest) => Promise<Array<number>>;
|
|
33
|
-
/**
|
|
34
|
-
* Install the headless Wallet-Standard mock for Solana connect + sign on a context (every page) or a
|
|
35
|
-
* single page. Call before `goto`, so the wallet is registered when the dapp discovers wallets.
|
|
36
|
-
* Returns the account it announces so a test can verify signatures against the public key.
|
|
37
|
-
*/
|
|
38
23
|
declare const installMockStandardWallet: (target: BrowserContext | Page, options?: MockStandardWalletOptions) => Promise<MockStandardAccount>;
|
|
39
24
|
//#endregion
|
|
40
|
-
export { type
|
|
25
|
+
export { type MockStandardAccount, type MockStandardWalletOptions, createStandardHandler, encodeBase58, installMockStandardWallet, privateKeyFromSeed };
|
|
41
26
|
//# sourceMappingURL=mock-standard.d.mts.map
|
package/dist/mock-standard.mjs
CHANGED
|
@@ -35,7 +35,7 @@ const rawPublicKey = (seed) => {
|
|
|
35
35
|
};
|
|
36
36
|
const signEd25519 = (seed, message) => [...sign(null, Buffer.from(message), privateKeyFromSeed(seed))];
|
|
37
37
|
const SVM_CHAINS = ["solana:mainnet", "solana:devnet"];
|
|
38
|
-
const createStandardHandler = (seed) => ({ message
|
|
38
|
+
const createStandardHandler = (seed) => ({ message, method }) => {
|
|
39
39
|
switch (method) {
|
|
40
40
|
case "solana:signMessage": return Promise.resolve(signEd25519(seed, Uint8Array.from(message)));
|
|
41
41
|
default: return Promise.reject(/* @__PURE__ */ new Error(`[walletwright/mock-standard] unsupported method: ${method}`));
|
|
@@ -46,21 +46,18 @@ const createStandardHandler = (seed) => ({ message = [], method }) => {
|
|
|
46
46
|
* single page. Call before `goto`, so the wallet is registered when the dapp discovers wallets.
|
|
47
47
|
* Returns the account it announces so a test can verify signatures against the public key.
|
|
48
48
|
*/
|
|
49
|
+
/** Per install, for the same reason as the EVM mock: see the note on `installCount` in `mock.ts`. */
|
|
50
|
+
let installCount = 0;
|
|
49
51
|
const installMockStandardWallet = async (target, options = {}) => {
|
|
50
|
-
const {
|
|
51
|
-
if (ecosystem !== "svm") throw new Error(`[walletwright/mock-standard] prototype supports only svm, got ${String(ecosystem)}`);
|
|
52
|
+
const { name = "Walletwright Mock", seedHex = DEFAULT_SEED_HEX } = options;
|
|
52
53
|
const seed = Buffer.from(seedHex, "hex");
|
|
53
54
|
if (seed.length !== 32) throw new Error("[walletwright/mock-standard] seedHex must be 32 bytes");
|
|
54
55
|
const publicKey = rawPublicKey(seed);
|
|
55
56
|
const address = encodeBase58(publicKey);
|
|
56
57
|
const publicKeyHex = publicKey.toString("hex");
|
|
57
58
|
const handle = createStandardHandler(seed);
|
|
58
|
-
const bindingName =
|
|
59
|
-
|
|
60
|
-
await target.exposeFunction(bindingName, (rpc) => handle(rpc));
|
|
61
|
-
} catch (error) {
|
|
62
|
-
if (!(error instanceof Error && error.message.includes("already registered"))) throw error;
|
|
63
|
-
}
|
|
59
|
+
const bindingName = `__walletwrightMockStandardSign_${installCount++}`;
|
|
60
|
+
await target.exposeFunction(bindingName, (rpc) => handle(rpc));
|
|
64
61
|
await target.addInitScript(([binding, info]) => {
|
|
65
62
|
const call = window[binding];
|
|
66
63
|
const publicKeyBytes = Uint8Array.from((info.publicKeyHex.match(new RegExp(".{2}", "gv")) ?? []).map((byte) => Number.parseInt(byte, 16)));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mock-standard.mjs","names":[],"sources":["../src/mock-standard.ts"],"sourcesContent":["import { createPrivateKey, createPublicKey, sign } from \"node:crypto\";\n\nimport type { BrowserContext, Page } from \"@playwright/test\";\n\n
|
|
1
|
+
{"version":3,"file":"mock-standard.mjs","names":[],"sources":["../src/mock-standard.ts"],"sourcesContent":["import { createPrivateKey, createPublicKey, sign } from \"node:crypto\";\n\nimport type { BrowserContext, Page } from \"@playwright/test\";\n\n/** Headless Wallet-Standard Solana mock that signs messages with Node's ed25519 implementation. */\ntype MockStandardWalletOptions = {\n /** Wallet name announced over Wallet Standard. */\n name?: string;\n /** 32-byte ed25519 seed as hex. Defaults to a fixed test seed so the address is stable. */\n seedHex?: string;\n};\n\ntype MockStandardAccount = {\n address: string;\n /** Raw 32-byte ed25519 public key, hex-encoded. */\n publicKeyHex: string;\n};\n\nconst DEFAULT_SEED_HEX = \"0101010101010101010101010101010101010101010101010101010101010101\";\n\nconst PKCS8_ED25519_PREFIX = Buffer.from(\"302e020100300506032b657004220420\", \"hex\");\n\nconst BASE58_ALPHABET = \"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz\";\n\nconst encodeBase58 = (bytes: Uint8Array): string => {\n let leadingZeros = 0;\n while (leadingZeros < bytes.length && bytes[leadingZeros] === 0) {\n leadingZeros += 1;\n }\n const digits: Array<number> = [];\n for (const byte of bytes) {\n let carry = byte;\n for (let index = 0; index < digits.length; index += 1) {\n carry += digits[index] * 256;\n digits[index] = carry % 58;\n carry = Math.floor(carry / 58);\n }\n while (carry > 0) {\n digits.push(carry % 58);\n carry = Math.floor(carry / 58);\n }\n }\n const prefix = \"1\".repeat(leadingZeros);\n const body = digits\n .toReversed()\n .map((digit) => BASE58_ALPHABET[digit])\n .join(\"\");\n return prefix + body;\n};\n\nconst privateKeyFromSeed = (seed: Buffer) =>\n createPrivateKey({\n format: \"der\",\n key: Buffer.concat([PKCS8_ED25519_PREFIX, seed]),\n type: \"pkcs8\",\n });\n\nconst rawPublicKey = (seed: Buffer): Buffer => {\n const spki = createPublicKey(privateKeyFromSeed(seed)).export({ format: \"der\", type: \"spki\" });\n return spki.subarray(-32);\n};\n\nconst signEd25519 = (seed: Buffer, message: Uint8Array): Array<number> => [\n ...sign(null, Buffer.from(message), privateKeyFromSeed(seed)),\n];\n\nconst SVM_CHAINS = [\"solana:mainnet\", \"solana:devnet\"] as const;\n\n/** `message` is required: defaulting it would sign the empty message instead of rejecting. */\ntype BridgeRequest = { message: Array<number>; method: string };\n\nconst createStandardHandler =\n (seed: Buffer) =>\n ({ message, method }: BridgeRequest): Promise<Array<number>> => {\n switch (method) {\n case \"solana:signMessage\": {\n return Promise.resolve(signEd25519(seed, Uint8Array.from(message)));\n }\n default: {\n return Promise.reject(\n new Error(`[walletwright/mock-standard] unsupported method: ${method}`),\n );\n }\n }\n };\n\n/**\n * Install the headless Wallet-Standard mock for Solana connect + sign on a context (every page) or a\n * single page. Call before `goto`, so the wallet is registered when the dapp discovers wallets.\n * Returns the account it announces so a test can verify signatures against the public key.\n */\n/** Per install, for the same reason as the EVM mock: see the note on `installCount` in `mock.ts`. */\nlet installCount = 0;\n\nconst installMockStandardWallet = async (\n target: BrowserContext | Page,\n options: MockStandardWalletOptions = {},\n): Promise<MockStandardAccount> => {\n const { name = \"Walletwright Mock\", seedHex = DEFAULT_SEED_HEX } = options;\n const seed = Buffer.from(seedHex, \"hex\");\n if (seed.length !== 32) {\n throw new Error(\"[walletwright/mock-standard] seedHex must be 32 bytes\");\n }\n const publicKey = rawPublicKey(seed);\n const address = encodeBase58(publicKey);\n const publicKeyHex = publicKey.toString(\"hex\");\n const handle = createStandardHandler(seed);\n\n const bindingName = `__walletwrightMockStandardSign_${installCount++}`;\n await target.exposeFunction(bindingName, (rpc: BridgeRequest) => handle(rpc));\n\n await target.addInitScript(\n ([binding, info]) => {\n const call =\n // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- Playwright's exposeBinding installs `binding` on the page's window after this script's types are fixed\n (window as unknown as Record<string, (rpc: BridgeRequest) => Promise<Array<number>>>)[\n binding\n ];\n const publicKeyBytes = Uint8Array.from(\n (info.publicKeyHex.match(/.{2}/gv) ?? []).map((byte) => Number.parseInt(byte, 16)),\n );\n const account = {\n address: info.address,\n chains: info.chains,\n features: [\"solana:signMessage\"],\n label: info.name,\n publicKey: publicKeyBytes,\n };\n const wallet = {\n accounts: [account],\n chains: info.chains,\n features: {\n \"solana:signMessage\": {\n signMessage: (...inputs: Array<{ message: Uint8Array }>) =>\n Promise.all(\n inputs.map(async ({ message }) => ({\n signature: Uint8Array.from(\n await call({ message: [...message], method: \"solana:signMessage\" }),\n ),\n signedMessage: message,\n })),\n ),\n version: \"1.0.0\",\n },\n \"standard:connect\": {\n connect: () => Promise.resolve({ accounts: [account] }),\n version: \"1.0.0\",\n },\n \"standard:events\": {\n on: () => () => {},\n version: \"1.0.0\",\n },\n },\n icon: info.icon,\n name: info.name,\n version: \"1.0.0\" as const,\n };\n\n const callback = (api: { register: (w: typeof wallet) => void }) => {\n api.register(wallet);\n };\n try {\n window.dispatchEvent(\n new CustomEvent(\"wallet-standard:register-wallet\", { detail: callback }),\n );\n } catch {\n // The app may not be listening yet; the app-ready handler below covers that case.\n }\n window.addEventListener(\"wallet-standard:app-ready\", (event) => {\n callback((event as CustomEvent<{ register: (w: typeof wallet) => void }>).detail);\n });\n },\n [\n bindingName,\n {\n address,\n chains: [...SVM_CHAINS],\n icon: \"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'/>\",\n name,\n publicKeyHex,\n },\n ] as const,\n );\n\n return { address, publicKeyHex };\n};\n\nexport { createStandardHandler, encodeBase58, installMockStandardWallet, privateKeyFromSeed };\nexport type { MockStandardAccount, MockStandardWalletOptions };\n"],"mappings":"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"}
|
package/dist/mock.d.mts
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
import { BrowserContext, Page } from "@playwright/test";
|
|
2
2
|
import { PrivateKeyAccount } from "viem/accounts";
|
|
3
3
|
//#region src/mock.d.ts
|
|
4
|
-
/**
|
|
5
|
-
* A headless injected-wallet fake for dapp tests that don't need a real extension. It announces
|
|
6
|
-
* itself over EIP-6963 and answers the EIP-1193 requests a connect/sign flow makes, signing with a
|
|
7
|
-
* real key so signatures verify. `@walletwright/core/mock` is a separate entry point that needs
|
|
8
|
-
* `viem` (optional peer); the extension-driving core doesn't.
|
|
9
|
-
*/
|
|
4
|
+
/** Headless EIP-6963 and EIP-1193 mock that signs with a real key. */
|
|
10
5
|
type MockWalletOptions = {
|
|
11
6
|
chainId?: number;
|
|
12
7
|
/** Wallet name announced over EIP-6963. */
|
package/dist/mock.mjs
CHANGED
|
@@ -20,6 +20,8 @@ const createRpcHandler = (account, chainIdHex) => ({ method, params = [] }) => {
|
|
|
20
20
|
default: return Promise.reject(/* @__PURE__ */ new Error(`[walletwright/mock] unsupported method: ${method}`));
|
|
21
21
|
}
|
|
22
22
|
};
|
|
23
|
+
/** Each install needs its own Playwright binding and EIP-6963 identity. */
|
|
24
|
+
let installCount = 0;
|
|
23
25
|
/**
|
|
24
26
|
* Install the mock on a context (every page) or a single page. Call before `goto`, so the provider
|
|
25
27
|
* exists when the dapp looks for it. Returns the account address it announces.
|
|
@@ -29,12 +31,9 @@ const installMockWallet = async (target, options = {}) => {
|
|
|
29
31
|
const account = privateKeyToAccount(privateKey);
|
|
30
32
|
const chainIdHex = toHex(chainId);
|
|
31
33
|
const handle = createRpcHandler(account, chainIdHex);
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
} catch (error) {
|
|
36
|
-
if (!(error instanceof Error && error.message.includes("already registered"))) throw error;
|
|
37
|
-
}
|
|
34
|
+
const install = installCount++;
|
|
35
|
+
const bindingName = `__walletwrightMockRpc_${install}`;
|
|
36
|
+
await target.exposeFunction(bindingName, (rpc) => handle(rpc));
|
|
38
37
|
await target.addInitScript(([binding, info]) => {
|
|
39
38
|
const call = window[binding];
|
|
40
39
|
const provider = {
|
|
@@ -56,7 +55,7 @@ const installMockWallet = async (target, options = {}) => {
|
|
|
56
55
|
icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'/>",
|
|
57
56
|
name,
|
|
58
57
|
rdns: "sh.walletwright.mock",
|
|
59
|
-
uuid:
|
|
58
|
+
uuid: `00000000-0000-0000-0000-${String(install).padStart(12, "0")}`
|
|
60
59
|
}]);
|
|
61
60
|
return account.address;
|
|
62
61
|
};
|
package/dist/mock.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mock.mjs","names":[],"sources":["../src/mock.ts"],"sourcesContent":["import type { BrowserContext, Page } from \"@playwright/test\";\nimport type { PrivateKeyAccount } from \"viem/accounts\";\nimport { privateKeyToAccount } from \"viem/accounts\";\n\n
|
|
1
|
+
{"version":3,"file":"mock.mjs","names":[],"sources":["../src/mock.ts"],"sourcesContent":["import type { BrowserContext, Page } from \"@playwright/test\";\nimport type { PrivateKeyAccount } from \"viem/accounts\";\nimport { privateKeyToAccount } from \"viem/accounts\";\n\n/** Headless EIP-6963 and EIP-1193 mock that signs with a real key. */\nexport type MockWalletOptions = {\n chainId?: number;\n /** Wallet name announced over EIP-6963. */\n name?: string;\n /** The account's private key. Defaults to anvil/hardhat account #0. */\n privateKey?: `0x${string}`;\n};\n\nconst DEFAULT_KEY = \"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80\";\n\ntype Rpc = { method: string; params?: ReadonlyArray<unknown> };\n\nconst toHex = (value: number): string => `0x${value.toString(16)}`;\n\nconst isHexMessage = (value: string): value is `0x${string}` => /^0x[\\da-fA-F]*$/v.test(value);\n\n/** The EIP-1193 handler a mock provider answers with. Exported for direct, browser-free testing. */\nconst createRpcHandler =\n (account: PrivateKeyAccount, chainIdHex: string) =>\n ({ method, params = [] }: Rpc): Promise<unknown> => {\n switch (method) {\n case \"eth_requestAccounts\":\n case \"eth_accounts\": {\n return Promise.resolve([account.address]);\n }\n case \"eth_chainId\": {\n return Promise.resolve(chainIdHex);\n }\n case \"personal_sign\": {\n const [message] = params;\n if (typeof message !== \"string\") {\n return Promise.reject(\n new Error(\"[walletwright/mock] personal_sign expects a string message\"),\n );\n }\n return account.signMessage({\n message: isHexMessage(message) ? { raw: message } : message,\n });\n }\n case \"wallet_switchEthereumChain\":\n case \"wallet_addEthereumChain\": {\n return Promise.resolve(null);\n }\n default: {\n return Promise.reject(new Error(`[walletwright/mock] unsupported method: ${method}`));\n }\n }\n };\n\n/** Each install needs its own Playwright binding and EIP-6963 identity. */\nlet installCount = 0;\n\n/**\n * Install the mock on a context (every page) or a single page. Call before `goto`, so the provider\n * exists when the dapp looks for it. Returns the account address it announces.\n */\nconst installMockWallet = async (\n target: BrowserContext | Page,\n options: MockWalletOptions = {},\n): Promise<string> => {\n const { chainId = 31_337, name = \"Walletwright Mock\", privateKey = DEFAULT_KEY } = options;\n const account = privateKeyToAccount(privateKey);\n const chainIdHex = toHex(chainId);\n const handle = createRpcHandler(account, chainIdHex);\n\n const install = installCount++;\n const bindingName = `__walletwrightMockRpc_${install}`;\n await target.exposeFunction(bindingName, (rpc: Rpc) => handle(rpc));\n\n await target.addInitScript(\n ([binding, info]) => {\n // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- Playwright's exposeBinding installs `binding` on the page's window after this script's types are fixed\n const call = (window as unknown as Record<string, (rpc: Rpc) => Promise<unknown>>)[binding];\n const provider = {\n isMetaMask: true,\n on: () => provider,\n removeListener: () => provider,\n request: (rpc: Rpc) => call(rpc),\n };\n // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- injecting the EIP-1193 provider is the point of this script; the DOM lib has no `ethereum` slot\n (window as unknown as { ethereum?: unknown }).ethereum = provider;\n\n const announce = () => {\n window.dispatchEvent(\n new CustomEvent(\"eip6963:announceProvider\", {\n detail: Object.freeze({ info, provider }),\n }),\n );\n };\n window.addEventListener(\"eip6963:requestProvider\", announce);\n announce();\n },\n [\n bindingName,\n {\n icon: \"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'/>\",\n name,\n rdns: \"sh.walletwright.mock\",\n // Per install, so two providers stay distinguishable in a dapp's uuid-keyed provider map.\n uuid: `00000000-0000-0000-0000-${String(install).padStart(12, \"0\")}`,\n },\n ] as const,\n );\n\n return account.address;\n};\n\nexport { createRpcHandler, installMockWallet };\n"],"mappings":";;;AAaA,MAAM,cAAc;AAIpB,MAAM,SAAS,UAA0B,KAAK,MAAM,SAAS,EAAE;AAE/D,MAAM,gBAAgB,UAA0C,kCAAiB,CAAC,CAAC,KAAK,KAAK;;AAG7F,MAAM,oBACH,SAA4B,gBAC5B,EAAE,QAAQ,SAAS,CAAC,QAA+B;CAClD,QAAQ,QAAR;EACE,KAAK;EACL,KAAK,gBACH,OAAO,QAAQ,QAAQ,CAAC,QAAQ,OAAO,CAAC;EAE1C,KAAK,eACH,OAAO,QAAQ,QAAQ,UAAU;EAEnC,KAAK,iBAAiB;GACpB,MAAM,CAAC,WAAW;GAClB,IAAI,OAAO,YAAY,UACrB,OAAO,QAAQ,uBACb,IAAI,MAAM,4DAA4D,CACxE;GAEF,OAAO,QAAQ,YAAY,EACzB,SAAS,aAAa,OAAO,IAAI,EAAE,KAAK,QAAQ,IAAI,QACtD,CAAC;EACH;EACA,KAAK;EACL,KAAK,2BACH,OAAO,QAAQ,QAAQ,IAAI;EAE7B,SACE,OAAO,QAAQ,uBAAO,IAAI,MAAM,2CAA2C,QAAQ,CAAC;CAExF;AACF;;AAGF,IAAI,eAAe;;;;;AAMnB,MAAM,oBAAoB,OACxB,QACA,UAA6B,CAAC,MACV;CACpB,MAAM,EAAE,UAAU,OAAQ,OAAO,qBAAqB,aAAa,gBAAgB;CACnF,MAAM,UAAU,oBAAoB,UAAU;CAC9C,MAAM,aAAa,MAAM,OAAO;CAChC,MAAM,SAAS,iBAAiB,SAAS,UAAU;CAEnD,MAAM,UAAU;CAChB,MAAM,cAAc,yBAAyB;CAC7C,MAAM,OAAO,eAAe,cAAc,QAAa,OAAO,GAAG,CAAC;CAElE,MAAM,OAAO,eACV,CAAC,SAAS,UAAU;EAEnB,MAAM,OAAQ,OAAqE;EACnF,MAAM,WAAW;GACf,YAAY;GACZ,UAAU;GACV,sBAAsB;GACtB,UAAU,QAAa,KAAK,GAAG;EACjC;EAEA,AAAC,OAA6C,WAAW;EAEzD,MAAM,iBAAiB;GACrB,OAAO,cACL,IAAI,YAAY,4BAA4B,EAC1C,QAAQ,OAAO,OAAO;IAAE;IAAM;GAAS,CAAC,EAC1C,CAAC,CACH;EACF;EACA,OAAO,iBAAiB,2BAA2B,QAAQ;EAC3D,SAAS;CACX,GACA,CACE,aACA;EACE,MAAM;EACN;EACA,MAAM;EAEN,MAAM,2BAA2B,OAAO,OAAO,CAAC,CAAC,SAAS,IAAI,GAAG;CACnE,CACF,CACF;CAEA,OAAO,QAAQ;AACjB"}
|
|
@@ -52,12 +52,7 @@ type NetworkConfig = {
|
|
|
52
52
|
type NetworkActions = WalletActionsFor<NetworkApi>;
|
|
53
53
|
/** Lock and unlock the wallet itself, from its own UI. */
|
|
54
54
|
type SettingsActions = WalletActionsFor<SettingsApi>;
|
|
55
|
-
/**
|
|
56
|
-
* Optional, per-wallet capabilities beyond the universal connect/sign flow. A wallet declares only
|
|
57
|
-
* what has actually been driven against the real extension, so the registry never claims support it
|
|
58
|
-
* doesn't have: `network` is meaningless for Slush (Sui), and Phantom's settings UI has no analogue
|
|
59
|
-
* for much of MetaMask's. Anything undeclared throws a clear error at call time.
|
|
60
|
-
*/
|
|
55
|
+
/** Optional wallet capabilities beyond connect and sign. Undeclared actions throw at call time. */
|
|
61
56
|
type WalletActions = {
|
|
62
57
|
accounts?: AccountActions;
|
|
63
58
|
network?: NetworkActions;
|
|
@@ -70,12 +65,7 @@ type WalletActions = {
|
|
|
70
65
|
type WalletDefinition = {
|
|
71
66
|
/** Optional capabilities beyond connect/sign. Omit a group the wallet can't (or doesn't) drive. */
|
|
72
67
|
actions?: WalletActions;
|
|
73
|
-
/**
|
|
74
|
-
* Controls that only exist while a request is on screen, used to tell a real approval from the
|
|
75
|
-
* wallet's idle UI: MetaMask's approval window can render its home screen, buttons and all. A
|
|
76
|
-
* wallet that doesn't declare this falls back to "any button is visible", which is enough for a
|
|
77
|
-
* window that only ever opens for a request.
|
|
78
|
-
*/
|
|
68
|
+
/** Controls that exist only for a pending request, excluding idle wallet UI. */
|
|
79
69
|
approvalControls?: (popup: Page) => Locator;
|
|
80
70
|
/**
|
|
81
71
|
* Click the approve/confirm button in an approval popup (connect or sign). `password` is provided
|
|
@@ -91,12 +81,7 @@ type WalletDefinition = {
|
|
|
91
81
|
* holding the DB), e.g. forcing `completedOnboarding=true` in MetaMask's leveldb.
|
|
92
82
|
*/
|
|
93
83
|
finalizeCache?: (profileDir: string, extensionId: string) => Promise<void>;
|
|
94
|
-
/**
|
|
95
|
-
* Whether this wallet's approval window surfaces as a page when the browser runs headless, so the
|
|
96
|
-
* engine can find and drive it. Declared only once verified against the real extension: MetaMask's
|
|
97
|
-
* window is created but never exposed, and `launchWallet` refuses headless without this rather
|
|
98
|
-
* than hanging at the first approval.
|
|
99
|
-
*/
|
|
84
|
+
/** Whether the real wallet exposes approval windows as Playwright pages in headless mode. */
|
|
100
85
|
headlessApprovals?: boolean;
|
|
101
86
|
/** Run the import-from-seed onboarding flow. */
|
|
102
87
|
importWallet: (page: Page, seedPhrase: string, password: string) => Promise<void>;
|
|
@@ -122,11 +107,8 @@ type WalletDefinition = {
|
|
|
122
107
|
* rather than returning a page that never rendered. The returned page stays open as `Wallet.home`.
|
|
123
108
|
*/
|
|
124
109
|
reachUnlockScreen: (context: BrowserContext, extensionId: string) => Promise<Page>;
|
|
125
|
-
/**
|
|
126
|
-
|
|
127
|
-
* a wallet declares it only once it has been driven against the real extension.
|
|
128
|
-
*/
|
|
129
|
-
reject?: (popup: Page) => Promise<void>;
|
|
110
|
+
/** Click the cancel/reject button in an approval popup, the counterpart of `approve`. */
|
|
111
|
+
reject: (popup: Page) => Promise<void>;
|
|
130
112
|
/** Unlock the wallet on its (already-open) home page. */
|
|
131
113
|
unlock: (page: Page, password: string) => Promise<void>;
|
|
132
114
|
};
|
|
@@ -138,12 +120,7 @@ type SettingsApi = {
|
|
|
138
120
|
/** Add and switch networks from the wallet's own UI. Throws if the wallet doesn't declare support. */
|
|
139
121
|
type NetworkApi = {
|
|
140
122
|
add: (config: NetworkConfig) => Promise<void>;
|
|
141
|
-
/**
|
|
142
|
-
* No wallet implements this today, so calling it throws. MetaMask 13.x scopes the active chain
|
|
143
|
-
* per dapp and has no wallet-side network selector, so switching is dapp-initiated: the dapp calls
|
|
144
|
-
* `wallet_addEthereumChain` (idempotent, adds when missing and switches when present) and
|
|
145
|
-
* `wallet.approve()` drives the popup. See the network recipe in Examples.
|
|
146
|
-
*/
|
|
123
|
+
/** MetaMask 13.x switches dapp-scoped networks through `wallet_addEthereumChain` approvals. */
|
|
147
124
|
switch: (chainId: number) => Promise<void>;
|
|
148
125
|
};
|
|
149
126
|
/** Manage accounts from the wallet's own UI. Throws if the wallet doesn't declare support. */
|
|
@@ -193,4 +170,4 @@ type Wallet = {
|
|
|
193
170
|
};
|
|
194
171
|
//#endregion
|
|
195
172
|
export { WalletSetup as a, WalletKind as i, Wallet as n, WalletDefinition as r, Ecosystem as t };
|
|
196
|
-
//# sourceMappingURL=types-
|
|
173
|
+
//# sourceMappingURL=types-CFQpWZhv.d.mts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@walletwright/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Playwright wallet automation for MetaMask, Phantom, Rabby, Solflare, and Slush across EVM, Solana, and Sui. Connect and sign in real browser extensions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"e2e",
|