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"