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..1b221b9 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( @@ -164,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 @@ -171,22 +218,28 @@ 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", + ); 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 +263,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 ( @@ -251,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++) { @@ -331,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, }); @@ -437,8 +510,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(); } /**