From a5ceaf95591b544e94b66e9a0e7ae0a817bf7dd7 Mon Sep 17 00:00:00 2001 From: Max Andreev Date: Wed, 27 May 2026 19:45:15 +0500 Subject: [PATCH 1/2] Fix MetaMask selectors for 13.32 onboarding and unlock flows MetaMask 13.32 inserted a new passkey/biometrics screen between "Create password" and the metametrics step, renamed the menu's lock entry, and changed the "Open wallet" button's role match. Race the passkey screen during onboarding and post-unlock stabilization, and switch to stable testids for "Open wallet" and the Lock menu item. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/w3wallets/package.json | 2 +- .../src/wallets/metamask/metamask.ts | 71 +++++++++++++++---- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/packages/w3wallets/package.json b/packages/w3wallets/package.json index 82fcf4b..43da0fc 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.10", + "version": "1.0.0-beta.11", "main": "dist/index.js", "types": "dist/index.d.ts", "homepage": "https://github.com/Maksandre/w3wallets", diff --git a/packages/w3wallets/src/wallets/metamask/metamask.ts b/packages/w3wallets/src/wallets/metamask/metamask.ts index 8bb7455..65e038d 100644 --- a/packages/w3wallets/src/wallets/metamask/metamask.ts +++ b/packages/w3wallets/src/wallets/metamask/metamask.ts @@ -89,17 +89,39 @@ export class Metamask extends Wallet { // Step 7: Click "Create password" await this.page.getByRole("button", { name: "Create password" }).click(); - // Step 8: Handle "Help improve MetaMask" screen + // Step 8: Decline passkey/biometrics setup if shown (MetaMask 13.32+ + // inserts an "Unlock with Biometrics" screen between password + // creation and the metametrics consent step). Race against the + // metametrics screen so we work on older builds too. + const passkeyMaybeLater = this.page.getByTestId( + "passkey-maybe-later-button", + ); const metametricsBtn = this.page.getByTestId("metametrics-i-agree"); - await metametricsBtn.click(); - // Step 9: Handle "Your wallet is ready!" screen - const openWalletBtn = this.page.getByRole("button", { - name: /open wallet/i, + const postPasswordState = await Promise.race([ + passkeyMaybeLater + .waitFor({ state: "visible", timeout: ONBOARD_VISIBLE_TIMEOUT }) + .then(() => "passkey" as const), + metametricsBtn + .waitFor({ state: "visible", timeout: ONBOARD_VISIBLE_TIMEOUT }) + .then(() => "metametrics" as const), + ]); + if (postPasswordState === "passkey") { + await passkeyMaybeLater.click(); + } + + // Step 9: Handle "Help improve MetaMask" screen + await metametricsBtn.waitFor({ + state: "visible", + timeout: ONBOARD_VISIBLE_TIMEOUT, }); + await metametricsBtn.click(); + + // Step 10: Handle "Your wallet is ready!" screen + const openWalletBtn = this.page.getByTestId("onboarding-complete-done"); await openWalletBtn.click(); - // Step 10: Navigate to home page to trigger full UI initialization + // Step 11: Navigate to home page to trigger full UI initialization // (token list fetches, network state, etc.), then to sidepanel. await this.page.goto(`chrome-extension://${this.extensionId}/home.html`); await this.page.goto( @@ -171,22 +193,27 @@ export class Metamask extends Wallet { */ private async stabilizePostUnlock() { debug("metamask.stabilizePostUnlock: racing post-unlock states"); + const passkeyMaybeLater = this.page.getByTestId( + "passkey-maybe-later-button", + ); const metametricsBtn = this.page.getByTestId("metametrics-i-agree"); - const openWalletBtn = this.page.getByRole("button", { - name: /open wallet/i, - }); + const openWalletBtn = this.page.getByTestId("onboarding-complete-done"); const readyIndicator = this.page.getByTestId("account-options-menu-button"); // Race: whichever post-unlock state appears first wins. // Includes confirmation-cancel-button to catch queued notifications // (Solana/Tron account removal) that ConfirmationHandler auto-routes to. // When there's only 1 notification, "Reject all" isn't rendered. + // MetaMask 13.32+ also re-shows the passkey setup screen after unlock. const rejectAllBtn = this.page.getByText("Reject all"); const notificationCancelBtn = this.page.getByTestId( "confirmation-cancel-button", ); const state = await Promise.race([ + passkeyMaybeLater + .waitFor({ state: "visible", timeout: POST_UNLOCK_TIMEOUT }) + .then(() => "passkey" as const), metametricsBtn .waitFor({ state: "visible", timeout: POST_UNLOCK_TIMEOUT }) .then(() => "metametrics" as const), @@ -210,10 +237,29 @@ export class Metamask extends Wallet { debug( `metamask.stabilizePostUnlock: timeout after ${POST_UNLOCK_TIMEOUT}ms. ` + `URL: ${this.page.url()}. ` + - `Checked: metametrics-i-agree, open-wallet button, Reject all, confirmation-cancel-button, account-options-menu-button`, + `Checked: passkey-maybe-later-button, metametrics-i-agree, onboarding-complete-done, Reject all, confirmation-cancel-button, account-options-menu-button`, ); } + // Onboarding screens may appear in sequence: passkey → metametrics + // → completion. Step through any that are present. + if (state === "passkey") { + await passkeyMaybeLater.click(); + if ( + await metametricsBtn + .isVisible({ timeout: POST_UNLOCK_TIMEOUT }) + .catch(() => false) + ) { + await metametricsBtn.click(); + } + if ( + await openWalletBtn + .isVisible({ timeout: POPUP_HIDDEN_TIMEOUT }) + .catch(() => false) + ) { + await openWalletBtn.click(); + } + } if (state === "metametrics") { await metametricsBtn.click(); if ( @@ -437,8 +483,9 @@ export class Metamask extends Wallet { // force: true bypasses the notification badge that can overlay this button await menuBtn.click({ force: true }); - // Click "Log out" menu item (formerly "Lock MetaMask") - await this.page.locator("text=Log out").click(); + // Click the Lock menu item (testid stable across the + // "Lock MetaMask" → "Log out" → "Lock" renames). + await this.page.getByTestId("global-menu-lock").click(); } /** From fdd1afe1a72807a05193d5842c67158390c1a912 Mon Sep 17 00:00:00 2001 From: Max Andreev Date: Fri, 29 May 2026 15:44:39 +0500 Subject: [PATCH 2/2] Recover from MetaMask MV3 service worker startup error CI runs against a freshly-downloaded MetaMask extension, so the MV3 service worker often hasn't finished booting when the first unlock triggers UI evaluation. The page renders MetaMask's "Background connection unresponsive" recovery screen, which blocks home.html and caused `Can perform multiple operations in sequence` to time out on `account-options-menu-button`. Detect that recovery screen and click "Restart MetaMask" before racing post-unlock states. Also bound `page.goto(sidepanelUrl)` to ROUTE_RETRY_TIMEOUT so the retry loop can actually retry instead of sitting on the Playwright default 30s/60s for a single nav. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/wallets/metamask/metamask.ts | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/w3wallets/src/wallets/metamask/metamask.ts b/packages/w3wallets/src/wallets/metamask/metamask.ts index 65e038d..1b221b9 100644 --- a/packages/w3wallets/src/wallets/metamask/metamask.ts +++ b/packages/w3wallets/src/wallets/metamask/metamask.ts @@ -186,6 +186,31 @@ export class Metamask extends Wallet { } } + /** + * MetaMask's MV3 service worker can fail to come up on cold-start + * (especially in fresh-extension CI runs), showing an error screen + * with a "Restart MetaMask" button. Detect and click it so the + * normal post-unlock flow can proceed. + */ + private async recoverFromStartupError() { + const restartBtn = this.page.getByRole("button", { + name: "Restart MetaMask", + }); + if ( + !(await restartBtn + .isVisible({ timeout: POPUP_VISIBILITY_TIMEOUT }) + .catch(() => false)) + ) { + return; + } + debug("metamask.recoverFromStartupError: clicking Restart MetaMask"); + await restartBtn.click(); + await restartBtn.waitFor({ + state: "hidden", + timeout: POST_UNLOCK_TIMEOUT, + }); + } + /** * After unlock, MetaMask may show onboarding screens, queued * notifications, or go straight to the wallet UI. Race all possible @@ -193,6 +218,7 @@ export class Metamask extends Wallet { */ private async stabilizePostUnlock() { debug("metamask.stabilizePostUnlock: racing post-unlock states"); + await this.recoverFromStartupError(); const passkeyMaybeLater = this.page.getByTestId( "passkey-maybe-later-button", ); @@ -297,6 +323,7 @@ export class Metamask extends Wallet { // Navigate to home to trigger ConfirmationHandler evaluation. await this.page.goto(homeUrl); + await this.recoverFromStartupError(); // Loop: dismiss notifications one at a time until the wallet UI appears. for (let i = 0; i < 10; i++) { @@ -377,8 +404,8 @@ export class Metamask extends Wallet { // worker may not have synced the pending approval on the first load. let routeFound = false; for (let attempt = 0; attempt < MAX_ROUTE_ATTEMPTS; attempt++) { - await this.page.goto(sidepanelUrl); try { + await this.page.goto(sidepanelUrl, { timeout: ROUTE_RETRY_TIMEOUT }); await this.page.waitForURL(confirmRoutePattern, { timeout: ROUTE_RETRY_TIMEOUT, });