Add Playwright e2e test suite

This commit is contained in:
2026-03-06 21:08:20 -05:00
parent 8b1fff4130
commit 7dd403e814
8 changed files with 304 additions and 0 deletions

View File

@@ -37,6 +37,29 @@ bun run build
bun run start
```
## Browser E2E tests
Install Playwright's Chromium browser once:
```bash
bun run test:e2e:install
```
Run the suite:
```bash
bun run test:e2e
```
Useful variants:
```bash
bun run test:e2e:headed
bun run test:e2e:ui
```
The Playwright web server boot path uses an isolated SQLite database at `data/e2e.sqlite`, forces local Better Auth origins for the test port, and stores artifacts under `output/playwright/`.
## Docker deployment
```bash

View File

@@ -26,6 +26,7 @@
"zhipu-ai-provider": "^0.2.2",
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/node": "^25.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -363,6 +364,8 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
"@react-router/node": ["@react-router/node@7.13.0", "", { "dependencies": { "@mjackson/node-fetch-server": "^0.2.0" }, "peerDependencies": { "react-router": "7.13.0", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-Mhr3fAou19oc/S93tKMIBHwCPfqLpWyWM/m0NWd3pJh/wZin8/9KhAdjwxhYbXw1TrTBZBLDENa35uZ+Y7oh3A=="],
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" } }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
@@ -955,6 +958,8 @@
"fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
@@ -1253,6 +1258,10 @@
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],

38
e2e/auth.spec.ts Normal file
View File

@@ -0,0 +1,38 @@
import { expect, test } from '@playwright/test';
const PASSWORD = 'Sup3rSecure!123';
test('redirects protected routes to sign in and preserves the return path', async ({ page }) => {
await page.goto('/analysis?ticker=nvda');
await expect(page).toHaveURL(/\/auth\/signin\?/);
await expect(page.getByRole('heading', { name: 'Secure Sign In' })).toBeVisible();
expect(new URL(page.url()).searchParams.get('next')).toBe('/analysis?ticker=nvda');
});
test('shows client-side validation when signup passwords do not match', async ({ page }) => {
await page.goto('/auth/signup');
await page.locator('input[autocomplete="name"]').fill('Playwright User');
await page.locator('input[autocomplete="email"]').fill('mismatch@example.com');
await page.locator('input[autocomplete="new-password"]').first().fill(PASSWORD);
await page.locator('input[autocomplete="new-password"]').nth(1).fill('NotTheSame123!');
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByText('Passwords do not match.')).toBeVisible();
});
test('creates a new account and lands on the command center', async ({ page }) => {
const email = `playwright-${Date.now()}@example.com`;
await page.goto('/auth/signup');
await page.locator('input[autocomplete="name"]').fill('Playwright User');
await page.locator('input[autocomplete="email"]').fill(email);
await page.locator('input[autocomplete="new-password"]').first().fill(PASSWORD);
await page.locator('input[autocomplete="new-password"]').nth(1).fill(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page).toHaveURL(/\/$/);
await expect(page.getByRole('heading', { name: 'Command Center' })).toBeVisible();
await expect(page.getByText('Quick Links')).toBeVisible();
});

View File

@@ -8,12 +8,18 @@
"build": "bun --bun next build --turbopack",
"start": "bun --bun next start",
"lint": "bun --bun tsc --noEmit",
"e2e:prepare": "bun run scripts/e2e-prepare.ts",
"e2e:webserver": "bun run scripts/e2e-webserver.ts",
"workflow:setup": "workflow-postgres-setup",
"backfill:filing-metrics": "bun run scripts/backfill-filing-metrics.ts",
"backfill:filing-statements": "bun run scripts/backfill-filing-statements.ts",
"backfill:taxonomy-snapshots": "bun run scripts/backfill-taxonomy-snapshots.ts",
"db:generate": "bun x drizzle-kit generate",
"db:migrate": "bun x drizzle-kit migrate",
"test:e2e": "bun run scripts/e2e-run.ts",
"test:e2e:headed": "bun run scripts/e2e-run.ts --headed",
"test:e2e:install": "playwright install chromium",
"test:e2e:ui": "bun run scripts/e2e-run.ts --ui",
"test:e2e:workflow": "RUN_TASK_WORKFLOW_E2E=1 bun test lib/server/api/task-workflow-hybrid.e2e.test.ts"
},
"dependencies": {
@@ -38,6 +44,7 @@
"zhipu-ai-provider": "^0.2.2"
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/node": "^25.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",

44
playwright.config.ts Normal file
View File

@@ -0,0 +1,44 @@
import { defineConfig } from '@playwright/test';
const host = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
const port = Number(process.env.PLAYWRIGHT_PORT ?? '3400');
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://${host}:${port}`;
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
timeout: 30_000,
expect: {
timeout: 10_000
},
reporter: [
['list'],
['html', { open: 'never', outputFolder: 'output/playwright/report' }]
],
outputDir: 'output/playwright/test-results',
use: {
baseURL,
browserName: 'chromium',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
viewport: {
width: 1440,
height: 960
}
},
webServer: {
command: 'bun run e2e:webserver',
url: baseURL,
reuseExistingServer: false,
timeout: 120_000,
env: {
PLAYWRIGHT_BASE_URL: baseURL,
PLAYWRIGHT_HOST: host,
PLAYWRIGHT_PORT: String(port)
}
}
});

49
scripts/e2e-prepare.ts Normal file
View File

