w3wallets
Version:
browser wallets for playwright
349 lines (341 loc) • 13 kB
JavaScript
// src/withWallets.ts
import path from "path";
import fs from "fs";
import crypto from "crypto";
import {
chromium
} from "@playwright/test";
var W3WALLETS_DIR = ".w3wallets";
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function withWallets(test, ...wallets) {
const extensionInfo = wallets.map((w) => {
const extPath = path.join(process.cwd(), W3WALLETS_DIR, w.extensionDir);
ensureWalletExtensionExists(extPath, w.name);
const extensionId = w.extensionId ?? getExtensionId(extPath);
return { path: extPath, id: extensionId, name: w.name };
});
const extensionPaths = extensionInfo.map((e) => e.path);
const fixtures = {
context: async ({}, use, testInfo) => {
const userDataDir = path.join(
process.cwd(),
W3WALLETS_DIR,
".context",
testInfo.testId
);
cleanUserDataDir(userDataDir);
const context = await chromium.launchPersistentContext(userDataDir, {
headless: testInfo.project.use.headless ?? true,
channel: "chromium",
args: [
`--disable-extensions-except=${extensionPaths.join(",")}`,
`--load-extension=${extensionPaths.join(",")}`
]
});
while (context.serviceWorkers().length < extensionPaths.length) {
await sleep(1e3);
}
await use(context);
await context.close();
}
};
for (let i = 0; i < wallets.length; i++) {
const wallet = wallets[i];
const info = extensionInfo[i];
fixtures[wallet.name] = async ({ context }, use) => {
const instance = await initializeExtension(
context,
wallet.WalletClass,
info.id,
wallet.name
);
await use(instance);
};
}
return test.extend(fixtures);
}
function cleanUserDataDir(userDataDir) {
if (fs.existsSync(userDataDir)) {
fs.rmSync(userDataDir, { recursive: true });
}
}
function ensureWalletExtensionExists(walletPath, walletName) {
if (!fs.existsSync(path.join(walletPath, "manifest.json"))) {
const cliAlias = walletName.toLowerCase();
throw new Error(
`Cannot find ${walletName}. Please download it via 'npx w3wallets ${cliAlias}'.`
);
}
}
function getExtensionId(extensionPath) {
const absolutePath = path.resolve(extensionPath);
const manifestPath = path.join(absolutePath, "manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
let dataToHash;
if (manifest.key) {
dataToHash = Buffer.from(manifest.key, "base64");
} else {
dataToHash = Buffer.from(absolutePath);
}
const hash = crypto.createHash("sha256").update(dataToHash).digest();
const ALPHABET = "abcdefghijklmnop";
let extensionId = "";
for (let i = 0; i < 16; i++) {
const byte = hash[i];
extensionId += ALPHABET[byte >> 4 & 15];
extensionId += ALPHABET[byte & 15];
}
return extensionId;
}
async function initializeExtension(context, ExtensionClass, expectedExtensionId, walletName) {
const expectedUrl = `chrome-extension://${expectedExtensionId}/`;
const worker = context.serviceWorkers().find((w) => w.url().startsWith(expectedUrl));
if (!worker) {
const availableIds = context.serviceWorkers().map((w) => w.url().split("/")[2]).filter(Boolean);
throw new Error(
`Service worker for ${walletName} (ID: ${expectedExtensionId}) not found. Available extension IDs: [${availableIds.join(", ")}]`
);
}
const page = await context.newPage();
const extension = new ExtensionClass(page, expectedExtensionId);
return extension;
}
// src/core/types.ts
function createWallet(config2) {
return config2;
}
// src/wallets/metamask/metamask.ts
import { expect } from "@playwright/test";
// src/config.ts
var config = {
/**
* Timeout for actions like click, fill, waitFor, goto.
* Set via W3WALLETS_ACTION_TIMEOUT env variable.
* @default 30000 (30 seconds)
*/
get actionTimeout() {
const value = process.env.W3WALLETS_ACTION_TIMEOUT;
return value ? parseInt(value, 10) : void 0;
}
};
// src/core/wallet.ts
var Wallet = class {
constructor(page, extensionId) {
this.page = page;
this.extensionId = extensionId;
if (config.actionTimeout) {
page.setDefaultTimeout(config.actionTimeout);
}
}
};
// src/wallets/metamask/metamask.ts
var Metamask = class extends Wallet {
defaultPassword = "TestPassword123!";
async gotoOnboardPage() {
await this.page.goto(`chrome-extension://${this.extensionId}/home.html`);
await expect(
this.page.getByRole("button", { name: "I have an existing wallet" })
).toBeVisible();
}
/**
* Onboard MetaMask with a mnemonic phrase
* @param mnemonic - 12 or 24 word recovery phrase
* @param password - Optional password (defaults to TestPassword123!)
*/
async onboard(mnemonic, password) {
const pwd = password ?? this.defaultPassword;
await this.gotoOnboardPage();
await this.page.getByRole("button", { name: "I have an existing wallet" }).click();
await this.page.getByRole("button", { name: "Import using Secret Recovery Phrase" }).click();
const textbox = this.page.getByRole("textbox");
await textbox.click();
for (const word of mnemonic.split(" ")) {
await this.page.keyboard.type(word);
await this.page.keyboard.type(" ");
await this.page.waitForTimeout(30);
}
const continueBtn = this.page.getByTestId("import-srp-confirm");
await continueBtn.click();
const passwordInputs = this.page.locator('input[type="password"]');
await passwordInputs.nth(0).fill(pwd);
await passwordInputs.nth(1).fill(pwd);
await this.page.getByRole("checkbox").click();
await this.page.getByRole("button", { name: "Create password" }).click();
const metametricsBtn = this.page.getByTestId("metametrics-i-agree");
await metametricsBtn.click();
const openWalletBtn = this.page.getByRole("button", {
name: /open wallet/i
});
await openWalletBtn.click();
await this.page.goto(
`chrome-extension://${this.extensionId}/sidepanel.html`
);
}
async approve() {
await this.page.getByTestId("confirm-btn").or(this.page.getByTestId("confirm-footer-button")).or(this.page.getByTestId("page-container-footer-next")).or(this.page.getByRole("button", { name: /confirm/i })).click();
}
async deny() {
const cancelBtn = this.page.getByTestId("cancel-btn").or(this.page.getByTestId("confirm-footer-cancel-button")).or(this.page.getByTestId("page-container-footer-cancel")).or(this.page.getByRole("button", { name: /cancel|reject/i }));
await cancelBtn.first().click();
}
/**
* Lock the MetaMask wallet
*/
async lock() {
await this.page.getByTestId("account-options-menu-button").click();
await this.page.locator("text=Lock MetaMask").click();
}
/**
* Unlock MetaMask with password
*/
async unlock(password) {
const pwd = password ?? this.defaultPassword;
const passwordInput = this.page.getByTestId("unlock-password");
await passwordInput.fill(pwd);
await this.page.getByTestId("unlock-submit").click();
}
/**
* Switch to an existing network in MetaMask
* @param networkName - Name of the network to switch to (e.g., "Ethereum Mainnet", "Sepolia")
*/
async switchNetwork(networkName, networkType = "Popular") {
await this.page.getByTestId("sort-by-networks").click();
if (networkType === "Custom") {
await this.page.getByRole("tab", { name: "Custom" }).click();
}
await this.page.getByText(networkName).click();
await expect(this.page.getByTestId("sort-by-networks")).toHaveText(
networkName
);
}
async switchAccount(accountName) {
await this.page.getByTestId("account-menu-icon").click();
await this.page.getByText(accountName, { exact: true }).click();
}
/**
* Add a custom network to MetaMask
*/
async addNetwork(network) {
await this.page.goto(
`chrome-extension://${this.extensionId}/home.html#settings/networks/add-network`
);
await this.page.getByTestId("network-form-network-name").fill(network.name);
await this.page.getByTestId("network-form-rpc-url").fill(network.rpc);
await this.page.getByTestId("network-form-chain-id").fill(network.chainId.toString());
await this.page.getByTestId("network-form-ticker-input").fill(network.currencySymbol);
await this.page.getByRole("button", { name: /save/i }).click();
}
async addCustomNetwork(settings) {
await this.page.getByTestId("account-options-menu-button").click();
await this.page.getByTestId("global-menu-networks").click();
await this.page.getByRole("button", { name: "Add a custom network" }).click();
await this.page.getByTestId("network-form-network-name").fill(settings.name);
await this.page.getByTestId("network-form-chain-id").fill(settings.chainId.toString());
await this.page.getByTestId("network-form-ticker-input").fill(settings.currencySymbol);
await this.page.getByTestId("test-add-rpc-drop-down").click();
await this.page.getByRole("button", { name: "Add RPC URL" }).click();
await this.page.getByTestId("rpc-url-input-test").fill(settings.rpc);
await this.page.getByRole("button", { name: "Add URL" }).click();
await this.page.getByRole("button", { name: "Save" }).click();
}
async enableTestNetworks() {
await this.page.getByTestId("account-options-menu-button").click();
await this.page.getByTestId("global-menu-networks").click();
await this.page.locator("text=Show test networks >> xpath=following-sibling::label").click();
await this.page.keyboard.press("Escape");
}
async importAccount(privateKey) {
await this.page.getByTestId("account-menu-icon").click();
await this.page.getByTestId("account-list-add-wallet-button").click();
await this.page.getByTestId("add-wallet-modal-import-account").click();
await this.page.locator("#private-key-box").fill(privateKey);
await this.page.getByTestId("import-account-confirm-button").click();
await this.page.getByRole("button", { name: "Back" }).click();
}
async accountNameIs(accountName) {
await expect(this.page.getByTestId("account-menu-icon")).toContainText(
accountName
);
}
};
// src/wallets/polkadot-js/polkadot-js.ts
import { expect as expect2 } from "@playwright/test";
var PolkadotJS = class extends Wallet {
defaultPassword = "11111111";
async gotoOnboardPage() {
await this.page.goto(`chrome-extension://${this.extensionId}/index.html`);
await expect2(
this.page.getByText("Before we start, just a couple of notes")
).toBeVisible();
}
async onboard(seed, password, name) {
await this.gotoOnboardPage();
await this.page.getByRole("button", { name: "Understood, let me continue" }).click();
await this.page.getByRole("button", { name: "I Understand" }).click();
await this.page.locator(".popupToggle").first().click();
await this.page.getByText("Import account from pre-existing seed").click();
await this.page.locator(".seedInput").getByRole("textbox").fill(seed);
await this.page.getByRole("button", { name: "Next" }).click();
await this._getLabeledInput("A descriptive name for your account").fill(
name ?? "Test"
);
await this._getLabeledInput("A new password for this account").fill(
password ?? this.defaultPassword
);
await this._getLabeledInput("Repeat password for verification").fill(
password ?? this.defaultPassword
);
await this.page.getByRole("button", { name: "Add the account with the supplied seed" }).click();
}
async selectAllAccounts() {
await this.page.getByText("Select all").click();
}
async selectAccount(accountId) {
const cb = this.page.locator(".accountWichCheckbox").filter({ hasText: accountId }).locator(".accountTree-checkbox").locator("span");
await cb.check().catch(() => cb.check());
}
async enterPassword(password) {
await this._getLabeledInput("Password for this account").fill(
password ?? this.defaultPassword
);
}
async approve() {
const connect = this.page.getByRole("button", { name: "Connect" });
const signTransaction = this.page.getByRole("button", {
name: "Sign the transaction"
});
await connect.or(signTransaction).click();
}
async deny() {
const reject = this.page.getByRole("button", { name: "Reject" });
const cancel = this.page.getByRole("link", { name: "Cancel" });
await reject.or(cancel).click();
}
_getLabeledInput(label) {
return this.page.locator(
`//label[text()="${label}"]/following-sibling::input`
);
}
};
// src/wallets/index.ts
var metamask = createWallet({
name: "metamask",
extensionDir: "metamask",
WalletClass: Metamask
});
var polkadotJS = createWallet({
name: "polkadotJS",
extensionDir: "polkadotjs",
WalletClass: PolkadotJS
});
export {
Metamask,
PolkadotJS,
config,
createWallet,
metamask,
polkadotJS,
withWallets
};