Skip to content
Open
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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,7 @@ browser_extensions/
*.coverage.info

# Secrets
.env
.env

# Playwright
/browser-profiles/
2 changes: 2 additions & 0 deletions catalyst_voices/apps/voices/e2e_tests/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ node_modules/
/playwright-report/
/blob-report/
/playwright/.cache/
/browser/
/browser-profiles/
56 changes: 56 additions & 0 deletions catalyst_voices/apps/voices/e2e_tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Catalyst Voices E2E Tests

End-to-end tests for the Catalyst Voices web application using Playwright.
Unlike regular Playwright setups, this project will connect to already existing browser binary via CDP
and the app will run locally.

## Scope

These tests cover browser-based end-to-end workflows for the Catalyst Voices application:

* **Cardano wallet integration testing** (Lace, Eternl, Yoroi, Nufi)
* **User authentication and account management**
* **Cross-environment testing** (dev, staging, prod)
* **Browser extension interaction**
* **Application title and basic navigation**

## Building & Setup

### Prerequisites

* **Node.js** (v18 or higher)
* **npm** package manager
* **Chrome for testing** ([Download manually](https://googlechromelabs.github.io/chrome-for-testing/))
* Ability to run the app locally (Check `catalyst_voices/README.md` for instructions)

### Installation

1. Run the app locally:

This ensures the app will be running on port 5555.

```bash
cd catalyst_voices/apps/voices &&
flutter run --flavor preprod --web-port 5555
--web-header "Cross-Origin-Opener-Policy=same-origin"
--web-header "Cross-Origin-Embedder-Policy=require-corp"
-d web-server lib/configs/main.dart
```

2. In new terminal, navigate to the e2e tests directory:

```bash
cd catalyst_voices/apps/voices/e2e_tests
```

3. Install dependencies:

```bash
npm install
```

4. Run the e2e tests:

```bash
npx playwright test
```
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,17 @@ export const browserExtensions: BrowserExtensionModel[] = [
{
Name: BrowserExtensionName.Lace,
Id: "gafhhkghbfjjkeiendhlofajokpaflmk",
HomeUrl: "app.html#/setup",
HomeUrl: "/app.html#/setup",
},
{
Name: BrowserExtensionName.Eternl,
Id: "kmhcihpebfmpgmihbkipmjlmmioameka",
HomeUrl: "index.html#/app/preprod/welcome",
HomeUrl: "/index.html#/app/preprod/welcome",
},
{
Name: BrowserExtensionName.Yoroi,
Id: "poonlenmfdfbjfeeballhiibknlknepo",
HomeUrl: "main_window.html#",
HomeUrl: "/main_window.html#",
},
{
Name: BrowserExtensionName.Nufi,
Expand Down
5 changes: 5 additions & 0 deletions catalyst_voices/apps/voices/e2e_tests/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { mergeTests } from "@playwright/test";
import { test as walletTest } from "./fixtures/wallet-fixtures";
import { test as endpointTest } from "./fixtures/endpoint-fixtures";

export const test = mergeTests(walletTest, endpointTest);
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { test as base } from "@playwright/test";
import dotenv from "dotenv";

dotenv.config();

type EndpointFixtures = {
appBaseURL: string;
};

export const test = base.extend<EndpointFixtures>({
appBaseURL: async ({}, use) => {
const baseURL =
process.env.APP_URL || "localhost:5555";
await use(baseURL);
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import path from "path";
import dotenv from "dotenv";
dotenv.config({
path: path.join(process.cwd(), "catalyst_voices/apps/voices/e2e_tests/.env"),
});

import { test as base, BrowserContext } from "@playwright/test";

import {
connectToBrowser,
getDynamicUrlInChrome,
} from "../utils/browser-extension";
import { WalletConfigModel } from "../models/walletConfigModel";
import { onboardWallet } from "../utils/wallets/walletActions";

type WalletFixtures = {
restoreWallet: (walletConfig: WalletConfigModel) => Promise<BrowserContext>;
};

export const test = base.extend<WalletFixtures>({
restoreWallet: async ({}, use) => {
const restoreWalletFn = async (walletConfig: WalletConfigModel) => {
const chromePath = process.env.CHROME_PATH;
if (!chromePath) {
throw new Error("CHROME_PATH is not set");
}
const browser = await connectToBrowser(
walletConfig.extension.Name,
chromePath
);
const extensionTab = browser.pages()[0];
walletConfig.extension.HomeUrl = await getDynamicUrlInChrome(
extensionTab,
walletConfig
);
await extensionTab.goto(walletConfig.extension.HomeUrl);
const address = await onboardWallet(extensionTab, walletConfig);
return browser;
};

// Provide the function to the test
await use(restoreWalletFn);
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Locator, Page } from "@playwright/test";

export class DiscoveryPage {
page: Page;
getStartedButton: Locator;

constructor(page: Page) {
this.page = page;
this.getStartedButton = page.getByTestId("GetStartedButton");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ export default defineConfig({
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
use: {
...devices["Desktop Chrome"],
testIdAttribute: "flt-semantics-identifier",
},
},
],
});
8 changes: 0 additions & 8 deletions catalyst_voices/apps/voices/e2e_tests/tests/example.spec.ts

This file was deleted.

14 changes: 14 additions & 0 deletions catalyst_voices/apps/voices/e2e_tests/tests/onboarding.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { test } from "../fixtures";
import { expect } from "@playwright/test";
import { getWalletConfig } from "../data/walletConfigs";

test.describe("Onboarding - ", () => {
test("creates keychain", async ({ restoreWallet, appBaseURL }) => {
const browser = await restoreWallet(getWalletConfig("1"));
const page = browser.pages()[0];
await page.goto(appBaseURL);
await page
.locator("//*[@aria-label='Enable accessibility']")
.evaluate((element: HTMLElement) => element.click());
});
});
163 changes: 163 additions & 0 deletions catalyst_voices/apps/voices/e2e_tests/utils/browser-extension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { Page, BrowserContext, chromium } from "@playwright/test";
import { spawn, ChildProcess } from "child_process";
import fs from "fs";
import path from "path";
import { WalletConfigModel } from "../models/walletConfigModel";
import { getBrowserExtension } from "../data/browserExtensionConfigs";
import { BrowserExtensionName } from "../models/browserExtensionModel";
import { ExtensionDownloader } from "./extensionDownloader";

export const getDynamicUrlInChrome = async (
extensionTab: Page,
wallet: WalletConfigModel
): Promise<string> => {
await extensionTab.goto("chrome://extensions/");
const extensionId = await extensionTab
.locator("extensions-item")
.getAttribute("id");
return `chrome-extension://${extensionId}${
getBrowserExtension(wallet.extension.Name).HomeUrl
}`;
};

export const connectToBrowser = async (
extensionName: BrowserExtensionName,
chromePath: string
): Promise<BrowserContext> => {
// Download the extension
const extensionPath = await new ExtensionDownloader().getExtension(
extensionName
);

const chromeProcess = await launchChromeWithExtension(
chromePath,
extensionPath
);

await new Promise((resolve) => setTimeout(resolve, 3000));

const browser = await chromium.connectOverCDP("http://localhost:9222");
const browserContext = browser.contexts()[0];

// Store the process reference for cleanup
(browserContext as any).chromeProcess = chromeProcess;

return browserContext;
};

const cleanupChromeProfile = async (profilePath: string): Promise<void> => {
if (fs.existsSync(profilePath)) {
try {
await fs.promises.rm(profilePath, { recursive: true, force: true });
console.log(`Cleaned up Chrome profile: ${profilePath}`);
} catch (error) {
console.error(`Failed to cleanup Chrome profile: ${error}`);
}
}
};

const launchChromeWithExtension = async (
chromePath: string,
extensionPath: string
): Promise<ChildProcess> => {
// Create a unique profile directory for this test run in the browser-profiles folder
const profilesDir = path.join(process.cwd(), "browser-profiles");

// Ensure the browser-profiles directory exists
if (!fs.existsSync(profilesDir)) {
fs.mkdirSync(profilesDir, { recursive: true });
}

const profilePath = path.join(
profilesDir,
`chrome-profile-${Date.now()}-${Math.random().toString(36).substring(7)}`
);

// Clean up any existing profile at this path (shouldn't exist, but just in case)
await cleanupChromeProfile(profilePath);

const chromeArgs = [
"--remote-debugging-port=9222",
"--no-first-run",
"--no-default-browser-check",
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
`--user-data-dir=${profilePath}`,
];

const chromeProcess = spawn(chromePath, chromeArgs, {
detached: false,
stdio: "pipe",
});

(chromeProcess as any).profilePath = profilePath;

chromeProcess.stdout?.on("data", (data) => {
console.log(`Chrome stdout: ${data}`);
});

chromeProcess.stderr?.on("data", (data) => {
console.log(`Chrome stderr: ${data}`);
});

chromeProcess.on("error", (error) => {
console.error(`Chrome process error: ${error}`);
});

return chromeProcess;
};

export const closeBrowserWithExtension = async (
browserContext: BrowserContext
) => {
const chromeProcess = (browserContext as any).chromeProcess as ChildProcess;
const profilePath = chromeProcess ? (chromeProcess as any).profilePath : null;

if (chromeProcess && !chromeProcess.killed) {
chromeProcess.kill("SIGTERM");

// Wait for the process to terminate gracefully
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
if (!chromeProcess.killed) {
chromeProcess.kill("SIGKILL");
}
resolve();
}, 5000);

chromeProcess.on("exit", () => {
clearTimeout(timeout);
resolve();
});
});
}

await browserContext.close();

// Clean up the Chrome profile directory
if (profilePath) {
await cleanupChromeProfile(profilePath);
}
};

export const launchBrowserWith = async (
extensionName: BrowserExtensionName
): Promise<BrowserContext> => {
const extensionPath = await new ExtensionDownloader().getExtension(
extensionName
);
const browser = await chromium.launchPersistentContext("", {
viewport: { width: 1920, height: 1080 },
headless: false,
ignoreHTTPSErrors: true,
channel: "chrome",
permissions: ["clipboard-read", "clipboard-write"],
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
],
});
let [background] = browser.serviceWorkers();
if (!background) background = await browser.waitForEvent("serviceworker");
return browser;
};
Loading