test(frontend): add real-auth Playwright e2e suite
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
47
frontend/e2e/auth.e2e.ts
Normal file
47
frontend/e2e/auth.e2e.ts
Normal 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
78
frontend/e2e/files.e2e.ts
Normal 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
26
frontend/e2e/helpers.ts
Normal 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') };
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user