@@ -0,0 +1,49 @@
import { mkdirSync, readFileSync, rmSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { Database } from 'bun:sqlite';
const MIGRATION_FILES = [
'0000_cold_silver_centurion.sql',
'0001_glossy_statement_snapshots.sql',
'0002_workflow_task_projection_metadata.sql',
'0003_task_stage_event_timeline.sql',
'0004_watchlist_company_taxonomy.sql',
'0005_financial_taxonomy_v3.sql'
] as const;
export const E2E_DATABASE_PATH = join(process.cwd(), 'data', 'e2e.sqlite');
export const E2E_WORKFLOW_DATA_DIR = join(process.cwd(), '.workflow-data', 'e2e');
function removeFileIfPresent(path: string) {
rmSync(path, { force: true });
}
function applyMigrations(database: Database) {
for (const file of MIGRATION_FILES) {
const sql = readFileSync(join(process.cwd(), 'drizzle', file), 'utf8');
database.exec(sql);
}
}
export function prepareE2eDatabase() {
mkdirSync(dirname(E2E_DATABASE_PATH), { recursive: true });
removeFileIfPresent(E2E_DATABASE_PATH);
removeFileIfPresent(`${E2E_DATABASE_PATH}-shm`);
removeFileIfPresent(`${E2E_DATABASE_PATH}-wal`);
rmSync(E2E_WORKFLOW_DATA_DIR, { force: true, recursive: true });
const database = new Database(E2E_DATABASE_PATH, { create: true });
try {
database.exec('PRAGMA foreign_keys = ON;');
applyMigrations(database);
} finally {
database.close();
}
}
if (import.meta.main) {
prepareE2eDatabase();
console.info(`[e2e] prepared SQLite database at ${E2E_DATABASE_PATH}`);
}

81
scripts/e2e-run.ts Normal file
View File

@@ -0,0 +1,81 @@
import { spawn } from 'node:child_process';
import { Socket } from 'node:net';
const host = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
const requestedPort = Number(process.env.PLAYWRIGHT_PORT ?? '3400');
const candidatePorts = [
requestedPort,
3401,
3402,
3410,
3500,
3600,
3700,
3800
];
async function isPortAvailable(port: number) {
return await new Promise<boolean>((resolve) => {
const socket = new Socket();
const finish = (available: boolean) => {
socket.destroy();
resolve(available);
};
socket.setTimeout(250);
socket.once('connect', () => finish(false));
socket.once('timeout', () => finish(false));
socket.once('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'ECONNREFUSED' || error.code === 'EHOSTUNREACH') {
finish(true);
return;
}
finish(false);
});
try {
socket.connect(port, host);
} catch {
finish(false);
}
});
}
async function pickPort() {
for (const port of candidatePorts) {
if (await isPortAvailable(port)) {
return port;
}
}
throw new Error(`Unable to find an open Playwright port from: ${candidatePorts.join(', ')}`);
}
const port = await pickPort();
const baseURL = `http://${host}:${port}`;
console.info(`[e2e] using ${baseURL}`);
const child = spawn(
'bun',
['x', 'playwright', 'test', ...process.argv.slice(2)],
{
stdio: 'inherit',
env: {
...process.env,
PLAYWRIGHT_BASE_URL: baseURL,
PLAYWRIGHT_HOST: host,
PLAYWRIGHT_PORT: String(port)
}
}
);
child.on('exit', (code, signal) => {
if (signal) {
process.exit(signal === 'SIGINT' || signal === 'SIGTERM' ? 0 : 1);
return;
}
process.exit(code ?? 0);
});

53
scripts/e2e-webserver.ts Normal file
View File

@@ -0,0 +1,53 @@
import { spawn } from 'node:child_process';
import { mkdirSync } from 'node:fs';
import { prepareE2eDatabase, E2E_DATABASE_PATH, E2E_WORKFLOW_DATA_DIR } from './e2e-prepare';
const host = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
const port = process.env.PLAYWRIGHT_PORT ?? '3400';
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://${host}:${port}`;
const env: NodeJS.ProcessEnv = {
...process.env,
BETTER_AUTH_BASE_URL: baseURL,
BETTER_AUTH_SECRET: 'playwright-e2e-secret-playwright-e2e-secret',
BETTER_AUTH_TRUSTED_ORIGINS: baseURL,
DATABASE_URL: `file:${E2E_DATABASE_PATH}`,
HOSTNAME: host,
NEXT_PUBLIC_API_URL: '',
PORT: port,
SEC_USER_AGENT: 'Fiscal Clone Playwright <support@fiscal.local>',
WORKFLOW_LOCAL_DATA_DIR: E2E_WORKFLOW_DATA_DIR,
WORKFLOW_LOCAL_QUEUE_CONCURRENCY: '1',
WORKFLOW_TARGET_WORLD: 'local'
};
delete env.NO_COLOR;
prepareE2eDatabase();
mkdirSync(E2E_WORKFLOW_DATA_DIR, { recursive: true });
const child = spawn(
'bun',
['--bun', 'next', 'dev', '--turbopack', '--hostname', host, '--port', port],
{
stdio: 'inherit',
env
}
);
function forwardSignal(signal: NodeJS.Signals) {
if (!child.killed) {
child.kill(signal);
}
}
process.on('SIGINT', () => forwardSignal('SIGINT'));
process.on('SIGTERM', () => forwardSignal('SIGTERM'));
child.on('exit', (code, signal) => {
if (signal) {
process.exit(signal === 'SIGINT' || signal === 'SIGTERM' ? 0 : 1);
return;
}
process.exit(code ?? 0);
});