From 7dd403e8141bce0e5728a1eede63760b26dfa8e7 Mon Sep 17 00:00:00 2001 From: francy51 Date: Fri, 6 Mar 2026 21:08:20 -0500 Subject: [PATCH] Add Playwright e2e test suite --- README.md | 23 ++++++++++++ bun.lock | 9 +++++ e2e/auth.spec.ts | 38 +++++++++++++++++++ package.json | 7 ++++ playwright.config.ts | 44 ++++++++++++++++++++++ scripts/e2e-prepare.ts | 49 ++++++++++++++++++++++++ scripts/e2e-run.ts | 81 ++++++++++++++++++++++++++++++++++++++++ scripts/e2e-webserver.ts | 53 ++++++++++++++++++++++++++ 8 files changed, 304 insertions(+) create mode 100644 e2e/auth.spec.ts create mode 100644 playwright.config.ts create mode 100644 scripts/e2e-prepare.ts create mode 100644 scripts/e2e-run.ts create mode 100644 scripts/e2e-webserver.ts diff --git a/README.md b/README.md index 692c0c6..4255f31 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bun.lock b/bun.lock index 1f12c7b..a28e74a 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 0000000..f93e1c0 --- /dev/null +++ b/e2e/auth.spec.ts @@ -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(); +}); diff --git a/package.json b/package.json index eeef021..1fe43c0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..27a5e09 --- /dev/null +++ b/playwright.config.ts @@ -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) + } + } +}); diff --git a/scripts/e2e-prepare.ts b/scripts/e2e-prepare.ts new file mode 100644 index 0000000..62abd6d --- /dev/null +++ b/scripts/e2e-prepare.ts @@ -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}`); +} diff --git a/scripts/e2e-run.ts b/scripts/e2e-run.ts new file mode 100644 index 0000000..1d0e256 --- /dev/null +++ b/scripts/e2e-run.ts @@ -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((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); +}); diff --git a/scripts/e2e-webserver.ts b/scripts/e2e-webserver.ts new file mode 100644 index 0000000..a175685 --- /dev/null +++ b/scripts/e2e-webserver.ts @@ -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 ', + 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); +});