Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/w3wallets/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
100 changes: 87 additions & 13 deletions packages/w3wallets/src/wallets/metamask/metamask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -164,29 +186,60 @@ 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
* states in a single wait to avoid sequential timeout penalties.
*/
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),
Expand All @@ -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 (
Expand Down Expand Up @@ -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++) {
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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();
}

/**
Expand Down
Loading