UNPKG

@coinbase/onchaintestkit

Version:

End-to-end testing toolkit for blockchain applications, powered by Playwright

206 lines (205 loc) 10.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.NotificationPage = exports.NotificationPageType = void 0; const utils_1 = require("../../../../utils"); const actions_1 = require("./actions"); const spendingCap_1 = require("./actions/spendingCap"); var NotificationPageType; (function (NotificationPageType) { NotificationPageType["SpendingCap"] = "spending-cap"; NotificationPageType["Signature"] = "signature"; NotificationPageType["Transaction"] = "transaction"; NotificationPageType["RemoveSpendCap"] = "remove-spend-cap"; })(NotificationPageType || (exports.NotificationPageType = NotificationPageType = {})); // Constants for MetaMask notification page const NOTIFICATION_PAGE_PATH = "notification.html"; const DEFAULT_VIEWPORT = { width: 360, height: 580 }; // Max time to wait for a notification popup before giving up. // waitForPage can hang forever due to a race condition: the popup opens // between the context.pages() check and the waitForEvent('page') listener, // so the event is never fired. This timeout prevents burning the entire // 300s test timeout on a single waitForPage call. const WAIT_FOR_PAGE_TIMEOUT_MS = 15000; class NotificationPage { constructor(page) { // Cached notification page from identifyNotificationType so the // immediately-following action handler can reuse it instead of doing // a second waitForPage lookup (which risks grabbing a stale page on CI). this.cachedNotificationPage = null; this.page = page; } /** * Helper method to get notification page URL */ getNotificationUrl(extensionId) { return `chrome-extension://${extensionId}/${NOTIFICATION_PAGE_PATH}`; } /** * Helper method to wait for notification page. * Reuses the cached page from identifyNotificationType if still alive. * * Wraps waitForPage with a timeout so a hung lookup doesn't burn the * entire 300s test timeout. waitForPage itself handles the TOCTOU race * (event listener + polling), and action handlers now wait for page * close so stale pages are no longer in context.pages(). */ async getNotificationPage(extensionId) { if (this.cachedNotificationPage && !this.cachedNotificationPage.isClosed()) { try { await this.cachedNotificationPage.evaluate(() => document.readyState); const cached = this.cachedNotificationPage; this.cachedNotificationPage = null; return cached; } catch { this.cachedNotificationPage = null; } } const targetUrl = this.getNotificationUrl(extensionId); const context = this.page.context(); // waitForPage with a safety timeout. If waitForPage errors (e.g. // "Target page closed"), swallow it and let the timeout reject instead. const page = await Promise.race([ (0, utils_1.waitForPage)(context, targetUrl, DEFAULT_VIEWPORT).catch(() => new Promise(() => { })), new Promise((_, reject) => setTimeout(() => reject(new Error(`Notification page not found after ${WAIT_FOR_PAGE_TIMEOUT_MS}ms. ` + `The MetaMask popup may not have appeared. URL: ${targetUrl}`)), WAIT_FOR_PAGE_TIMEOUT_MS)), ]); await page.waitForLoadState("domcontentloaded").catch(() => { }); await page.setViewportSize(DEFAULT_VIEWPORT).catch(() => { }); return page; } async connectToDapp(extensionId) { const notificationPage = await this.getNotificationPage(extensionId); await (0, actions_1.connectToDapp)(notificationPage); } async approveNewNetwork(extensionId) { const notificationPage = await this.getNotificationPage(extensionId); await actions_1.network.approveNewNetwork(notificationPage); } async rejectNewNetwork(extensionId) { const notificationPage = await this.getNotificationPage(extensionId); await actions_1.network.rejectNewNetwork(notificationPage); } async approveSwitchNetwork(extensionId) { const notificationPage = await this.getNotificationPage(extensionId); await actions_1.network.approveSwitchNetwork(notificationPage); } async rejectSwitchNetwork(extensionId) { const notificationPage = await this.getNotificationPage(extensionId); await actions_1.network.rejectSwitchNetwork(notificationPage); } async approveAddNetwork(extensionId) { const notificationPage = await this.getNotificationPage(extensionId); await actions_1.network.approveNewNetwork(notificationPage); } async rejectAddNetwork(extensionId) { const notificationPage = await this.getNotificationPage(extensionId); await actions_1.network.rejectNewNetwork(notificationPage); } /** * Run an action on the notification page with a single retry. * If the first attempt fails with "Target page, context or browser has been * closed", waits 1 second for the stale page to detach, then looks for a * fresh notification page directly in context.pages(). * * The retry does NOT use waitForPage because its waitForEvent('page') * fallback hangs forever — MetaMask reuses popups rather than opening * new page events. */ async withRetry(extensionId, action) { const notificationPage = await this.getNotificationPage(extensionId); try { await action(notificationPage); } catch (error) { const msg = error instanceof Error ? error.message : String(error); if (!msg.includes("Target page, context or browser has been closed")) { throw error; } console.log("Notification page was stale, retrying with fresh page..."); await new Promise(r => setTimeout(r, 1000)); const targetUrl = this.getNotificationUrl(extensionId); const freshPage = this.page .context() .pages() .filter(p => !p.isClosed() && p.url().includes(targetUrl)) .pop(); if (!freshPage) { throw new Error(`Notification page was stale and no fresh page found after retry. Original error: ${msg}`); } await freshPage.waitForLoadState("domcontentloaded").catch(() => { }); await freshPage.setViewportSize(DEFAULT_VIEWPORT).catch(() => { }); await action(freshPage); } } async confirmTransaction(extensionId) { await this.withRetry(extensionId, p => actions_1.transaction.confirm(p)); } async rejectTransaction(extensionId) { await this.withRetry(extensionId, p => actions_1.transaction.reject(p)); } async approveTokenPermission(extensionId) { await this.withRetry(extensionId, p => actions_1.approvePermission.approve(p)); } async rejectTokenPermission(extensionId) { await this.withRetry(extensionId, p => actions_1.approvePermission.reject(p)); } async confirmSpendingCapRemoval(extensionId) { await this.withRetry(extensionId, p => spendingCap_1.spendingCapRemoval.confirm(p)); } async rejectSpendingCapRemoval(extensionId) { await this.withRetry(extensionId, p => spendingCap_1.spendingCapRemoval.reject(p)); } async addNewToken(extensionId) { const notificationPage = await this.getNotificationPage(extensionId); await actions_1.token.addNew(notificationPage); } async identifyNotificationType(extensionId, globalTimeout = 15000, pollInterval = 500) { // Get the notification page via a fresh lookup (bypass cache) // so we always get the latest notification popup, then cache it for the // action handler that runs immediately after this returns. // Uses getNotificationPage which has a built-in timeout to avoid // hanging on the waitForPage race condition. this.cachedNotificationPage = null; // clear cache to force fresh lookup const notificationPage = await this.getNotificationPage(extensionId); this.cachedNotificationPage = notificationPage; // Give the page an extra moment to fully render and stabilize. // Use a page-independent timeout — notificationPage.waitForTimeout() // throws if the page has already closed. await new Promise(r => setTimeout(r, 500)); const checks = [ { type: NotificationPageType.SpendingCap, text: "Spending cap request" }, { type: NotificationPageType.Signature, text: "Signature request" }, { type: NotificationPageType.Transaction, text: "Network fee" }, { type: NotificationPageType.RemoveSpendCap, text: "Remove Permission" }, ]; // Poll synchronously until a notification type is found or we time out. // The previous approach launched 4 concurrent fire-and-forget isVisible() // checks, but isVisible() is an instant check (not a poller) — if the page // hadn't rendered yet, all 4 returned false immediately and the 15s timeout // was the only thing left. This polling loop retries every pollInterval ms. const deadline = Date.now() + globalTimeout; while (Date.now() < deadline) { if (notificationPage.isClosed()) { throw new Error("Notification page closed before type could be identified"); } for (const { type, text } of checks) { try { const selector = notificationPage.getByText(text, { exact: false }); const isVisible = await selector.isVisible(); if (isVisible) { return type; } } catch { // Page might have closed mid-check — we'll catch it at the top of the loop } } // Short pause before the next poll to avoid busy-waiting await new Promise(r => setTimeout(r, pollInterval)); } throw new Error("Timeout waiting for notification type"); } } exports.NotificationPage = NotificationPage;