From 34aa76504e4e98362aa01fa04f0e3eee28ff250d Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sat, 30 May 2026 23:04:40 -0400 Subject: [PATCH 1/2] add end-to-end testing setup with Playwright and initial test cases --- .github/workflows/e2e.yml | 138 ++++++++++++++++++++++++++++++++++++++ e2e/.gitignore | 4 ++ e2e/fixtures/auth.js | 49 ++++++++++++++ e2e/global-setup.js | 96 ++++++++++++++++++++++++++ e2e/package-lock.json | 78 +++++++++++++++++++++ e2e/package.json | 14 ++++ e2e/playwright.config.js | 32 +++++++++ e2e/tests/auth.spec.js | 64 ++++++++++++++++++ e2e/tests/chores.spec.js | 53 +++++++++++++++ 9 files changed, 528 insertions(+) create mode 100644 .github/workflows/e2e.yml create mode 100644 e2e/.gitignore create mode 100644 e2e/fixtures/auth.js create mode 100644 e2e/global-setup.js create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.js create mode 100644 e2e/tests/auth.spec.js create mode 100644 e2e/tests/chores.spec.js diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..f73d178 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,138 @@ +name: E2E Tests + +on: + push: + branches: [main] + paths: + - 'src/**' + - 'e2e/**' + - '.github/workflows/e2e.yml' + pull_request: + branches: [main] + paths: + - 'src/**' + - 'e2e/**' + - '.github/workflows/e2e.yml' + workflow_dispatch: + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + + # ── Node / frontend ──────────────────────────────────────────────────── + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'npm' + + - name: Install frontend dependencies + run: | + npm ci + # Platform-specific native packages required on Linux + npm install --force \ + @rollup/rollup-linux-x64-gnu@4.34.9 \ + @swc/core-linux-x64-gnu + + # ── Playwright ──────────────────────────────────────────────────────── + - name: Install Playwright dependencies + run: npm ci + working-directory: e2e + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + working-directory: e2e + + # ── Backend binary ──────────────────────────────────────────────────── + - name: Get latest donetick release tag + id: release + run: | + TAG=$(curl -fsSL https://api.github.com/repos/donetick/donetick/releases/latest \ + | grep '"tag_name"' | head -1 \ + | sed 's/.*"tag_name": "\(.*\)".*/\1/') + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "Using backend tag: $TAG" + + - name: Cache donetick binary + id: cache-binary + uses: actions/cache@v4 + with: + path: .donetick-bin/ + key: donetick-${{ steps.release.outputs.tag }}-linux-x86_64 + + - name: Download and extract donetick binary + if: steps.cache-binary.outputs.cache-hit != 'true' + run: | + mkdir -p .donetick-bin + curl -fsSL \ + "https://github.com/donetick/donetick/releases/download/${{ steps.release.outputs.tag }}/donetick_Linux_x86_64.tar.gz" \ + -o .donetick-bin/donetick_Linux_x86_64.tar.gz + tar -xzf .donetick-bin/donetick_Linux_x86_64.tar.gz -C .donetick-bin + chmod +x .donetick-bin/donetick + + - name: Write backend config + run: | + mkdir -p .donetick-bin/config + cat > .donetick-bin/config/selfhosted.yaml << 'EOF' + name: "selfhosted" + is_done_tick_dot_com: false + is_user_creation_disabled: false + database: + type: "sqlite" + migration: true + jwt: + secret: "e2e_test_secret_32chars_long_ok!" + session_time: 168h + max_refresh: 1440h + server: + port: 2021 + read_timeout: 10s + write_timeout: 10s + rate_period: 60s + rate_limit: 300 + cors_allow_origins: + - "http://localhost:5173" + - "http://localhost:4173" + serve_frontend: false + logging: + level: "warn" + encoding: "json" + development: false + EOF + + - name: Start donetick backend + working-directory: .donetick-bin + env: + DT_ENV: selfhosted + DT_SQLITE_PATH: /tmp/donetick-e2e.db + run: ./donetick & + + # ── Frontend ────────────────────────────────────────────────────────── + # Build with --mode development so .env.development is loaded, + # which sets VITE_APP_API_URL=http://localhost:2021 + - name: Build frontend + run: npx vite build --mode development + + - name: Serve frontend + run: npx vite preview --port 5173 & + + # ── Tests ───────────────────────────────────────────────────────────── + - name: Run Playwright tests + working-directory: e2e + env: + E2E_FRONTEND_URL: http://localhost:5173 + E2E_API_URL: http://localhost:2021 + run: npx playwright test + + - name: Upload test artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + e2e/playwright-report/ + e2e/test-results/ + retention-days: 7 diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..86849c4 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.auth/ +playwright-report/ +test-results/ diff --git a/e2e/fixtures/auth.js b/e2e/fixtures/auth.js new file mode 100644 index 0000000..13df6f6 --- /dev/null +++ b/e2e/fixtures/auth.js @@ -0,0 +1,49 @@ +import { test as base, expect } from '@playwright/test' +import path from 'path' +import { fileURLToPath } from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +/** + * Fill and submit the signup form through the UI. + * After successful signup the app auto-logs in and redirects to /chores. + */ +export async function signUpViaUI(page, { username, email, password, displayName }) { + await page.goto('/signup') + await page.locator('#username').fill(username) + await page.locator('#email').fill(email) + await page.locator('#password').fill(password) + await page.locator('#displayName').fill(displayName) + await page.getByRole('button', { name: 'Sign Up' }).click() + await page.waitForURL('**/chores', { timeout: 10_000 }) +} + +/** + * Fill and submit the login form through the UI. + * After successful login the app redirects to /chores. + */ +export async function loginViaUI(page, { username, password }) { + await page.goto('/login') + // The username input on the login page has id="email" (quirk of the form) + await page.locator('#email').fill(username) + await page.locator('#password').fill(password) + await page.getByRole('button', { name: 'Sign In' }).click() + await page.waitForURL('**/chores', { timeout: 10_000 }) +} + +/** + * A Playwright test fixture that provides a page already authenticated as the + * shared E2E user (via persisted storage state, no UI interaction required). + */ +export const test = base.extend({ + authenticatedPage: async ({ browser }, use) => { + const ctx = await browser.newContext({ + storageState: path.join(__dirname, '..', '.auth', 'state.json'), + }) + const page = await ctx.newPage() + await use(page) + await ctx.close() + }, +}) + +export { expect } diff --git a/e2e/global-setup.js b/e2e/global-setup.js new file mode 100644 index 0000000..1d949d9 --- /dev/null +++ b/e2e/global-setup.js @@ -0,0 +1,96 @@ +import { writeFile, mkdir } from 'fs/promises' +import path from 'path' +import { fileURLToPath } from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +// FRONTEND_URL: what the browser navigates to (Playwright baseURL) +// API_URL: where the Go API lives (may be the same in production builds) +export const FRONTEND_URL = + process.env.E2E_FRONTEND_URL || + process.env.E2E_BASE_URL || + 'http://localhost:5173' +export const API_URL = + process.env.E2E_API_URL || process.env.E2E_BASE_URL || 'http://localhost:2021' + +export const TEST_USER = { + username: 'e2e.user', + email: 'e2e@donetick.test', + password: 'E2ePassword123!', + displayName: 'E2E User', +} + +/** + * Global setup: create the shared E2E test user (idempotent) and persist + * the JWT token as a Playwright storage state so authenticated tests can + * skip the login UI. + */ +export default async function globalSetup() { + await waitForServer(API_URL) + + // Create user – ignore 409/conflict so re-runs are safe + const signupRes = await fetch(`${API_URL}/api/v1/auth/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(TEST_USER), + }) + if (!signupRes.ok) { + const body = await signupRes.text() + // Treat "already exists" errors as success so re-runs are idempotent + if (!body.includes('already exists') && !body.includes('already taken')) { + throw new Error(`Signup failed (${signupRes.status}): ${body}`) + } + } + + // Login and grab the access token + const loginRes = await fetch(`${API_URL}/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: TEST_USER.username, + password: TEST_USER.password, + }), + }) + if (!loginRes.ok) { + const body = await loginRes.text() + throw new Error(`Login failed (${loginRes.status}): ${body}`) + } + + const { token, expire } = await loginRes.json() + + // Write a Playwright storage-state file containing the token in localStorage + const stateDir = path.join(__dirname, '.auth') + await mkdir(stateDir, { recursive: true }) + await writeFile( + path.join(stateDir, 'state.json'), + JSON.stringify({ + cookies: [], + origins: [ + { + origin: FRONTEND_URL, + localStorage: [ + { name: 'token', value: token }, + { name: 'token_expiry', value: expire }, + ], + }, + ], + }), + 'utf8', + ) + + console.log('[global-setup] Test user ready, storage state saved.') +} + +async function waitForServer(url, retries = 20, delayMs = 1000) { + for (let i = 0; i < retries; i++) { + try { + const res = await fetch(`${url}/api/v1/auth/login`, { method: 'POST', body: '{}', headers: { 'Content-Type': 'application/json' } }) + if (res.status < 500) return + } catch { + // server not up yet + } + console.log(`[global-setup] Waiting for server… (${i + 1}/${retries})`) + await new Promise(r => setTimeout(r, delayMs)) + } + throw new Error(`Server at ${url} did not become ready in time`) +} diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..703eadb --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "donetick-e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "donetick-e2e", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "^1.44.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..28d59dc --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,14 @@ +{ + "name": "donetick-e2e", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "test:report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.44.0" + } +} diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js new file mode 100644 index 0000000..046be39 --- /dev/null +++ b/e2e/playwright.config.js @@ -0,0 +1,32 @@ +import { defineConfig, devices } from '@playwright/test' + +// When frontend and backend are the same origin (embedded binary) set E2E_BASE_URL. +// For dev, set E2E_FRONTEND_URL (default 5173) and E2E_API_URL (default 2021) separately. +const FRONTEND_URL = + process.env.E2E_FRONTEND_URL || + process.env.E2E_BASE_URL || + 'http://localhost:5173' + +export default defineConfig({ + testDir: './tests', + timeout: 30_000, + retries: process.env.CI ? 2 : 0, + workers: 1, // sequential – single SQLite DB + reporter: process.env.CI ? 'github' : 'list', + + use: { + baseURL: FRONTEND_URL, + headless: true, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + + globalSetup: './global-setup.js', + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/tests/auth.spec.js b/e2e/tests/auth.spec.js new file mode 100644 index 0000000..bf657dd --- /dev/null +++ b/e2e/tests/auth.spec.js @@ -0,0 +1,64 @@ +import { test, expect } from '@playwright/test' +import { signUpViaUI, loginViaUI } from '../fixtures/auth.js' + +// Username must match /^[a-z.-]+$/ — no digits allowed. +// Generate a random lowercase-only suffix for uniqueness across runs. +function randomSuffix(len = 8) { + const chars = 'abcdefghijklmnopqrstuvwxyz' + return Array.from({ length: len }, () => + chars[Math.floor(Math.random() * 26)], + ).join('') +} + +test.describe('Auth – Sign Up', () => { + test('creates a new account and lands on /chores', async ({ page }) => { + const suffix = randomSuffix() + const user = { + username: `test.signup.${suffix}`, + email: `signup.${suffix}@donetick.test`, + password: 'TestPassword123!', + displayName: 'Test Signup User', + } + + await signUpViaUI(page, user) + + await expect(page).toHaveURL(/\/chores/) + }) + + test('shows an error when username is too short', async ({ page }) => { + await page.goto('/signup') + await page.locator('#username').fill('ab') // < 4 chars + await page.locator('#email').fill('short@donetick.test') + await page.locator('#password').fill('ValidPass123!') + await page.locator('#displayName').fill('Short User') + await page.getByRole('button', { name: 'Sign Up' }).click() + + await expect( + page.getByText('Username must be at least 4 characters'), + ).toBeVisible() + }) +}) + +test.describe('Auth – Login', () => { + // Re-use the shared E2E user that global-setup already created + test('logs in with valid credentials and lands on /chores', async ({ page }) => { + await loginViaUI(page, { + username: 'e2e.user', + password: 'E2ePassword123!', + }) + + await expect(page).toHaveURL(/\/chores/) + }) + + test('shows an error for wrong password', async ({ page }) => { + await page.goto('/login') + await page.locator('#email').fill('e2e.user') + await page.locator('#password').fill('WrongPassword!') + await page.getByRole('button', { name: 'Sign In' }).click() + + // The notification snackbar / error message should appear + await expect( + page.getByText(/Login Failed|invalid|incorrect/i).first(), + ).toBeVisible({ timeout: 5_000 }) + }) +}) diff --git a/e2e/tests/chores.spec.js b/e2e/tests/chores.spec.js new file mode 100644 index 0000000..c0c65ec --- /dev/null +++ b/e2e/tests/chores.spec.js @@ -0,0 +1,53 @@ +import { expect, test } from '../fixtures/auth.js' +import { API_URL } from '../global-setup.js' + +test.describe('Chores – Create', () => { + // All tests in this suite run as the pre-authenticated E2E user + test.use({ + storageState: '.auth/state.json', + }) + + test('creates a daily recurring chore and confirms it appears in the list', async ({ + page, + }) => { + const choreName = `E2E Daily Chore ${Date.now()}` + + // ── Navigate to the create chore page ──────────────────────────────── + await page.goto('/chores/create') + + // ── Fill in the chore name ──────────────────────────────────────────── + // Name input is the first on the page (no id/placeholder) + await page.locator('input').first().fill(choreName) + + // ── Enable recurrence ───────────────────────────────────────────────── + // Check the "Repeat this task" checkbox to reveal frequency options + await page.getByLabel('Repeat this task').click() + + // Select "Daily" from the frequency chips + await page.getByLabel('Daily').click() + + // The due date is auto-populated to today once a repeating type is selected. + // No manual date entry required unless testing date-specific behaviour. + + // ── Save ────────────────────────────────────────────────────────────── + await page.getByRole('button', { name: 'Create' }).click() + + // After save the app navigates back to the chore list + await page.waitForURL('**/chores', { timeout: 15_000 }) + + // ── Verify the chore appears in the UI ─────────────────────────────── + await expect(page.getByText(choreName)).toBeVisible({ timeout: 10_000 }) + + // ── Verify the chore exists in the API ─────────────────────────────── + const token = await page.evaluate(() => localStorage.getItem('token')) + const apiRes = await fetch(`${API_URL}/api/v1/chores/`, { + headers: { Authorization: `Bearer ${token}` }, + }) + expect(apiRes.ok).toBe(true) + + const { res: chores } = await apiRes.json() + const created = chores.find(c => c.name === choreName) + expect(created).toBeDefined() + expect(created.frequencyType).toBe('daily') + }) +}) From 2b7dfe68c2c90eeb9c7fe2024c9e5626dc60f43e Mon Sep 17 00:00:00 2001 From: Mohamad Tarbin Date: Mon, 10 Aug 2026 22:47:36 -0400 Subject: [PATCH 2/2] Add way to run the e2e test --- e2e/.gitignore | 1 + e2e/package.json | 3 +- e2e/run-e2e.sh | 117 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100755 e2e/run-e2e.sh diff --git a/e2e/.gitignore b/e2e/.gitignore index 86849c4..6df8e35 100644 --- a/e2e/.gitignore +++ b/e2e/.gitignore @@ -2,3 +2,4 @@ node_modules/ .auth/ playwright-report/ test-results/ +.e2e-run/ diff --git a/e2e/package.json b/e2e/package.json index 28d59dc..1baecfc 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -6,7 +6,8 @@ "scripts": { "test": "playwright test", "test:headed": "playwright test --headed", - "test:report": "playwright show-report" + "test:report": "playwright show-report", + "test:full": "./run-e2e.sh" }, "devDependencies": { "@playwright/test": "^1.44.0" diff --git a/e2e/run-e2e.sh b/e2e/run-e2e.sh new file mode 100755 index 0000000..9bc4c56 --- /dev/null +++ b/e2e/run-e2e.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Starts an isolated backend + frontend for this repo, runs the Playwright +# suite against them, then tears both servers down. Uses dedicated ports +# (2022/5180) so it never touches dev servers you may have running elsewhere +# (e.g. another worktree on the default 2021/5173). +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FRONTEND_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +BACKEND_DIR="$HOME/workspace/donetick-core" + +BACKEND_PORT=2022 +FRONTEND_PORT=5180 +BACKEND_URL="http://localhost:$BACKEND_PORT" +FRONTEND_URL="http://localhost:$FRONTEND_PORT" + +LOG_DIR="$SCRIPT_DIR/.e2e-run" +mkdir -p "$LOG_DIR" + +BACKEND_PID="" +FRONTEND_PID="" + +cleanup() { + echo "--- Stopping e2e servers ---" + [[ -n "$FRONTEND_PID" ]] && kill -TERM -"$FRONTEND_PID" 2>/dev/null + [[ -n "$BACKEND_PID" ]] && kill -TERM -"$BACKEND_PID" 2>/dev/null + sleep 1 + [[ -n "$FRONTEND_PID" ]] && kill -KILL -"$FRONTEND_PID" 2>/dev/null + [[ -n "$BACKEND_PID" ]] && kill -KILL -"$BACKEND_PID" 2>/dev/null + wait 2>/dev/null +} +trap cleanup EXIT INT TERM + +kill_port() { + local port=$1 + local pids + pids=$(lsof -ti tcp:"$port" 2>/dev/null || true) + if [[ -n "$pids" ]]; then + echo "Port $port in use by leftover process(es) $pids — killing." + kill -TERM $pids 2>/dev/null + sleep 1 + kill -KILL $pids 2>/dev/null || true + fi +} + +wait_for_backend() { + for ((i = 1; i <= 40; i++)); do + if curl -s -o /dev/null -w '%{http_code}' -X POST "$BACKEND_URL/api/v1/auth/login" \ + -H 'Content-Type: application/json' -d '{}' 2>/dev/null | grep -qE '^[0-9]+$'; then + return 0 + fi + sleep 1 + done + return 1 +} + +wait_for_frontend() { + for ((i = 1; i <= 40; i++)); do + if curl -sf -o /dev/null "$FRONTEND_URL" 2>/dev/null; then + return 0 + fi + sleep 1 + done + return 1 +} + +kill_port "$BACKEND_PORT" +kill_port "$FRONTEND_PORT" + +echo "--- Starting backend on :$BACKEND_PORT (log: $LOG_DIR/backend.log) ---" +( + cd "$BACKEND_DIR" + exec setsid env \ + DT_NAME=e2e-frontend-repo \ + DT_IS_DONE_TICK_DOT_COM=false \ + DT_IS_USER_CREATION_DISABLED=false \ + DT_DATABASE_TYPE=sqlite \ + DT_DATABASE_MIGRATION=true \ + DT_JWT_SECRET=e2e_test_secret_change_this_32chars \ + DT_JWT_SESSION_TIME=168h \ + DT_JWT_MAX_REFRESH=168h \ + DT_SERVER_PORT="$BACKEND_PORT" \ + DT_SERVER_READ_TIMEOUT=10s \ + DT_SERVER_WRITE_TIMEOUT=10s \ + DT_SERVER_RATE_PERIOD=60s \ + DT_SERVER_RATE_LIMIT=300 \ + DT_SERVER_CORS_ALLOW_ORIGINS="$FRONTEND_URL" \ + DT_SERVER_SERVE_FRONTEND=false \ + go run . +) > "$LOG_DIR/backend.log" 2>&1 & +BACKEND_PID=$! + +echo "--- Starting frontend on :$FRONTEND_PORT (log: $LOG_DIR/frontend.log) ---" +( + cd "$FRONTEND_DIR" + exec setsid env VITE_APP_API_URL="$BACKEND_URL" npx vite --port "$FRONTEND_PORT" --strictPort +) > "$LOG_DIR/frontend.log" 2>&1 & +FRONTEND_PID=$! + +echo "Waiting for backend..." +if ! wait_for_backend; then + echo "Backend did not become ready. See $LOG_DIR/backend.log" >&2 + exit 1 +fi + +echo "Waiting for frontend..." +if ! wait_for_frontend; then + echo "Frontend did not become ready. See $LOG_DIR/frontend.log" >&2 + exit 1 +fi + +echo "--- Running Playwright tests ---" +cd "$SCRIPT_DIR" +E2E_FRONTEND_URL="$FRONTEND_URL" E2E_API_URL="$BACKEND_URL" npx playwright test "$@" +TEST_EXIT=$? + +exit $TEST_EXIT