@walletwright/core 0.2.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 +110 -0
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/dist/cache-Bqz3Mchl.mjs +972 -0
- package/dist/cache-Bqz3Mchl.mjs.map +1 -0
- package/dist/chain.d.mts +24 -0
- package/dist/chain.mjs +25 -0
- package/dist/chain.mjs.map +1 -0
- package/dist/cli.d.mts +14 -0
- package/dist/cli.mjs +101 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/index.d.mts +59 -0
- package/dist/index.mjs +201 -0
- package/dist/index.mjs.map +1 -0
- package/dist/mock-standard.d.mts +41 -0
- package/dist/mock-standard.mjs +125 -0
- package/dist/mock-standard.mjs.map +1 -0
- package/dist/mock.d.mts +30 -0
- package/dist/mock.mjs +66 -0
- package/dist/mock.mjs.map +1 -0
- package/dist/types-CWxz_dJe.d.mts +196 -0
- package/package.json +94 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { a as wallets, c as extensionIdFromPath, d as placeApprovalWindow, f as profileKey, i as walletKindsByEcosystem, l as findNotificationPopup, m as waitUntil, n as extensionContextOptions, o as DEFAULT_CACHE_DIR, p as formatTimeout, s as DEFAULT_NOTIFICATION_MATCH, t as buildCache, u as hasNotificationPopup } from "./cache-Bqz3Mchl.mjs";
|
|
2
|
+
import { cp, mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { chromium, test } from "@playwright/test";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
|
|
8
|
+
//#region src/internal/controller.ts
|
|
9
|
+
const POPUP_CLOSE_TIMEOUT_MS = 15e3;
|
|
10
|
+
/** Optional approvals keep a short wait: "no popup" is a normal outcome, and the wait is latency. */
|
|
11
|
+
const OPTIONAL_POPUP_TIMEOUT_MS = 1e4;
|
|
12
|
+
/** The MV3 service worker spawns a popup slowly once the wallet's own UI has been driven. */
|
|
13
|
+
const REQUIRED_POPUP_TIMEOUT_MS = 3e4;
|
|
14
|
+
/** Build the runtime controller that drives an unlocked wallet against the dapp under test. */
|
|
15
|
+
const createWallet = ({ context, definition, extensionId, home, password }) => {
|
|
16
|
+
const match = definition.notificationMatch ?? "notification.html";
|
|
17
|
+
const ctx = {
|
|
18
|
+
context,
|
|
19
|
+
extensionId,
|
|
20
|
+
home,
|
|
21
|
+
password
|
|
22
|
+
};
|
|
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
|
+
*/
|
|
29
|
+
const frontDapp = async () => {
|
|
30
|
+
await context.pages().find((page) => (/* @__PURE__ */ new RegExp("^https?:", "v")).test(page.url()) && !page.isClosed())?.bringToFront().catch(() => {});
|
|
31
|
+
};
|
|
32
|
+
/** Wait for the approval window the wallet opens for a pending request. */
|
|
33
|
+
const findApproval = (optional) => findNotificationPopup({
|
|
34
|
+
approvalControls: definition.approvalControls,
|
|
35
|
+
context,
|
|
36
|
+
extensionId,
|
|
37
|
+
match,
|
|
38
|
+
timeoutMs: optional ? OPTIONAL_POPUP_TIMEOUT_MS : REQUIRED_POPUP_TIMEOUT_MS
|
|
39
|
+
});
|
|
40
|
+
/** Drive the pending approval popup with `settle`, then wait for it to close. */
|
|
41
|
+
const resolvePopup = async (settle, { optional = false }) => {
|
|
42
|
+
await frontDapp();
|
|
43
|
+
const find = () => findApproval(optional);
|
|
44
|
+
const popup = await find();
|
|
45
|
+
if (!popup) {
|
|
46
|
+
if (optional) return;
|
|
47
|
+
const open = context.pages().map((page) => page.url());
|
|
48
|
+
throw new Error(`[walletwright] approval popup did not appear. Open pages: ${open.join(" | ") || "none"}`);
|
|
49
|
+
}
|
|
50
|
+
await placeApprovalWindow(popup);
|
|
51
|
+
try {
|
|
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)`);
|
|
61
|
+
};
|
|
62
|
+
const unsupported = (name) => /* @__PURE__ */ new Error(`[walletwright] ${definition.extensionName} does not support ${name}()`);
|
|
63
|
+
const approve = (options = {}) => resolvePopup((popup) => definition.approve(popup, password), options);
|
|
64
|
+
const reject = async (options = {}) => {
|
|
65
|
+
const { reject: rejectPopup } = definition;
|
|
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
|
+
*/
|
|
77
|
+
const action = (fn, name) => async (...args) => {
|
|
78
|
+
if (!fn) throw unsupported(name);
|
|
79
|
+
if (home.isClosed()) throw new Error(`[walletwright] wallet home page is closed; cannot run ${name}()`);
|
|
80
|
+
await home.bringToFront().catch(() => {});
|
|
81
|
+
await fn(ctx, ...args);
|
|
82
|
+
await frontDapp();
|
|
83
|
+
};
|
|
84
|
+
return {
|
|
85
|
+
accounts: {
|
|
86
|
+
add: action(definition.actions?.accounts?.add, "accounts.add"),
|
|
87
|
+
importPrivateKey: action(definition.actions?.accounts?.importPrivateKey, "accounts.importPrivateKey"),
|
|
88
|
+
rename: action(definition.actions?.accounts?.rename, "accounts.rename"),
|
|
89
|
+
switch: action(definition.actions?.accounts?.switch, "accounts.switch")
|
|
90
|
+
},
|
|
91
|
+
approve,
|
|
92
|
+
confirmSignature: () => approve(),
|
|
93
|
+
confirmTransaction: () => approve(),
|
|
94
|
+
connectToDapp: () => approve({ optional: true }),
|
|
95
|
+
extensionId,
|
|
96
|
+
home,
|
|
97
|
+
network: {
|
|
98
|
+
add: action(definition.actions?.network?.add, "network.add"),
|
|
99
|
+
switch: action(definition.actions?.network?.switch, "network.switch")
|
|
100
|
+
},
|
|
101
|
+
reject,
|
|
102
|
+
rejectConnection: () => reject(),
|
|
103
|
+
rejectSignature: () => reject(),
|
|
104
|
+
rejectTransaction: () => reject(),
|
|
105
|
+
settings: {
|
|
106
|
+
lock: action(definition.actions?.settings?.lock, "settings.lock"),
|
|
107
|
+
unlock: action(definition.actions?.settings?.unlock, "settings.unlock")
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
//#endregion
|
|
113
|
+
//#region src/internal/launch.ts
|
|
114
|
+
/**
|
|
115
|
+
* Drop the tabs nobody drives: Chromium's initial `about:blank` and the extension's own auto-opened
|
|
116
|
+
* tab. We navigate a tab of our own to the wallet instead of adopting that one (it's unreliable, see
|
|
117
|
+
* `reachUnlockScreen`), which otherwise leaves a second, still-locked wallet tab open all run.
|
|
118
|
+
* Approval popups are unaffected: they're matched by URL, not by being the only extension page.
|
|
119
|
+
*/
|
|
120
|
+
const closeStrayPages = async (context, home) => {
|
|
121
|
+
const isStray = (page) => page !== home && !page.isClosed() && (page.url() === "about:blank" || page.url().startsWith("chrome-extension://"));
|
|
122
|
+
const closing = [];
|
|
123
|
+
for (const page of context.pages()) if (isStray(page)) closing.push(page.close());
|
|
124
|
+
await Promise.allSettled(closing);
|
|
125
|
+
};
|
|
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
|
+
*/
|
|
132
|
+
const launchWallet = async (setup, { headless = false } = {}) => {
|
|
133
|
+
const definition = wallets[setup.wallet];
|
|
134
|
+
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
|
+
const cacheDir = path.resolve(setup.cacheDir ?? ".walletwright");
|
|
136
|
+
const profileDir = path.join(cacheDir, profileKey(setup));
|
|
137
|
+
if (!existsSync(profileDir)) throw new Error(`[walletwright] no cache for this setup at ${profileDir}. Build it first with buildCache() or \`walletwright cache\`.`);
|
|
138
|
+
const extensionPath = await definition.prepareExtension(cacheDir, setup.version);
|
|
139
|
+
const runDir = await mkdtemp(path.join(os.tmpdir(), "walletwright-"));
|
|
140
|
+
await cp(profileDir, runDir, { recursive: true });
|
|
141
|
+
const context = await chromium.launchPersistentContext(runDir, extensionContextOptions(extensionPath, headless));
|
|
142
|
+
context.on("close", async () => {
|
|
143
|
+
await rm(runDir, {
|
|
144
|
+
force: true,
|
|
145
|
+
recursive: true
|
|
146
|
+
}).catch(() => {});
|
|
147
|
+
});
|
|
148
|
+
const extensionId = extensionIdFromPath(extensionPath);
|
|
149
|
+
try {
|
|
150
|
+
await definition.prepareContext?.(context);
|
|
151
|
+
const home = await definition.reachUnlockScreen(context, extensionId);
|
|
152
|
+
await definition.unlock(home, setup.password);
|
|
153
|
+
await closeStrayPages(context, home);
|
|
154
|
+
return {
|
|
155
|
+
context,
|
|
156
|
+
wallet: createWallet({
|
|
157
|
+
context,
|
|
158
|
+
definition,
|
|
159
|
+
extensionId,
|
|
160
|
+
home,
|
|
161
|
+
password: setup.password
|
|
162
|
+
})
|
|
163
|
+
};
|
|
164
|
+
} catch (error) {
|
|
165
|
+
await context.close().catch(() => {});
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
//#endregion
|
|
171
|
+
//#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
|
+
*/
|
|
185
|
+
const createWalletFixtures = (setup) => test.extend({
|
|
186
|
+
context: async ({ walletLaunch }, use) => {
|
|
187
|
+
await use(walletLaunch.context);
|
|
188
|
+
},
|
|
189
|
+
wallet: async ({ walletLaunch }, use) => {
|
|
190
|
+
await use(walletLaunch.wallet);
|
|
191
|
+
},
|
|
192
|
+
walletLaunch: async ({ headless }, use) => {
|
|
193
|
+
const launched = await launchWallet(setup, { headless });
|
|
194
|
+
await use(launched);
|
|
195
|
+
await launched.context.close();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
//#endregion
|
|
200
|
+
export { buildCache, createWalletFixtures, launchWallet, walletKindsByEcosystem, wallets };
|
|
201
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +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 // Naming the open pages separates \"the wallet never opened one\" from \"it opened one showing\n // something other than the request\", which look identical from the timeout alone.\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 // The finder can grab the previous popup in its final moments (the window closes right after\n // its own approval resolves). If ours died under us, one fresh find gets the real popup.\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 // Wait for the popup to close so the next approval doesn't grab a stale page.\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 // Actions drive the wallet's own page; front it first so clicks land on a visible tab, then\n // hand focus back to the dapp below so new approvals open as popups instead of inline.\n await home.bringToFront().catch(() => {});\n await fn(ctx, ...args);\n await frontDapp();\n };\n\n // A capability is declared once, on the *Api types in types.ts: WalletActions derives from those,\n // so a new method makes this object fail to compile until it is bound. Only the fn-to-slot pairing\n // below can still be wrong, and capability-wiring.test.ts guards that.\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 // Connect may auto-approve on some wallets, so a missing popup is not an error here.\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 // A wallet whose approval window never surfaces headless reaches no approval at all, and would\n // fail 30s later at the first connect with nothing pointing back at the mode as the cause.\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 // Run from a throwaway copy so the cache stays pristine and parallel runs don't share a profile.\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 // The throwaway profile copy is only needed while the context is live; drop it on close so a long\n // suite (workers x specs x retries) doesn't fill the temp dir with profile copies.\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 // Kept open (not closed after unlock): the settings/network/account actions drive this page.\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 // Fires the context.on(\"close\") handler above, which removes the throwaway runDir.\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 // Depends only on Playwright's `headless` option (so `--headed` and `use: { headless }` reach\n // the wallet): its own `browser` fixture would launch a second Chromium that no test ever\n // drives, since the wallet needs a persistent context of its own.\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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"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { BrowserContext, Page } from "@playwright/test";
|
|
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";
|
|
14
|
+
type MockStandardWalletOptions = {
|
|
15
|
+
ecosystem?: MockEcosystem;
|
|
16
|
+
/** Wallet name announced over Wallet Standard. */
|
|
17
|
+
name?: string;
|
|
18
|
+
/** 32-byte ed25519 seed as hex. Defaults to a fixed test seed so the address is stable. */
|
|
19
|
+
seedHex?: string;
|
|
20
|
+
};
|
|
21
|
+
type MockStandardAccount = {
|
|
22
|
+
address: string;
|
|
23
|
+
/** Raw 32-byte ed25519 public key, hex-encoded. */
|
|
24
|
+
publicKeyHex: string;
|
|
25
|
+
};
|
|
26
|
+
declare const encodeBase58: (bytes: Uint8Array) => string;
|
|
27
|
+
declare const privateKeyFromSeed: (seed: Buffer) => import("node:crypto").KeyObject;
|
|
28
|
+
type BridgeRequest = {
|
|
29
|
+
message?: Array<number>;
|
|
30
|
+
method: string;
|
|
31
|
+
};
|
|
32
|
+
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
|
+
declare const installMockStandardWallet: (target: BrowserContext | Page, options?: MockStandardWalletOptions) => Promise<MockStandardAccount>;
|
|
39
|
+
//#endregion
|
|
40
|
+
export { type MockEcosystem, type MockStandardAccount, type MockStandardWalletOptions, createStandardHandler, encodeBase58, installMockStandardWallet, privateKeyFromSeed };
|
|
41
|
+
//# sourceMappingURL=mock-standard.d.mts.map
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { createPrivateKey, createPublicKey, sign } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
//#region src/mock-standard.ts
|
|
4
|
+
const DEFAULT_SEED_HEX = "0101010101010101010101010101010101010101010101010101010101010101";
|
|
5
|
+
const PKCS8_ED25519_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
|
6
|
+
const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
7
|
+
const encodeBase58 = (bytes) => {
|
|
8
|
+
let leadingZeros = 0;
|
|
9
|
+
while (leadingZeros < bytes.length && bytes[leadingZeros] === 0) leadingZeros += 1;
|
|
10
|
+
const digits = [];
|
|
11
|
+
for (const byte of bytes) {
|
|
12
|
+
let carry = byte;
|
|
13
|
+
for (let index = 0; index < digits.length; index += 1) {
|
|
14
|
+
carry += digits[index] * 256;
|
|
15
|
+
digits[index] = carry % 58;
|
|
16
|
+
carry = Math.floor(carry / 58);
|
|
17
|
+
}
|
|
18
|
+
while (carry > 0) {
|
|
19
|
+
digits.push(carry % 58);
|
|
20
|
+
carry = Math.floor(carry / 58);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return "1".repeat(leadingZeros) + digits.toReversed().map((digit) => BASE58_ALPHABET[digit]).join("");
|
|
24
|
+
};
|
|
25
|
+
const privateKeyFromSeed = (seed) => createPrivateKey({
|
|
26
|
+
format: "der",
|
|
27
|
+
key: Buffer.concat([PKCS8_ED25519_PREFIX, seed]),
|
|
28
|
+
type: "pkcs8"
|
|
29
|
+
});
|
|
30
|
+
const rawPublicKey = (seed) => {
|
|
31
|
+
return createPublicKey(privateKeyFromSeed(seed)).export({
|
|
32
|
+
format: "der",
|
|
33
|
+
type: "spki"
|
|
34
|
+
}).subarray(-32);
|
|
35
|
+
};
|
|
36
|
+
const signEd25519 = (seed, message) => [...sign(null, Buffer.from(message), privateKeyFromSeed(seed))];
|
|
37
|
+
const SVM_CHAINS = ["solana:mainnet", "solana:devnet"];
|
|
38
|
+
const createStandardHandler = (seed) => ({ message = [], method }) => {
|
|
39
|
+
switch (method) {
|
|
40
|
+
case "solana:signMessage": return Promise.resolve(signEd25519(seed, Uint8Array.from(message)));
|
|
41
|
+
default: return Promise.reject(/* @__PURE__ */ new Error(`[walletwright/mock-standard] unsupported method: ${method}`));
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Install the headless Wallet-Standard mock for Solana connect + sign on a context (every page) or a
|
|
46
|
+
* single page. Call before `goto`, so the wallet is registered when the dapp discovers wallets.
|
|
47
|
+
* Returns the account it announces so a test can verify signatures against the public key.
|
|
48
|
+
*/
|
|
49
|
+
const installMockStandardWallet = async (target, options = {}) => {
|
|
50
|
+
const { ecosystem = "svm", name = "Walletwright Mock", seedHex = DEFAULT_SEED_HEX } = options;
|
|
51
|
+
if (ecosystem !== "svm") throw new Error(`[walletwright/mock-standard] prototype supports only svm, got ${String(ecosystem)}`);
|
|
52
|
+
const seed = Buffer.from(seedHex, "hex");
|
|
53
|
+
if (seed.length !== 32) throw new Error("[walletwright/mock-standard] seedHex must be 32 bytes");
|
|
54
|
+
const publicKey = rawPublicKey(seed);
|
|
55
|
+
const address = encodeBase58(publicKey);
|
|
56
|
+
const publicKeyHex = publicKey.toString("hex");
|
|
57
|
+
const handle = createStandardHandler(seed);
|
|
58
|
+
const bindingName = "__walletwrightMockStandardSign";
|
|
59
|
+
try {
|
|
60
|
+
await target.exposeFunction(bindingName, (rpc) => handle(rpc));
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (!(error instanceof Error && error.message.includes("already registered"))) throw error;
|
|
63
|
+
}
|
|
64
|
+
await target.addInitScript(([binding, info]) => {
|
|
65
|
+
const call = window[binding];
|
|
66
|
+
const publicKeyBytes = Uint8Array.from((info.publicKeyHex.match(/* @__PURE__ */ new RegExp(".{2}", "gv")) ?? []).map((byte) => Number.parseInt(byte, 16)));
|
|
67
|
+
const account = {
|
|
68
|
+
address: info.address,
|
|
69
|
+
chains: info.chains,
|
|
70
|
+
features: ["solana:signMessage"],
|
|
71
|
+
label: info.name,
|
|
72
|
+
publicKey: publicKeyBytes
|
|
73
|
+
};
|
|
74
|
+
const wallet = {
|
|
75
|
+
accounts: [account],
|
|
76
|
+
chains: info.chains,
|
|
77
|
+
features: {
|
|
78
|
+
"solana:signMessage": {
|
|
79
|
+
signMessage: (...inputs) => Promise.all(inputs.map(async ({ message }) => ({
|
|
80
|
+
signature: Uint8Array.from(await call({
|
|
81
|
+
message: [...message],
|
|
82
|
+
method: "solana:signMessage"
|
|
83
|
+
})),
|
|
84
|
+
signedMessage: message
|
|
85
|
+
}))),
|
|
86
|
+
version: "1.0.0"
|
|
87
|
+
},
|
|
88
|
+
"standard:connect": {
|
|
89
|
+
connect: () => Promise.resolve({ accounts: [account] }),
|
|
90
|
+
version: "1.0.0"
|
|
91
|
+
},
|
|
92
|
+
"standard:events": {
|
|
93
|
+
on: () => () => {},
|
|
94
|
+
version: "1.0.0"
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
icon: info.icon,
|
|
98
|
+
name: info.name,
|
|
99
|
+
version: "1.0.0"
|
|
100
|
+
};
|
|
101
|
+
const callback = (api) => {
|
|
102
|
+
api.register(wallet);
|
|
103
|
+
};
|
|
104
|
+
try {
|
|
105
|
+
window.dispatchEvent(new CustomEvent("wallet-standard:register-wallet", { detail: callback }));
|
|
106
|
+
} catch {}
|
|
107
|
+
window.addEventListener("wallet-standard:app-ready", (event) => {
|
|
108
|
+
callback(event.detail);
|
|
109
|
+
});
|
|
110
|
+
}, [bindingName, {
|
|
111
|
+
address,
|
|
112
|
+
chains: [...SVM_CHAINS],
|
|
113
|
+
icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'/>",
|
|
114
|
+
name,
|
|
115
|
+
publicKeyHex
|
|
116
|
+
}]);
|
|
117
|
+
return {
|
|
118
|
+
address,
|
|
119
|
+
publicKeyHex
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
//#endregion
|
|
124
|
+
export { createStandardHandler, encodeBase58, installMockStandardWallet, privateKeyFromSeed };
|
|
125
|
+
//# sourceMappingURL=mock-standard.mjs.map
|
|
@@ -0,0 +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/**\n * A headless Wallet-Standard fake for Solana dapp tests that don't need a real extension. It\n * hand-rolls the Wallet-Standard registration protocol (no browser-side library) and answers\n * `standard:connect` plus `solana:signMessage`, signing with a real ed25519 key so signatures\n * verify. Isolated from the EVM `mock.ts`; `@walletwright/core/mock-standard` is its own entry\n * point.\n *\n * ed25519 signing runs Node-side (viem is secp256k1-only), reusing the same exposeFunction bridge\n * pattern as the EVM mock. No extra dependency: Node's built-in `crypto` signs ed25519 natively.\n */\ntype MockEcosystem = \"svm\";\n\ntype MockStandardWalletOptions = {\n ecosystem?: MockEcosystem;\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\n// A fixed 32-byte seed so the derived Solana address is stable across runs.\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\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 */\nconst installMockStandardWallet = async (\n target: BrowserContext | Page,\n options: MockStandardWalletOptions = {},\n): Promise<MockStandardAccount> => {\n const { ecosystem = \"svm\", name = \"Walletwright Mock\", seedHex = DEFAULT_SEED_HEX } = options;\n if (ecosystem !== \"svm\") {\n throw new Error(\n `[walletwright/mock-standard] prototype supports only svm, got ${String(ecosystem)}`,\n );\n }\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\";\n try {\n await target.exposeFunction(bindingName, (rpc: BridgeRequest) => handle(rpc));\n } catch (error) {\n if (!(error instanceof Error && error.message.includes(\"already registered\"))) {\n throw error;\n }\n }\n\n await target.addInitScript(\n ([binding, info]) => {\n const call = (\n window as unknown as Record<string, (rpc: BridgeRequest) => Promise<Array<number>>>\n )[binding];\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 { MockEcosystem, MockStandardAccount, MockStandardWalletOptions };\n"],"mappings":"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"}
|
package/dist/mock.d.mts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { BrowserContext, Page } from "@playwright/test";
|
|
2
|
+
import { PrivateKeyAccount } from "viem/accounts";
|
|
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
|
+
*/
|
|
10
|
+
type MockWalletOptions = {
|
|
11
|
+
chainId?: number;
|
|
12
|
+
/** Wallet name announced over EIP-6963. */
|
|
13
|
+
name?: string;
|
|
14
|
+
/** The account's private key. Defaults to anvil/hardhat account #0. */
|
|
15
|
+
privateKey?: `0x${string}`;
|
|
16
|
+
};
|
|
17
|
+
type Rpc = {
|
|
18
|
+
method: string;
|
|
19
|
+
params?: ReadonlyArray<unknown>;
|
|
20
|
+
};
|
|
21
|
+
/** The EIP-1193 handler a mock provider answers with. Exported for direct, browser-free testing. */
|
|
22
|
+
declare const createRpcHandler: (account: PrivateKeyAccount, chainIdHex: string) => ({ method, params }: Rpc) => Promise<unknown>;
|
|
23
|
+
/**
|
|
24
|
+
* Install the mock on a context (every page) or a single page. Call before `goto`, so the provider
|
|
25
|
+
* exists when the dapp looks for it. Returns the account address it announces.
|
|
26
|
+
*/
|
|
27
|
+
declare const installMockWallet: (target: BrowserContext | Page, options?: MockWalletOptions) => Promise<string>;
|
|
28
|
+
//#endregion
|
|
29
|
+
export { MockWalletOptions, createRpcHandler, installMockWallet };
|
|
30
|
+
//# sourceMappingURL=mock.d.mts.map
|
package/dist/mock.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
2
|
+
|
|
3
|
+
//#region src/mock.ts
|
|
4
|
+
const DEFAULT_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
|
|
5
|
+
const toHex = (value) => `0x${value.toString(16)}`;
|
|
6
|
+
const isHexMessage = (value) => (/* @__PURE__ */ new RegExp("^0x[\\da-fA-F]*$", "v")).test(value);
|
|
7
|
+
/** The EIP-1193 handler a mock provider answers with. Exported for direct, browser-free testing. */
|
|
8
|
+
const createRpcHandler = (account, chainIdHex) => ({ method, params = [] }) => {
|
|
9
|
+
switch (method) {
|
|
10
|
+
case "eth_requestAccounts":
|
|
11
|
+
case "eth_accounts": return Promise.resolve([account.address]);
|
|
12
|
+
case "eth_chainId": return Promise.resolve(chainIdHex);
|
|
13
|
+
case "personal_sign": {
|
|
14
|
+
const [message] = params;
|
|
15
|
+
if (typeof message !== "string") return Promise.reject(/* @__PURE__ */ new Error("[walletwright/mock] personal_sign expects a string message"));
|
|
16
|
+
return account.signMessage({ message: isHexMessage(message) ? { raw: message } : message });
|
|
17
|
+
}
|
|
18
|
+
case "wallet_switchEthereumChain":
|
|
19
|
+
case "wallet_addEthereumChain": return Promise.resolve(null);
|
|
20
|
+
default: return Promise.reject(/* @__PURE__ */ new Error(`[walletwright/mock] unsupported method: ${method}`));
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Install the mock on a context (every page) or a single page. Call before `goto`, so the provider
|
|
25
|
+
* exists when the dapp looks for it. Returns the account address it announces.
|
|
26
|
+
*/
|
|
27
|
+
const installMockWallet = async (target, options = {}) => {
|
|
28
|
+
const { chainId = 31337, name = "Walletwright Mock", privateKey = DEFAULT_KEY } = options;
|
|
29
|
+
const account = privateKeyToAccount(privateKey);
|
|
30
|
+
const chainIdHex = toHex(chainId);
|
|
31
|
+
const handle = createRpcHandler(account, chainIdHex);
|
|
32
|
+
const bindingName = "__walletwrightMockRpc";
|
|
33
|
+
try {
|
|
34
|
+
await target.exposeFunction(bindingName, (rpc) => handle(rpc));
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (!(error instanceof Error && error.message.includes("already registered"))) throw error;
|
|
37
|
+
}
|
|
38
|
+
await target.addInitScript(([binding, info]) => {
|
|
39
|
+
const call = window[binding];
|
|
40
|
+
const provider = {
|
|
41
|
+
isMetaMask: true,
|
|
42
|
+
request: (rpc) => call(rpc),
|
|
43
|
+
on: () => provider,
|
|
44
|
+
removeListener: () => provider
|
|
45
|
+
};
|
|
46
|
+
window.ethereum = provider;
|
|
47
|
+
const announce = () => {
|
|
48
|
+
window.dispatchEvent(new CustomEvent("eip6963:announceProvider", { detail: Object.freeze({
|
|
49
|
+
info,
|
|
50
|
+
provider
|
|
51
|
+
}) }));
|
|
52
|
+
};
|
|
53
|
+
window.addEventListener("eip6963:requestProvider", announce);
|
|
54
|
+
announce();
|
|
55
|
+
}, [bindingName, {
|
|
56
|
+
icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'/>",
|
|
57
|
+
name,
|
|
58
|
+
rdns: "sh.walletwright.mock",
|
|
59
|
+
uuid: "00000000-0000-0000-0000-000000000000"
|
|
60
|
+
}]);
|
|
61
|
+
return account.address;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
//#endregion
|
|
65
|
+
export { createRpcHandler, installMockWallet };
|
|
66
|
+
//# sourceMappingURL=mock.mjs.map
|
|
@@ -0,0 +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/**\n * A headless injected-wallet fake for dapp tests that don't need a real extension. It announces\n * itself over EIP-6963 and answers the EIP-1193 requests a connect/sign flow makes, signing with a\n * real key so signatures verify. `@walletwright/core/mock` is a separate entry point that needs\n * `viem` (optional peer); the extension-driving core doesn't.\n */\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\n// Anvil/Hardhat account #0, address 0xf39F…92266.\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 // params: [message, address]. Dapps pass either a 0x-hex message or a plain UTF-8 string.\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/**\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 bindingName = \"__walletwrightMockRpc\";\n try {\n await target.exposeFunction(bindingName, (rpc: Rpc) => handle(rpc));\n } catch (error) {\n // Playwright rejects a second exposeFunction with the same name; the bridge is already there,\n // so a repeat install (e.g. in a per-test hook) is fine. Any other error is real.\n if (!(error instanceof Error && error.message.includes(\"already registered\"))) {\n throw error;\n }\n }\n\n await target.addInitScript(\n ([binding, info]) => {\n const call = (window as unknown as Record<string, (rpc: Rpc) => Promise<unknown>>)[binding];\n const provider = {\n isMetaMask: true,\n request: (rpc: Rpc) => call(rpc),\n // Minimal event surface so wagmi/web3-onboard don't throw wiring listeners.\n on: () => provider,\n removeListener: () => provider,\n };\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 uuid: \"00000000-0000-0000-0000-000000000000\",\n },\n ] as const,\n );\n\n return account.address;\n};\n\nexport { createRpcHandler, installMockWallet };\n"],"mappings":";;;AAmBA,MAAM,cAAc;AAIpB,MAAM,SAAS,UAA0B,KAAK,MAAM,SAAS,EAAE;AAE/D,MAAM,gBAAgB,2BAA0C,kCAAiB,EAAC,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;GAEpB,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;;;;;AAMF,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,cAAc;CACpB,IAAI;EACF,MAAM,OAAO,eAAe,cAAc,QAAa,OAAO,GAAG,CAAC;CACpE,SAAS,OAAO;EAGd,IAAI,EAAE,iBAAiB,SAAS,MAAM,QAAQ,SAAS,oBAAoB,IACzE,MAAM;CAEV;CAEA,MAAM,OAAO,eACV,CAAC,SAAS,UAAU;EACnB,MAAM,OAAQ,OAAqE;EACnF,MAAM,WAAW;GACf,YAAY;GACZ,UAAU,QAAa,KAAK,GAAG;GAE/B,UAAU;GACV,sBAAsB;EACxB;EACA,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;EACN,MAAM;CACR,CACF,CACF;CAEA,OAAO,QAAQ;AACjB"}
|