From d2f044db07a8a66ff6394eae125959d3cd535c7a Mon Sep 17 00:00:00 2001 From: stefiosif Date: Thu, 4 Jun 2026 21:55:10 +0300 Subject: [PATCH] test(frontend): add real-auth Playwright e2e suite Co-Authored-By: Claude Opus 4.8 --- frontend/e2e/auth.e2e.ts | 47 ++++++++++++++++ frontend/e2e/files.e2e.ts | 78 +++++++++++++++++++++++++++ frontend/e2e/helpers.ts | 26 +++++++++ frontend/playwright.config.ts | 29 +++++++++- frontend/src/routes/auth/+page.svelte | 6 ++- 5 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 frontend/e2e/auth.e2e.ts create mode 100644 frontend/e2e/files.e2e.ts create mode 100644 frontend/e2e/helpers.ts diff --git a/frontend/e2e/auth.e2e.ts b/frontend/e2e/auth.e2e.ts new file mode 100644 index 0000000..0b59bea --- /dev/null +++ b/frontend/e2e/auth.e2e.ts @@ -0,0 +1,47 @@ +import { test, expect } from '@playwright/test'; +import { uniqueUser, registerAndLogin } from './helpers'; + +test('registering via the form creates an account', async ({ page }) => { + const user = uniqueUser(); + await page.goto('/auth'); + await page.getByTestId('auth-tab-register').click(); + await page.locator('#username').fill(user.username); + await page.locator('#password').fill(user.password); + + const register = page.waitForResponse((r) => r.url().includes('/api/auth/register')); + await page.getByTestId('auth-submit').click(); + expect((await register).status()).toBe(201); +}); + +test('logging in via the form reaches the app', async ({ page }) => { + const user = uniqueUser(); + // pre-create the account, then exercise the login form (single clean navigation) + const reg = await page.request.post('/api/auth/register', { data: user }); + expect(reg.ok()).toBeTruthy(); + + await page.goto('/auth'); + await page.locator('#username').fill(user.username); + await page.locator('#password').fill(user.password); + await page.getByTestId('auth-submit').click(); + + await expect(page.getByRole('button', { name: 'Import' })).toBeVisible(); +}); + +test('login with wrong credentials shows an error', async ({ page }) => { + await page.goto('/auth'); + await page.locator('#username').fill('nope_does_not_exist'); + await page.locator('#password').fill('wrongpassword'); + await page.getByTestId('auth-submit').click(); + + await expect(page.getByText('Invalid credentials')).toBeVisible(); +}); + +test('signing out returns to the auth page', async ({ page }) => { + await registerAndLogin(page); + await page.goto('/'); + await expect(page.getByRole('button', { name: 'Import' })).toBeVisible(); + + await page.getByRole('button', { name: 'Sign out' }).click(); + await expect(page).toHaveURL(/\/auth$/); + await expect(page.getByTestId('auth-tab-login')).toBeVisible(); +}); diff --git a/frontend/e2e/files.e2e.ts b/frontend/e2e/files.e2e.ts new file mode 100644 index 0000000..07cbb03 --- /dev/null +++ b/frontend/e2e/files.e2e.ts @@ -0,0 +1,78 @@ +import { test, expect } from '@playwright/test'; +import { registerAndLogin, textFile } from './helpers'; + +// every test runs as a fresh logged-in user, so the file list starts empty +// and tests never see each other's files (the list is per-user on the backend). +test.beforeEach(async ({ page }) => { + await registerAndLogin(page); + await page.goto('/'); +}); + +test('a new user sees the empty state', async ({ page }) => { + await expect(page.getByText('No files yet.')).toBeVisible(); +}); + +test('uploading a file shows it in the list', async ({ page }) => { + await page.locator('input[type="file"]').setInputFiles(textFile('hello.txt')); + await expect(page.getByText('hello.txt')).toBeVisible(); +}); + +test('downloading a file triggers a download', async ({ page }) => { + await page.locator('input[type="file"]').setInputFiles(textFile('report.txt')); + await expect(page.getByText('report.txt')).toBeVisible(); + + const downloadPromise = page.waitForEvent('download'); + await page.locator('[title="Download"]').first().click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe('report.txt'); +}); + +test('deleting a file with confirmation removes it', async ({ page }) => { + await page.locator('input[type="file"]').setInputFiles(textFile('trash.txt')); + await expect(page.getByText('trash.txt')).toBeVisible(); + + await page.locator('[title="Delete"]').first().click(); + await expect(page.getByText('Confirm action')).toBeVisible(); + await page.locator('[title="Confirm"]').click(); + + await expect(page.getByText('trash.txt')).toHaveCount(0); +}); + +test('search filters the file list', async ({ page }) => { + const input = page.locator('input[type="file"]'); + await input.setInputFiles(textFile('alpha.txt')); + await expect(page.getByText('alpha.txt')).toBeVisible(); + await input.setInputFiles(textFile('bravo.txt')); + await expect(page.getByText('bravo.txt')).toBeVisible(); + + await page.getByPlaceholder('Search files...').fill('alpha'); + await expect(page.getByText('alpha.txt')).toBeVisible(); + await expect(page.getByText('bravo.txt')).toHaveCount(0); +}); + +test('previewing a video opens the player', async ({ page }) => { + await page.locator('input[type="file"]').setInputFiles({ + name: 'clip.mp4', + mimeType: 'video/mp4', + buffer: Buffer.from('fake mp4 bytes') + }); + await expect(page.getByText('clip.mp4')).toBeVisible(); + + await page.getByText('clip.mp4').dblclick(); + await expect(page.getByRole('button', { name: 'Close' })).toBeVisible(); + await expect(page.locator('video')).toHaveCount(1); + + await page.keyboard.press('Escape'); + await expect(page.getByRole('button', { name: 'Close' })).toHaveCount(0); +}); + +test('previewing a non-video shows no preview available', async ({ page }) => { + await page.locator('input[type="file"]').setInputFiles(textFile('notes.txt')); + await expect(page.getByText('notes.txt')).toBeVisible(); + + await page.getByText('notes.txt').dblclick(); + await expect(page.getByText('No preview available')).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(page.getByText('No preview available')).toHaveCount(0); +}); diff --git a/frontend/e2e/helpers.ts b/frontend/e2e/helpers.ts new file mode 100644 index 0000000..4d3c5c1 --- /dev/null +++ b/frontend/e2e/helpers.ts @@ -0,0 +1,26 @@ +import { expect, type Page } from '@playwright/test'; + +/** A fresh, unique account per call so tests never collide in the shared test DB. */ +export function uniqueUser() { + const id = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + return { username: `e2e_${id}`, password: 'password123' }; +} + +/** + * Registers a brand-new user and logs in via the real /api/auth flow. + * `page.request` shares the browser context's cookie jar, so the session + * cookie it sets makes subsequent page navigations authenticated. + */ +export async function registerAndLogin(page: Page) { + const user = uniqueUser(); + const register = await page.request.post('/api/auth/register', { data: user }); + expect(register.ok(), 'register should succeed').toBeTruthy(); + const login = await page.request.post('/api/auth/login', { data: user }); + expect(login.ok(), 'login should succeed').toBeTruthy(); + return user; +} + +/** A small in-memory upload fixture (no files on disk needed). */ +export function textFile(name: string) { + return { name, mimeType: 'text/plain', buffer: Buffer.from('e2e test content') }; +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 9eea31b..2771982 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,6 +1,31 @@ import { defineConfig } from '@playwright/test'; +// e2e runs against the dev server (vite :5173), which proxies /api to a backend +// instance pointed at the isolated `loft_test` database (see .env.test). export default defineConfig({ - webServer: { command: 'npm run build && npm run preview', port: 4173 }, - testMatch: '**/*.e2e.{ts,js}' + testDir: './e2e', + testMatch: '**/*.e2e.{ts,js}', + fullyParallel: false, + workers: 1, + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry' + }, + webServer: [ + { + // backend on :3000, pointed at the test DB via .env.test + command: 'cd ../backend && set -a && . ./.env.test && set +a && cargo run', + url: 'http://localhost:3000/health', + reuseExistingServer: true, + timeout: 180_000, + stdout: 'pipe', + stderr: 'pipe' + }, + { + command: 'pnpm dev', + url: 'http://localhost:5173', + reuseExistingServer: true, + timeout: 60_000 + } + ] }); diff --git a/frontend/src/routes/auth/+page.svelte b/frontend/src/routes/auth/+page.svelte index 244ada0..916abd9 100644 --- a/frontend/src/routes/auth/+page.svelte +++ b/frontend/src/routes/auth/+page.svelte @@ -22,6 +22,7 @@
{#if error}

{error}

{/if}