@coinbase/onchaintestkit
Version:
End-to-end testing toolkit for blockchain applications, powered by Playwright
468 lines (467 loc) • 23.1 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.PhantomWallet = exports.PhantomSpecificActionType = void 0;
const node_path_1 = __importDefault(require("node:path"));
const test_1 = require("@playwright/test");
const extensionManager_1 = require("../../utils/extensionManager");
const BaseWallet_1 = require("../BaseWallet");
const pages_1 = require("./pages");
// Extend BaseActionType with Phantom-specific actions
var PhantomSpecificActionType;
(function (PhantomSpecificActionType) {
PhantomSpecificActionType["LOCK"] = "lock";
PhantomSpecificActionType["UNLOCK"] = "unlock";
PhantomSpecificActionType["ADD_TOKEN"] = "addToken";
PhantomSpecificActionType["ADD_ACCOUNT"] = "addAccount";
PhantomSpecificActionType["SWITCH_ACCOUNT"] = "switchAccount";
PhantomSpecificActionType["SEND_TOKENS"] = "sendTokens";
PhantomSpecificActionType["SWITCH_BLOCKCHAIN"] = "switchBlockchain";
PhantomSpecificActionType["ENABLE_TEST_MODE"] = "enableTestMode";
PhantomSpecificActionType["SIGN_MESSAGE"] = "signMessage";
})(PhantomSpecificActionType || (exports.PhantomSpecificActionType = PhantomSpecificActionType = {}));
const WALLET_CONNECTION_ERROR = new Error("Phantom Wallet extension connection not established");
const PHANTOM_VERSION = "25.27.0";
class PhantomWallet extends BaseWallet_1.BaseWallet {
get browserContext() {
return this.context;
}
constructor(walletConfig, context, page, extensionId) {
super();
this.context = context;
this.extensionId = extensionId;
this.config = walletConfig;
this.page = page;
this.onboardingPage = new pages_1.OnboardingPage(page);
this.homePage = new pages_1.HomePage(page);
this.notificationPage = new pages_1.NotificationPage(page);
}
static async initialize(currentContext, contextPath, _walletConfig) {
// Create browser context with Phantom extension
const context = await PhantomWallet.createContext(contextPath);
// Handle cookie and storage transfer if currentContext exists
if (currentContext) {
const { cookies } = await currentContext.storageState();
if (cookies) {
await context.addCookies(cookies);
}
}
// Wait for extension page to load and get extension ID
const extensionId = await (0, extensionManager_1.getExtensionId)(context, "Phantom");
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
// Wait longer for extension to initialize in CI
const initDelay = isCI ? 3000 : 1000;
await new Promise(resolve => setTimeout(resolve, initDelay));
// Check if there's already a page with the extension loaded
let phantomPage = context
.pages()
.find(page => page.url().includes(`chrome-extension://${extensionId}`));
if (!phantomPage) {
phantomPage = await context.newPage();
// Wait for page to be ready
await phantomPage.waitForLoadState("domcontentloaded", { timeout: 15000 });
}
// Validate page is still open before proceeding
if (phantomPage.isClosed()) {
throw new Error("Phantom extension page was closed during initialization");
}
// Check current URL to determine the state
const currentUrl = phantomPage.url();
console.log(`[Phantom Init] Current URL: ${currentUrl}`);
// If we're on onboarding.html or about:blank, or no extension URL, try different approaches
if (currentUrl.includes("onboarding.html") ||
currentUrl === "about:blank" ||
!currentUrl.includes(extensionId)) {
// First try the onboarding page since that's what Phantom shows initially
const onboardingUrl = `chrome-extension://${extensionId}/onboarding.html`;
try {
if (!phantomPage.isClosed()) {
await phantomPage.goto(onboardingUrl, {
waitUntil: "domcontentloaded",
timeout: 15000,
});
if (!phantomPage.isClosed()) {
await phantomPage.waitForLoadState("networkidle", {
timeout: 15000,
});
}
}
}
catch (error) {
console.error("Failed to load onboarding page:", error);
// Try other URLs as fallback
const altUrls = [
`chrome-extension://${extensionId}/popup.html`,
`chrome-extension://${extensionId}/index.html`,
];
let loaded = false;
for (const altUrl of altUrls) {
try {
if (phantomPage.isClosed()) {
phantomPage = await context.newPage();
}
await phantomPage.goto(altUrl, {
waitUntil: "domcontentloaded",
timeout: 15000,
});
if (!phantomPage.isClosed()) {
await phantomPage.waitForLoadState("networkidle", {
timeout: 15000,
});
loaded = true;
break;
}
}
catch (altError) {
const errorMessage = altError instanceof Error ? altError.message : String(altError);
console.error(`Alternative URL ${altUrl} failed:`, errorMessage);
}
}
if (!loaded) {
throw new Error("Failed to load Phantom extension. Tried multiple URLs including onboarding.");
}
}
}
else {
// Already on a phantom extension page, just wait for it to be ready
try {
if (!phantomPage.isClosed()) {
await phantomPage.waitForLoadState("networkidle", { timeout: 15000 });
}
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn("Warning: Could not wait for network idle:", errorMessage);
// Continue anyway, the page might still be usable
}
}
// Final validation that page is still open
if (phantomPage.isClosed()) {
throw new Error("Phantom extension page was closed after initialization");
}
// In CI, keep a "keeper" page open so the context never has zero pages
// which can lead to context/browser shutdowns when the last page closes.
if (isCI) {
try {
const hasNonExtensionPage = context
.pages()
.some(p => !p.url().startsWith("chrome-extension://"));
if (!hasNonExtensionPage) {
await context.newPage(); // about:blank
}
}
catch (e) {
console.warn("[Phantom CI] Failed to create keeper page:", e);
}
}
// Close any other pages (skip this cleanup in CI to keep keeper page alive)
if (!isCI) {
const pages = context.pages();
for (const page of pages) {
if (page !== phantomPage && !page.isClosed()) {
try {
await page.close();
}
catch (_error) {
// Ignore errors when closing pages
}
}
}
}
console.log(`[Phantom Init] Final URL: ${phantomPage.url()}`);
return { phantomPage, phantomContext: context };
}
/**
* Navigate to the main Phantom extension popup after onboarding
*/
async navigateToMainPopup() {
try {
const popupUrl = `chrome-extension://${this.extensionId}/popup.html`;
// First, try to navigate the current page if it's still open
try {
if (!this.page.isClosed()) {
await this.page.goto(popupUrl, {
waitUntil: "domcontentloaded",
timeout: 15000,
});
if (!this.page.isClosed()) {
await this.page.waitForLoadState("networkidle", { timeout: 15000 });
return;
}
}
}
catch (_navigationError) {
console.log("Current page navigation failed.");
}
// If current page is closed or navigation failed, find or create a new page
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
if (isCI) {
// In CI, find the remaining extension page after onboarding closes the old one
const pages = this.context.pages();
const extensionPage = pages.find(p => p.url().includes(`chrome-extension://${this.extensionId}/popup.html`));
if (extensionPage && !extensionPage.isClosed()) {
this.page = extensionPage;
this.homePage = new pages_1.HomePage(extensionPage);
this.onboardingPage = new pages_1.OnboardingPage(extensionPage);
this.notificationPage = new pages_1.NotificationPage(extensionPage);
return;
}
// If no popup.html page exists, look for any extension page
const anyExtensionPage = pages.find(p => p.url().includes(`chrome-extension://${this.extensionId}`) &&
!p.isClosed());
if (anyExtensionPage) {
// Navigate this page to popup.html
await anyExtensionPage.goto(popupUrl, {
waitUntil: "domcontentloaded",
timeout: 15000,
});
await anyExtensionPage.waitForLoadState("networkidle", {
timeout: 15000,
});
this.page = anyExtensionPage;
this.homePage = new pages_1.HomePage(anyExtensionPage);
this.onboardingPage = new pages_1.OnboardingPage(anyExtensionPage);
this.notificationPage = new pages_1.NotificationPage(anyExtensionPage);
return;
}
// FINAL CI FALLBACK: open a fresh page to the popup with retries
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const newPage = await this.context.newPage();
await newPage.goto(popupUrl, {
waitUntil: "domcontentloaded",
timeout: 15000,
});
if (!newPage.isClosed()) {
await newPage.waitForLoadState("networkidle", { timeout: 15000 });
}
this.page = newPage;
this.homePage = new pages_1.HomePage(newPage);
this.onboardingPage = new pages_1.OnboardingPage(newPage);
this.notificationPage = new pages_1.NotificationPage(newPage);
return;
}
catch (err) {
console.warn(`[Phantom CI] Popup open attempt ${attempt} failed:`, err);
await new Promise(r => setTimeout(r, 1000 * attempt));
}
}
}
// Only in local/dev: create a new page
const newPage = await this.context.newPage();
await newPage.goto(popupUrl, {
waitUntil: "domcontentloaded",
timeout: 15000,
});
if (newPage.isClosed()) {
console.log("Phantom main popup closed after newPage.goto, skipping waitForLoadState.");
return;
}
await newPage.waitForLoadState("networkidle", { timeout: 15000 });
this.page = newPage;
this.homePage = new pages_1.HomePage(newPage);
this.onboardingPage = new pages_1.OnboardingPage(newPage);
this.notificationPage = new pages_1.NotificationPage(newPage);
return;
}
catch (error) {
console.error("Failed to navigate to main popup, but continuing in CI:", error);
// Do not throw in CI, just log and continue
if (!(process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true")) {
throw new Error(`Could not navigate to Phantom main popup: ${error}`);
}
}
}
static async createContext(contextPath, slowMo = 0) {
// Use retry logic to handle potential race conditions when setting up extension
const MAX_RETRIES = 3;
let lastError = null;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
// Check if running in CI environment
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
// Get phantom extension path (assumes prepare-phantom.mjs was run first)
const cacheDir = node_path_1.default.join(process.cwd(), "e2e", ".cache", "phantom-extension");
const phantomPath = node_path_1.default.join(cacheDir, `phantom-${PHANTOM_VERSION}`);
// Verify extension files exist
const fs = await Promise.resolve().then(() => __importStar(require("fs")));
if (!fs.existsSync(phantomPath)) {
throw new Error(`Phantom extension not found at ${phantomPath}. Run 'node src/cli/prepare-phantom.mjs' first.`);
}
// Log extension path for debugging
if (isCI) {
console.log(`[Phantom CI] Extension path: ${phantomPath}`);
console.log(`[Phantom CI] Extension exists: ${fs.existsSync(phantomPath)}`);
}
const browserArgs = [
`--disable-extensions-except=${phantomPath}`,
"--enable-extensions",
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-accelerated-2d-canvas",
"--no-first-run",
"--no-zygote",
"--disable-gpu",
"--force-gpu-mem-available-mb=1024",
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-renderer-backgrounding",
"--disable-features=IsolateOrigins,site-per-process", // Allow cross-origin iframes
"--disable-web-security", // Help with extension loading
"--disable-features=VizDisplayCompositor", // Reduce resource usage
];
// Add CI-specific arguments
if (isCI) {
browserArgs.push("--disable-backgrounding-occluded-windows", "--disable-ipc-flooding-protection", "--disable-hang-monitor", "--disable-prompt-on-repost", "--disable-domain-reliability", "--disable-background-networking", "--metrics-recording-only", "--no-default-browser-check", "--disable-sync", "--disable-translate", "--disable-features=TranslateUI,BlinkGenPropertyTrees", "--remote-debugging-port=0");
}
const context = await test_1.chromium.launchPersistentContext(contextPath, {
headless: false,
args: browserArgs,
slowMo,
viewport: { width: 1280, height: 800 },
ignoreHTTPSErrors: true,
acceptDownloads: true,
// Add timeout to help with context creation
timeout: 30000,
});
// Wait for extension to be ready with longer delay
await new Promise(resolve => setTimeout(resolve, 3000));
// Verify context is still open
if (context.pages().length === 0) {
throw new Error("Browser context closed immediately after creation");
}
return context;
}
catch (error) {
// If we failed at the setup stage
if (attempt === MAX_RETRIES - 1) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to setup Phantom: ${errorMessage}`);
}
// Save error for potential retry
lastError = error instanceof Error ? error : new Error(String(error));
console.error(`Attempt ${attempt + 1} failed:`, lastError.message);
// Short delay before retry with increasing backoff
const delay = (attempt + 1) * 2000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
// If we reach here, all attempts failed
throw lastError ?? new Error("Failed to create context after all retries");
}
async handleAction(action, options) {
const { approvalType, ...additionalOptions } = options ?? {};
if (!this.extensionId) {
throw WALLET_CONNECTION_ERROR;
}
switch (action) {
// Basic setup actions
case BaseWallet_1.BaseActionType.IMPORT_WALLET_FROM_SEED:
await this.onboardingPage.importWallet(additionalOptions.seedPhrase, additionalOptions.password, additionalOptions.username);
// After import, navigate to main extension popup
await this.navigateToMainPopup();
break;
case BaseWallet_1.BaseActionType.IMPORT_WALLET_FROM_PRIVATE_KEY:
await this.onboardingPage.importPrivateKey(additionalOptions.privateKey, this.config.password, additionalOptions.chain || "base", additionalOptions.name);
await this.navigateToMainPopup();
break;
// Account actions
case PhantomSpecificActionType.ADD_ACCOUNT:
await this.homePage.addNewAccount(additionalOptions.accountName, additionalOptions.privateKey, additionalOptions.chain);
break;
case PhantomSpecificActionType.SWITCH_ACCOUNT:
await this.homePage.switchAccount(additionalOptions.accountName);
break;
// Dapp actions
case BaseWallet_1.BaseActionType.CONNECT_TO_DAPP:
await this.notificationPage.connectToDapp(this.extensionId);
break;
// Transaction actions
case BaseWallet_1.BaseActionType.HANDLE_TRANSACTION:
if (approvalType === BaseWallet_1.ActionApprovalType.APPROVE) {
await this.notificationPage.confirmTransaction(this.extensionId);
}
else {
await this.notificationPage.rejectTransaction(this.extensionId);
}
break;
// Token permission actions
case BaseWallet_1.BaseActionType.CHANGE_SPENDING_CAP:
if (approvalType === BaseWallet_1.ActionApprovalType.APPROVE) {
await this.notificationPage.approveTokenPermission(this.extensionId);
}
else {
await this.notificationPage.rejectTokenPermission(this.extensionId);
}
break;
case BaseWallet_1.BaseActionType.REMOVE_SPENDING_CAP:
if (approvalType === BaseWallet_1.ActionApprovalType.APPROVE) {
await this.notificationPage.confirmSpendingCapRemoval(this.extensionId);
}
else {
await this.notificationPage.rejectSpendingCapRemoval(this.extensionId);
}
break;
case PhantomSpecificActionType.ENABLE_TEST_MODE:
await this.homePage.enableTestMode();
break;
case PhantomSpecificActionType.SIGN_MESSAGE:
await this.notificationPage.signMessage(this.extensionId);
break;
default:
throw new Error(`Unsupported action: ${action}`);
}
}
// Public getters for integration
get walletContext() {
return this.context;
}
get walletPage() {
return this.page;
}
get walletExtensionId() {
return this.extensionId;
}
}
exports.PhantomWallet = PhantomWallet;
__exportStar(require("./fixtures"), exports);