test(frontend): add real-auth Playwright e2e suite

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 21:55:10 +03:00
parent 0ef7728ac0
commit d2f044db07
5 changed files with 182 additions and 4 deletions

47
frontend/e2e/auth.e2e.ts Normal file
View File

@@ -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();
});

78
frontend/e2e/files.e2e.ts Normal file
View File

@@ -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);
});

26
frontend/e2e/helpers.ts Normal file
View File

@@ -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') };
}

View File

@@ -1,6 +1,31 @@
import { defineConfig } from '@playwright/test'; 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({ export default defineConfig({
webServer: { command: 'npm run build && npm run preview', port: 4173 }, testDir: './e2e',
testMatch: '**/*.e2e.{ts,js}' 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
}
]
}); });

View File

@@ -22,6 +22,7 @@
<div class="mb-6 flex border-b border-sky-200/20"> <div class="mb-6 flex border-b border-sky-200/20">
<button <button
data-testid="auth-tab-login"
onclick={() => (activeTab = 'login')} onclick={() => (activeTab = 'login')}
class="flex-1 cursor-pointer py-2 text-sm transition-colors {activeTab === 'login' class="flex-1 cursor-pointer py-2 text-sm transition-colors {activeTab === 'login'
? '-mb-px border-b border-white text-white' ? '-mb-px border-b border-white text-white'
@@ -30,9 +31,9 @@
Login Login
</button> </button>
<button <button
data-testid="auth-tab-register"
onclick={() => (activeTab = 'register')} onclick={() => (activeTab = 'register')}
class="flex-1 cursor-pointer py-2 text-sm transition-colors {activeTab === class="flex-1 cursor-pointer py-2 text-sm transition-colors {activeTab === 'register'
'register'
? '-mb-px border-b border-white text-white' ? '-mb-px border-b border-white text-white'
: 'text-white/40 hover:text-white/60'}" : 'text-white/40 hover:text-white/60'}"
> >
@@ -63,6 +64,7 @@
</div> </div>
{#if error}<p class="text-xs text-red-400/60">{error}</p>{/if} {#if error}<p class="text-xs text-red-400/60">{error}</p>{/if}
<button <button
data-testid="auth-submit"
onclick={handleSubmit} onclick={handleSubmit}
class="mt-1 cursor-pointer rounded-sm border border-sky-200/15 px-4 py-2 text-sm text-white/40 transition-colors hover:border-sky-200/30 hover:text-white/60" class="mt-1 cursor-pointer rounded-sm border border-sky-200/15 px-4 py-2 text-sm text-white/40 transition-colors hover:border-sky-200/30 hover:text-white/60"
> >