diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
index 7ea78ec..6be735f 100644
--- a/.github/workflows/playwright.yml
+++ b/.github/workflows/playwright.yml
@@ -18,7 +18,7 @@ jobs:
name: "Playwright Tests"
runs-on: ubuntu-latest
container:
- image: mcr.microsoft.com/playwright:v1.57.0-noble
+ image: mcr.microsoft.com/playwright:v1.58.1-noble
env:
NEXT_PUBLIC_WC_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_WC_PROJECT_ID }}
steps:
@@ -55,8 +55,16 @@ jobs:
- name: ๐จ Copy test envs
run: cp packages/w3wallets/.env.example packages/w3wallets/.env
+ - name: ๐จ Build w3wallets
+ run: yarn build
+ working-directory: packages/w3wallets
+
+ - name: ๐๏ธ Build wallet cache
+ run: xvfb-run --auto-servernum --server-args='-screen 0 1280x800x24' npx w3wallets cache tests/wallets-cache/
+ working-directory: packages/w3wallets
+
- name: ๐งช Run tests
- run: xvfb-run --auto-servernum --server-args='-screen 0 1280x800x24' yarn test
+ run: xvfb-run --auto-servernum --server-args='-screen 0 1280x800x24' yarn test:ci
- name: Upload test artifacts
uses: actions/upload-artifact@v4
diff --git a/package.json b/package.json
index 9380204..149344e 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,8 @@
"deploy": "yarn workspace @w3wallets/test-dapp deploy",
"build": "yarn workspace w3wallets build",
"download-wallets": "yarn workspace w3wallets download-wallets",
- "test": "yarn workspace w3wallets test"
+ "test": "yarn workspace w3wallets test",
+ "test:ci": "yarn workspace w3wallets test:ci"
},
"packageManager": "yarn@4.6.0",
"dependencies": {
diff --git a/packages/w3wallets/.env.example b/packages/w3wallets/.env.example
index d0525ce..c3d0e34 100644
--- a/packages/w3wallets/.env.example
+++ b/packages/w3wallets/.env.example
@@ -3,4 +3,5 @@ ETHEREUM_PRIVATE_KEY1=0x903518e191f3843d017f0f9611cfdd27c655565917887faf2762549e
ETHEREUM_MNEMONIC2=typical flock crash unique wage turtle become trim legend diet dance kitchen
ETHEREUM_PRIVATE_KEY2=0xbf80a3a6906d48c828b1f3a71ee529092375557f3ab4d3088f1e9495cdb7b624
SUBSTRATE_SEED=focus inspire onion claw ski jaguar kidney screen bike kiss icon aerobic
-W3WALLETS_ACTION_TIMEOUT=60000
\ No newline at end of file
+W3WALLETS_ACTION_TIMEOUT=60000
+W3WALLETS_EXPECT_TIMEOUT=60000
\ No newline at end of file
diff --git a/packages/w3wallets/README.md b/packages/w3wallets/README.md
index 252e5c3..a5d9ac7 100644
--- a/packages/w3wallets/README.md
+++ b/packages/w3wallets/README.md
@@ -105,6 +105,65 @@ test("Can connect MetaMask to dApp", async ({ page, metamask }) => {
});
```
+## Caching
+
+Wallet onboarding can be slow. Caching lets you run the setup once and reuse the browser profile across tests.
+
+#### 1. Create a setup file
+
+Create a `*.cache.ts` file in a `wallets-cache/` directory (default):
+
+```ts
+// wallets-cache/default.cache.ts
+import { prepareWallet, metamask } from "w3wallets";
+
+export default prepareWallet(metamask, async (wallet, page) => {
+ await wallet.onboard("your seed phrase ...", "YourPassword123!");
+});
+```
+
+#### 2. Build the cache
+
+```sh
+npx w3wallets cache
+```
+
+
+CLI Options
+
+```
+USAGE:
+ npx w3wallets cache [OPTIONS] [directory]
+
+OPTIONS:
+ -f, --force Force rebuild even if cache exists
+ --headed Run browser in headed mode
+ directory Directory containing *.cache.{ts,js} files (default: ./wallets-cache/)
+```
+
+
+
+The cached profiles are stored in `.w3wallets/cache/`. The `.w3wallets` directory should already be in `.gitignore`.
+
+#### 3. Use cached wallets in tests
+
+Import the setup and pass it to `withWallets`:
+
+```ts
+import { test as base, expect } from "@playwright/test";
+import { withWallets } from "w3wallets";
+import cachedMetamask from "./wallets-cache/default.cache";
+
+const test = withWallets(base, cachedMetamask);
+
+test("wallet is ready", async ({ metamask }) => {
+ await metamask.unlock("YourPassword123!");
+ // wallet is already onboarded
+});
+```
+
+> **Note:** All wallets in a test must be either all cached or all non-cached.
+
## Configuration
Configure library behavior via environment variables:
diff --git a/packages/w3wallets/package.json b/packages/w3wallets/package.json
index ef68a5a..2e55813 100644
--- a/packages/w3wallets/package.json
+++ b/packages/w3wallets/package.json
@@ -1,7 +1,7 @@
{
"name": "w3wallets",
"description": "browser wallets for playwright",
- "version": "1.0.0-beta.3",
+ "version": "1.0.0-beta.4",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"homepage": "https://github.com/Maksandre/w3wallets",
@@ -31,7 +31,7 @@
"bin": "./src/scripts/download.js",
"scripts": {
"download-wallets": "npx w3wallets pjs mm",
- "test": "npx playwright test --project=local --workers=2",
+ "test": "npx playwright test --project=local",
"test:ci": "npx playwright test --project=ci",
"build": "tsup",
"clean": "rm -rf dist",
@@ -46,7 +46,7 @@
"devDependencies": {
"@arethetypeswrong/cli": "^0.17.2",
"@changesets/cli": "^2.27.11",
- "@playwright/test": "1.57.0",
+ "@playwright/test": "1.58.1",
"@types/node": "^22.10.5",
"dotenv": "^16.4.7",
"prettier": "^3.4.2",
diff --git a/packages/w3wallets/playwright.config.ts b/packages/w3wallets/playwright.config.ts
index 764aea9..626184e 100644
--- a/packages/w3wallets/playwright.config.ts
+++ b/packages/w3wallets/playwright.config.ts
@@ -11,9 +11,9 @@ export default defineConfig({
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
- // retries: process.env.CI ? 2 : 0,
+ retries: process.env.CI ? 1 : 0,
/* Opt out of parallel tests on CI. */
- // workers: process.env.CI ? 1 : undefined,
+ workers: process.env.CI ? 2 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
diff --git a/packages/w3wallets/src/cache/buildCache.ts b/packages/w3wallets/src/cache/buildCache.ts
new file mode 100644
index 0000000..0cabf21
--- /dev/null
+++ b/packages/w3wallets/src/cache/buildCache.ts
@@ -0,0 +1,262 @@
+import path from "path";
+import fs from "fs";
+import crypto from "crypto";
+import { chromium } from "@playwright/test";
+import { CACHE_DIR } from "./constants";
+import { isCachedConfig } from "./types";
+import type { CachedWalletConfig } from "./types";
+
+const W3WALLETS_DIR = ".w3wallets";
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+export interface BuildOptions {
+ force?: boolean;
+ headed?: boolean;
+}
+
+export function hashFilePath(filePath: string): string {
+ const hash = crypto.createHash("sha256").update(filePath).digest("hex");
+ return hash.slice(0, 20);
+}
+
+/**
+ * Build cache for a single setup file.
+ *
+ * @param compiledFilePath - Path to the compiled JS file to import
+ * @param originalFilePath - Path to the original source file (used for hash)
+ * @param options - Build options
+ */
+export async function buildCacheForSetup(
+ compiledFilePath: string,
+ originalFilePath: string,
+ options: BuildOptions = {},
+): Promise {
+ const hash = hashFilePath(path.resolve(originalFilePath));
+ const cacheDir = path.join(process.cwd(), CACHE_DIR, hash);
+
+ if (!options.force && fs.existsSync(cacheDir)) {
+ console.log(` Cache exists: ${cacheDir} (use --force to rebuild)`);
+ return;
+ }
+
+ // Import the compiled setup file
+ const compiled = require(path.resolve(compiledFilePath));
+ const config: CachedWalletConfig = compiled.default ?? compiled;
+
+ if (!isCachedConfig(config)) {
+ throw new Error(
+ `Setup file must export a CachedWalletConfig (use prepareWallet()): ${originalFilePath}`,
+ );
+ }
+
+ // Resolve extension path
+ const extPath = path.join(process.cwd(), W3WALLETS_DIR, config.extensionDir);
+ if (!fs.existsSync(path.join(extPath, "manifest.json"))) {
+ throw new Error(
+ `Extension not found at ${extPath}. Run 'npx w3wallets ${config.name}' first.`,
+ );
+ }
+
+ console.log(` Building cache for "${config.name}"...`);
+
+ // Clean and create cache dir
+ if (fs.existsSync(cacheDir)) {
+ fs.rmSync(cacheDir, { recursive: true });
+ }
+
+ const context = await chromium.launchPersistentContext(cacheDir, {
+ headless: !options.headed,
+ channel: "chromium",
+ args: [
+ `--disable-extensions-except=${extPath}`,
+ `--load-extension=${extPath}`,
+ ],
+ });
+
+ // Wait for service worker
+ while (context.serviceWorkers().length < 1) {
+ await sleep(1000);
+ }
+
+ // Derive extension ID
+ const worker = context.serviceWorkers()[0]!;
+ const extensionId = worker.url().split("/")[2]!;
+
+ // Create wallet instance and run setup
+ const page = await context.newPage();
+ const wallet = new config.WalletClass(page, extensionId);
+ await config.setupFn(wallet, page);
+
+ // Force MV3 extensions to persist chrome.storage.session data to chrome.storage.local.
+ // MV3 extensions like MetaMask use chrome.storage.session (memory-only) for vault data.
+ // We copy it to chrome.storage.local so it survives browser restart from cache.
+ // Use a helper HTML page injected into the extension to bypass LavaMoat's scuttling.
+ try {
+ const extDir = path.join(
+ process.cwd(),
+ W3WALLETS_DIR,
+ config.extensionDir,
+ );
+ const helperJs = path.join(extDir, "_w3wallets_helper.js");
+ const helperHtml = path.join(extDir, "_w3wallets_helper.html");
+ fs.writeFileSync(
+ helperJs,
+ `chrome.storage.session.get(null, (sessionData) => {
+ const keys = Object.keys(sessionData);
+ if (keys.length === 0) {
+ document.title = "done:0";
+ return;
+ }
+ // Copy session data to local, and switch storageKind to "data"
+ // so MetaMask reads everything from chrome.storage.local on restart.
+ chrome.storage.local.set(sessionData, () => {
+ chrome.storage.local.get("meta", (result) => {
+ const meta = result.meta || {};
+ // "single" means all data is in chrome.storage.local
+ meta.storageKind = "data";
+ chrome.storage.local.set({ meta }, () => {
+ document.title = "done:" + keys.length;
+ });
+ });
+ });
+ });`,
+ );
+ fs.writeFileSync(
+ helperHtml,
+ ``,
+ );
+
+ const helperPage = await context.newPage();
+ await helperPage.goto(
+ `chrome-extension://${extensionId}/_w3wallets_helper.html`,
+ );
+ await helperPage.waitForFunction(
+ () => document.title.startsWith("done:"),
+ null,
+ { timeout: 10000 },
+ );
+ const title = await helperPage.title();
+ const count = title.split(":")[1];
+ console.log(
+ ` Persisted ${count} session storage keys to local storage`,
+ );
+ await helperPage.close();
+
+ // Clean up helper files
+ fs.unlinkSync(helperJs);
+ fs.unlinkSync(helperHtml);
+ } catch (err) {
+ console.log(` Note: could not persist session storage: ${err}`);
+ }
+
+ await sleep(2000);
+ await context.close();
+
+ // Write metadata for cache discovery at test time
+ fs.writeFileSync(
+ path.join(cacheDir, ".meta.json"),
+ JSON.stringify({ name: config.name }),
+ );
+
+ console.log(` Cached: ${cacheDir}`);
+}
+
+/**
+ * Find the cache directory for a wallet by scanning .meta.json files.
+ */
+export function findCacheDir(walletName: string): string | null {
+ const cacheRoot = path.join(process.cwd(), CACHE_DIR);
+ if (!fs.existsSync(cacheRoot)) return null;
+
+ const entries = fs.readdirSync(cacheRoot, { withFileTypes: true });
+ for (const entry of entries) {
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
+ const metaPath = path.join(cacheRoot, entry.name, ".meta.json");
+ if (!fs.existsSync(metaPath)) continue;
+ try {
+ const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
+ if (meta.name === walletName) {
+ return path.join(cacheRoot, entry.name);
+ }
+ } catch {
+ continue;
+ }
+ }
+ return null;
+}
+
+export async function buildAllCaches(
+ directory: string,
+ options: BuildOptions = {},
+): Promise {
+ const absoluteDir = path.resolve(directory);
+
+ if (!fs.existsSync(absoluteDir)) {
+ throw new Error(`Setup directory not found: ${absoluteDir}`);
+ }
+
+ // Find setup files
+ const files = fs.readdirSync(absoluteDir).filter((f) => {
+ return /\.cache\.(ts|js)$/.test(f);
+ });
+
+ if (files.length === 0) {
+ console.log(`No *.cache.{ts,js} files found in ${absoluteDir}`);
+ return;
+ }
+
+ console.log(`Found ${files.length} setup file(s) in ${absoluteDir}`);
+
+ // Compile TS files with tsup
+ const distDir = path.join(process.cwd(), CACHE_DIR, ".dist");
+ const entryPoints = files.map((f) => path.join(absoluteDir, f));
+
+ console.log("Compiling setup files...");
+ const { build } = await import("tsup");
+ await build({
+ entry: entryPoints,
+ outDir: distDir,
+ format: ["cjs"],
+ clean: true,
+ silent: true,
+ });
+
+ // Run each setup
+ let built = 0;
+ let skipped = 0;
+
+ for (const file of files) {
+ const baseName = file.replace(/\.ts$/, ".js");
+ const compiledPath = path.join(distDir, baseName);
+ const originalPath = path.join(absoluteDir, file);
+
+ console.log(`\n[${file}]`);
+
+ if (!fs.existsSync(compiledPath)) {
+ console.log(` Compiled file not found: ${compiledPath}`);
+ continue;
+ }
+
+ const hash = hashFilePath(path.resolve(originalPath));
+ const cacheExists = fs.existsSync(
+ path.join(process.cwd(), CACHE_DIR, hash),
+ );
+
+ if (!options.force && cacheExists) {
+ console.log(` Cache exists (use --force to rebuild)`);
+ skipped++;
+ continue;
+ }
+
+ await buildCacheForSetup(compiledPath, originalPath, {
+ ...options,
+ force: true,
+ });
+ built++;
+ }
+
+ console.log(`\nDone: ${built} built, ${skipped} skipped`);
+}
diff --git a/packages/w3wallets/src/cache/constants.ts b/packages/w3wallets/src/cache/constants.ts
new file mode 100644
index 0000000..17978ff
--- /dev/null
+++ b/packages/w3wallets/src/cache/constants.ts
@@ -0,0 +1 @@
+export const CACHE_DIR = ".w3wallets/cache";
diff --git a/packages/w3wallets/src/cache/index.ts b/packages/w3wallets/src/cache/index.ts
new file mode 100644
index 0000000..603f1d8
--- /dev/null
+++ b/packages/w3wallets/src/cache/index.ts
@@ -0,0 +1,5 @@
+export { CACHE_DIR } from "./constants";
+export { prepareWallet } from "./prepareWallet";
+export { findCacheDir } from "./buildCache";
+export { isCachedConfig } from "./types";
+export type { SetupFn, CachedWalletConfig } from "./types";
diff --git a/packages/w3wallets/src/cache/prepareWallet.ts b/packages/w3wallets/src/cache/prepareWallet.ts
new file mode 100644
index 0000000..57c5369
--- /dev/null
+++ b/packages/w3wallets/src/cache/prepareWallet.ts
@@ -0,0 +1,16 @@
+import type { IWallet, WalletConfig } from "../core/types";
+import type { CachedWalletConfig, SetupFn } from "./types";
+
+export function prepareWallet<
+ TName extends string,
+ TWallet extends IWallet,
+>(
+ walletConfig: WalletConfig,
+ setupFn: SetupFn,
+): CachedWalletConfig {
+ return {
+ ...walletConfig,
+ setupFn,
+ __cached: true,
+ };
+}
diff --git a/packages/w3wallets/src/cache/types.ts b/packages/w3wallets/src/cache/types.ts
new file mode 100644
index 0000000..4b29a36
--- /dev/null
+++ b/packages/w3wallets/src/cache/types.ts
@@ -0,0 +1,21 @@
+import type { Page } from "@playwright/test";
+import type { IWallet, WalletConfig } from "../core/types";
+
+export type SetupFn = (
+ wallet: TWallet,
+ page: Page,
+) => Promise;
+
+export interface CachedWalletConfig<
+ TName extends string = string,
+ TWallet extends IWallet = IWallet,
+> extends WalletConfig {
+ setupFn: SetupFn;
+ __cached: true;
+}
+
+export function isCachedConfig(
+ config: WalletConfig,
+): config is CachedWalletConfig {
+ return "__cached" in config && config.__cached === true;
+}
diff --git a/packages/w3wallets/src/config.ts b/packages/w3wallets/src/config.ts
index 550b29a..c4bed44 100644
--- a/packages/w3wallets/src/config.ts
+++ b/packages/w3wallets/src/config.ts
@@ -12,4 +12,14 @@ export const config = {
const value = process.env.W3WALLETS_ACTION_TIMEOUT;
return value ? parseInt(value, 10) : undefined;
},
+
+ /**
+ * Timeout for expect assertions like toBeVisible, toContainText.
+ * Set via W3WALLETS_EXPECT_TIMEOUT env variable.
+ * @default undefined (uses Playwright's default of 5000ms)
+ */
+ get expectTimeout(): number | undefined {
+ const value = process.env.W3WALLETS_EXPECT_TIMEOUT;
+ return value ? parseInt(value, 10) : undefined;
+ },
};
diff --git a/packages/w3wallets/src/core/types.ts b/packages/w3wallets/src/core/types.ts
index 6340d42..9f94f9f 100644
--- a/packages/w3wallets/src/core/types.ts
+++ b/packages/w3wallets/src/core/types.ts
@@ -26,6 +26,8 @@ export interface WalletConfig<
* that don't have a `key` field in their manifest.
*/
extensionId?: string;
+ /** Path to the extension's home page (e.g. "home.html"), used for cached wallets */
+ homeUrl?: string;
}
/**
diff --git a/packages/w3wallets/src/core/wallet.ts b/packages/w3wallets/src/core/wallet.ts
index a1b8a3c..f6cdf24 100644
--- a/packages/w3wallets/src/core/wallet.ts
+++ b/packages/w3wallets/src/core/wallet.ts
@@ -5,7 +5,7 @@ import { config } from "../config";
export abstract class Wallet implements IWallet {
constructor(
public readonly page: Page,
- protected readonly extensionId: string,
+ public readonly extensionId: string,
) {
if (config.actionTimeout) {
page.setDefaultTimeout(config.actionTimeout);
diff --git a/packages/w3wallets/src/index.ts b/packages/w3wallets/src/index.ts
index 540913f..e446627 100644
--- a/packages/w3wallets/src/index.ts
+++ b/packages/w3wallets/src/index.ts
@@ -10,6 +10,10 @@ export { createWallet } from "./core/types";
// Configuration
export { config } from "./config";
+// Cache
+export { prepareWallet, isCachedConfig } from "./cache";
+export type { SetupFn, CachedWalletConfig } from "./cache";
+
// Types
export type { IWallet, WalletConfig, Network } from "./core/types";
export type { NetworkSettings } from "./wallets/metamask";
diff --git a/packages/w3wallets/src/scripts/cache.ts b/packages/w3wallets/src/scripts/cache.ts
new file mode 100644
index 0000000..ffa7781
--- /dev/null
+++ b/packages/w3wallets/src/scripts/cache.ts
@@ -0,0 +1,62 @@
+import { buildAllCaches } from "../cache/buildCache";
+
+interface CacheOptions {
+ force: boolean;
+ headed: boolean;
+ directory: string;
+}
+
+function parseArgs(args: string[]): CacheOptions {
+ const options: CacheOptions = {
+ force: false,
+ headed: false,
+ directory: "./wallets-cache/",
+ };
+
+ for (let i = 0; i < args.length; i++) {
+ const arg = args[i]!;
+ if (arg === "-f" || arg === "--force") {
+ options.force = true;
+ } else if (arg === "--headed") {
+ options.headed = true;
+ } else if (arg === "-h" || arg === "--help") {
+ console.log(`
+w3wallets cache - Build wallet caches from setup files
+
+USAGE:
+ npx w3wallets cache [OPTIONS] [directory]
+
+ARGUMENTS:
+ directory Directory containing *.cache.{ts,js} files (default: ./wallets-cache/)
+
+OPTIONS:
+ -f, --force Force rebuild even if cache exists
+ --headed Run browser in headed mode
+ -h, --help Show this help message
+
+EXAMPLES:
+ npx w3wallets cache # Build caches from ./wallets-cache/
+ npx w3wallets cache ./tests/setups/ # Custom directory
+ npx w3wallets cache --force # Force rebuild
+`);
+ process.exit(0);
+ } else if (!arg.startsWith("-")) {
+ options.directory = arg;
+ } else {
+ console.error(`Error: Unknown option "${arg}"`);
+ process.exit(1);
+ }
+ }
+
+ return options;
+}
+
+const options = parseArgs(process.argv.slice(2));
+
+buildAllCaches(options.directory, {
+ force: options.force,
+ headed: options.headed,
+}).catch((err) => {
+ console.error(`Cache build failed: ${err.message}`);
+ process.exit(1);
+});
diff --git a/packages/w3wallets/src/scripts/download.js b/packages/w3wallets/src/scripts/download.js
index 352d725..28e5f76 100755
--- a/packages/w3wallets/src/scripts/download.js
+++ b/packages/w3wallets/src/scripts/download.js
@@ -218,8 +218,31 @@ function parseArgs(args) {
}
}
-// Parse command line arguments
-parseArgs(process.argv.slice(2));
+// Check if first arg is "cache" โ delegate to compiled cache script
+const rawArgs = process.argv.slice(2);
+if (rawArgs[0] === "cache") {
+ const { execFileSync } = require("child_process");
+ const cacheScript = path.join(__dirname, "..", "..", "dist", "scripts", "cache.js");
+
+ if (!fs.existsSync(cacheScript)) {
+ console.error(
+ "Error: Cache script not found. Make sure w3wallets is built (run: npx tsup).",
+ );
+ process.exit(1);
+ }
+
+ try {
+ execFileSync(process.execPath, [cacheScript, ...rawArgs.slice(1)], {
+ stdio: "inherit",
+ });
+ } catch (err) {
+ process.exit(err.status || 1);
+ }
+ process.exit(0);
+}
+
+// Parse command line arguments for download mode
+parseArgs(rawArgs);
// Handle --help
if (CLI_OPTIONS.help) {
diff --git a/packages/w3wallets/src/wallets/index.ts b/packages/w3wallets/src/wallets/index.ts
index 3b3a113..87540ca 100644
--- a/packages/w3wallets/src/wallets/index.ts
+++ b/packages/w3wallets/src/wallets/index.ts
@@ -9,6 +9,7 @@ export const metamask = createWallet({
name: "metamask",
extensionDir: "metamask",
WalletClass: Metamask,
+ homeUrl: "home.html",
});
/**
@@ -18,6 +19,7 @@ export const polkadotJS = createWallet({
name: "polkadotJS",
extensionDir: "polkadotjs",
WalletClass: PolkadotJS,
+ homeUrl: "index.html",
});
// Export classes for advanced usage / extending
diff --git a/packages/w3wallets/src/wallets/metamask/metamask.ts b/packages/w3wallets/src/wallets/metamask/metamask.ts
index af78e72..6e36092 100644
--- a/packages/w3wallets/src/wallets/metamask/metamask.ts
+++ b/packages/w3wallets/src/wallets/metamask/metamask.ts
@@ -1,5 +1,6 @@
import { expect } from "@playwright/test";
import { Wallet } from "../../core/wallet";
+import { config } from "../../config";
import type { NetworkSettings } from "./types";
export class Metamask extends Wallet {
@@ -9,7 +10,7 @@ export class Metamask extends Wallet {
await this.page.goto(`chrome-extension://${this.extensionId}/home.html`);
await expect(
this.page.getByRole("button", { name: "I have an existing wallet" }),
- ).toBeVisible();
+ ).toBeVisible({ timeout: config.expectTimeout });
}
/**
@@ -137,6 +138,7 @@ export class Metamask extends Wallet {
// Wait for the network list to appear and click the desired network
await expect(this.page.getByTestId("sort-by-networks")).toHaveText(
networkName,
+ { timeout: config.expectTimeout },
);
}
@@ -210,6 +212,7 @@ export class Metamask extends Wallet {
async accountNameIs(accountName: string) {
await expect(this.page.getByTestId("account-menu-icon")).toContainText(
accountName,
+ { timeout: config.expectTimeout },
);
}
}
diff --git a/packages/w3wallets/src/wallets/polkadot-js/polkadot-js.ts b/packages/w3wallets/src/wallets/polkadot-js/polkadot-js.ts
index 8709d03..27cfde3 100644
--- a/packages/w3wallets/src/wallets/polkadot-js/polkadot-js.ts
+++ b/packages/w3wallets/src/wallets/polkadot-js/polkadot-js.ts
@@ -1,5 +1,6 @@
import { expect } from "@playwright/test";
import { Wallet } from "../../core/wallet";
+import { config } from "../../config";
export class PolkadotJS extends Wallet {
private defaultPassword = "11111111";
@@ -8,7 +9,7 @@ export class PolkadotJS extends Wallet {
await this.page.goto(`chrome-extension://${this.extensionId}/index.html`);
await expect(
this.page.getByText("Before we start, just a couple of notes"),
- ).toBeVisible();
+ ).toBeVisible({ timeout: config.expectTimeout });
}
async onboard(seed: string, password?: string, name?: string) {
diff --git a/packages/w3wallets/src/withWallets.ts b/packages/w3wallets/src/withWallets.ts
index b95a729..107bc24 100644
--- a/packages/w3wallets/src/withWallets.ts
+++ b/packages/w3wallets/src/withWallets.ts
@@ -12,6 +12,9 @@ import type {
WalletConfig,
WalletFixturesFromConfigs,
} from "./core/types";
+import { isCachedConfig } from "./cache/types";
+import { findCacheDir } from "./cache/buildCache";
+import { CACHE_DIR } from "./cache/constants";
// TODO: with new CLI this directory can be overwritten with -o argument
const W3WALLETS_DIR = ".w3wallets";
@@ -38,6 +41,17 @@ export function withWallets(
test: typeof base,
...wallets: T
) {
+ // Check for mixed cached/non-cached wallets
+ const cachedCount = wallets.filter((w) => isCachedConfig(w)).length;
+ if (cachedCount > 0 && cachedCount < wallets.length) {
+ throw new Error(
+ "Mixing cached and non-cached wallet configs is not supported. " +
+ "All wallets must be either cached (via prepareWallet) or non-cached.",
+ );
+ }
+
+ const useCachedContext = cachedCount > 0;
+
// Validate and build extension paths + IDs
const extensionInfo = wallets.map((w) => {
const extPath = path.join(process.cwd(), W3WALLETS_DIR, w.extensionDir);
@@ -68,6 +82,21 @@ export function withWallets(
cleanUserDataDir(userDataDir);
+ if (useCachedContext) {
+ // For cached configs, copy the cache directory as the user data dir
+ // Currently only single-wallet cached configs are supported
+ const wallet = wallets[0]!;
+ const cacheDir = findCacheDir(wallet.name);
+ if (!cacheDir) {
+ throw new Error(
+ `Cache not found for wallet "${wallet.name}". ` +
+ `Searched in: ${path.join(process.cwd(), CACHE_DIR)}\n` +
+ `Run 'npx w3wallets cache' to build the cache first.`,
+ );
+ }
+ fs.cpSync(cacheDir, userDataDir, { recursive: true });
+ }
+
const context = await chromium.launchPersistentContext(userDataDir, {
headless: testInfo.project.use.headless ?? true,
channel: "chromium",
@@ -96,13 +125,25 @@ export function withWallets(
{ context }: { context: BrowserContext },
use: (instance: IWallet) => Promise,
) => {
- const instance = await initializeExtension(
- context,
- wallet.WalletClass,
- info.id,
- wallet.name,
- );
- await use(instance);
+ if (isCachedConfig(wallet)) {
+ // Cached wallet: find existing extension page instead of creating new one
+ const instance = await findCachedExtension(
+ context,
+ wallet.WalletClass,
+ info.id,
+ wallet.name,
+ wallet.homeUrl,
+ );
+ await use(instance);
+ } else {
+ const instance = await initializeExtension(
+ context,
+ wallet.WalletClass,
+ info.id,
+ wallet.name,
+ );
+ await use(instance);
+ }
};
}
@@ -177,6 +218,46 @@ function getExtensionId(extensionPath: string): string {
return extensionId;
}
+/**
+ * Finds an existing extension page for a cached wallet.
+ * Since the browser profile is restored from cache, the extension is already set up.
+ */
+async function findCachedExtension(
+ context: BrowserContext,
+ ExtensionClass: new (page: Page, extensionId: string) => T,
+ expectedExtensionId: string,
+ walletName: string,
+ homeUrl?: string,
+): Promise {
+ 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 in cached context. ` +
+ `Available extension IDs: [${availableIds.join(", ")}]. ` +
+ `Try rebuilding the cache with 'npx w3wallets cache --force'.`,
+ );
+ }
+
+ const page = await context.newPage();
+ if (homeUrl) {
+ await page.goto(
+ `chrome-extension://${expectedExtensionId}/${homeUrl}`,
+ );
+ }
+ const extension = new ExtensionClass(page, expectedExtensionId);
+
+ return extension;
+}
+
/**
* Initializes an extension by finding its service worker and navigating to onboard page.
*/
diff --git a/packages/w3wallets/tests/fixtures/metamask-fixture.ts b/packages/w3wallets/tests/fixtures/metamask-fixture.ts
index 1068b4f..d33251a 100644
--- a/packages/w3wallets/tests/fixtures/metamask-fixture.ts
+++ b/packages/w3wallets/tests/fixtures/metamask-fixture.ts
@@ -1,13 +1,20 @@
import { test as base, expect } from "@playwright/test";
-import { withWallets, metamask } from "../../src";
-import config from "../utils/config";
+import { withWallets } from "../../src";
+import cachedMetamask from "../wallets-cache/default.cache";
import { EthereumPage } from "../POM";
-const metamaskTest = withWallets(base, metamask).extend<{
+const metamaskTest = withWallets(base, cachedMetamask).extend<{
ethereumPage: EthereumPage;
}>({
metamask: async ({ metamask }, use) => {
- await metamask.onboard(config.ethMnemonic);
+ await metamask.unlock();
+ await metamask.page
+ .getByRole("button", { name: "Open wallet" })
+ .click({ timeout: 10000 });
+ // Navigate to sidepanel (same as onboard() does at the end)
+ await metamask.page.goto(
+ `chrome-extension://${metamask.extensionId}/sidepanel.html`,
+ );
await use(metamask);
},
diff --git a/packages/w3wallets/tests/metamask.onboard.spec.ts b/packages/w3wallets/tests/metamask.onboard.spec.ts
new file mode 100644
index 0000000..89c5998
--- /dev/null
+++ b/packages/w3wallets/tests/metamask.onboard.spec.ts
@@ -0,0 +1,10 @@
+import { test as base, expect } from "@playwright/test";
+import { withWallets, metamask } from "../src";
+import config from "./utils/config";
+
+const test = withWallets(base, metamask);
+
+test("Can onboard MetaMask with seed phrase", async ({ metamask }) => {
+ await metamask.onboard(config.ethMnemonic);
+ await metamask.accountNameIs("Account 1");
+});
diff --git a/packages/w3wallets/tests/wallets-cache/default.cache.ts b/packages/w3wallets/tests/wallets-cache/default.cache.ts
new file mode 100644
index 0000000..6ef3aa4
--- /dev/null
+++ b/packages/w3wallets/tests/wallets-cache/default.cache.ts
@@ -0,0 +1,8 @@
+import { prepareWallet, metamask } from "../../src";
+import config from "../utils/config";
+
+const password = "TestPassword123!";
+
+export default prepareWallet(metamask, async (wallet, page) => {
+ await wallet.onboard(config.ethMnemonic, password);
+});
diff --git a/packages/w3wallets/tsup.config.ts b/packages/w3wallets/tsup.config.ts
index db09caf..3795881 100644
--- a/packages/w3wallets/tsup.config.ts
+++ b/packages/w3wallets/tsup.config.ts
@@ -1,9 +1,18 @@
import { defineConfig } from "tsup";
-export default defineConfig({
- entryPoints: ["src/index.ts"],
- format: ["cjs", "esm"],
- dts: true,
- outDir: "dist",
- clean: true,
-});
+export default defineConfig([
+ {
+ entryPoints: ["src/index.ts"],
+ format: ["cjs", "esm"],
+ dts: true,
+ outDir: "dist",
+ clean: true,
+ },
+ {
+ entryPoints: ["src/scripts/cache.ts"],
+ format: ["cjs"],
+ outDir: "dist/scripts",
+ banner: { js: "#!/usr/bin/env node" },
+ external: ["@playwright/test", "tsup", "esbuild"],
+ },
+]);
diff --git a/yarn.lock b/yarn.lock
index 525f3e9..55095a6 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1823,14 +1823,14 @@ __metadata:
languageName: node
linkType: hard
-"@playwright/test@npm:1.57.0":
- version: 1.57.0
- resolution: "@playwright/test@npm:1.57.0"
+"@playwright/test@npm:1.58.1":
+ version: 1.58.1
+ resolution: "@playwright/test@npm:1.58.1"
dependencies:
- playwright: "npm:1.57.0"
+ playwright: "npm:1.58.1"
bin:
playwright: cli.js
- checksum: 10c0/35ba4b28be72bf0a53e33dbb11c6cff848fb9a37f49e893ce63a90675b5291ec29a1ba82c8a3b043abaead129400f0589623e9ace2e6a1c8eaa409721ecc3774
+ checksum: 10c0/ca32be812c6f86b2247109eaecd2fed452414debee05b4b0d690a3397f6bd08a56e0b2484f74d20fa0e7494508ee1cbdcbc27864acd5093e34c3f94d0e278188
languageName: node
linkType: hard
@@ -10887,27 +10887,27 @@ __metadata:
languageName: node
linkType: hard
-"playwright-core@npm:1.57.0":
- version: 1.57.0
- resolution: "playwright-core@npm:1.57.0"
+"playwright-core@npm:1.58.1":
+ version: 1.58.1
+ resolution: "playwright-core@npm:1.58.1"
bin:
playwright-core: cli.js
- checksum: 10c0/798e35d83bf48419a8c73de20bb94d68be5dde68de23f95d80a0ebe401e3b83e29e3e84aea7894d67fa6c79d2d3d40cc5bcde3e166f657ce50987aaa2421b6a9
+ checksum: 10c0/2c12755579148cbd13811cc1a01e9693432f0e4595c76ebb02d2e1b4ee7286719c6769fdb26cda61f218bc49b7ddd4de5d856abbd034acde4ff3dbeee93e4773
languageName: node
linkType: hard
-"playwright@npm:1.57.0":
- version: 1.57.0
- resolution: "playwright@npm:1.57.0"
+"playwright@npm:1.58.1":
+ version: 1.58.1
+ resolution: "playwright@npm:1.58.1"
dependencies:
fsevents: "npm:2.3.2"
- playwright-core: "npm:1.57.0"
+ playwright-core: "npm:1.58.1"
dependenciesMeta:
fsevents:
optional: true
bin:
playwright: cli.js
- checksum: 10c0/ab03c99a67b835bdea9059f516ad3b6e42c21025f9adaa161a4ef6bc7ca716dcba476d287140bb240d06126eb23f889a8933b8f5f1f1a56b80659d92d1358899
+ checksum: 10c0/29cb2b34ad80f9dc1b27d26d8cf56e0964d7787e0beb18b25fd9d087a09ce56a359779104d2a1717d08789c2f2713928ef59140b2905e6ef00b2cb6df58bb107
languageName: node
linkType: hard
@@ -13865,7 +13865,7 @@ __metadata:
dependencies:
"@arethetypeswrong/cli": "npm:^0.17.2"
"@changesets/cli": "npm:^2.27.11"
- "@playwright/test": "npm:1.57.0"
+ "@playwright/test": "npm:1.58.1"
"@types/node": "npm:^22.10.5"
dotenv: "npm:^16.4.7"
prettier: "npm:^3.4.2"