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';
|
||||
|
||||
// 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
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
<div class="mb-6 flex border-b border-sky-200/20">
|
||||
<button
|
||||
data-testid="auth-tab-login"
|
||||
onclick={() => (activeTab = 'login')}
|
||||
class="flex-1 cursor-pointer py-2 text-sm transition-colors {activeTab === 'login'
|
||||
? '-mb-px border-b border-white text-white'
|
||||
@@ -30,9 +31,9 @@
|
||||
Login
|
||||
</button>
|
||||
<button
|
||||
data-testid="auth-tab-register"
|
||||
onclick={() => (activeTab = 'register')}
|
||||
class="flex-1 cursor-pointer py-2 text-sm transition-colors {activeTab ===
|
||||
'register'
|
||||
class="flex-1 cursor-pointer py-2 text-sm transition-colors {activeTab === 'register'
|
||||
? '-mb-px border-b border-white text-white'
|
||||
: 'text-white/40 hover:text-white/60'}"
|
||||
>
|
||||
@@ -63,6 +64,7 @@
|
||||
</div>
|
||||
{#if error}<p class="text-xs text-red-400/60">{error}</p>{/if}
|
||||
<button
|
||||
data-testid="auth-submit"
|
||||
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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user