diff --git a/AGENTS.md b/AGENTS.md index 41da2c96..0c0c6674 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -821,10 +821,11 @@ vi.mock('#lib/api/index.ts', async (importOriginal) => ({ - Coverage thresholds: statements 92, branches 76, functions 90, lines 92; the generated client (`src/lib/api/`) is excluded ### E2E (Playwright) -- Chromium only, base URL `https://localhost:5001`, `ignoreHTTPSErrors: true` (self-signed certs), `timeout: 10000`, `expect.timeout: 3000` -- Run through npm scripts: `npm run test:e2e:deps` and `npm run test:e2e:browsers` once, then `npm run test:e2e`. Outside CI the config starts `npm run dev` unless a server is already up -- Users are seeded by the `user-seed` compose service (`scripts/seed_users.py`) on every `./deploy.sh dev`; rerun it with `docker compose run --rm user-seed` -- Log in by username: `user` / `user123` and `admin` / `admin123` (`frontend/e2e/fixtures.ts`). The seeded emails are user@integr8scode.com and admin@integr8scode.com +- Chromium only, base URL `https://localhost:5001`, `ignoreHTTPSErrors: true` (self-signed certs), `timeout: 10_000`, `expect.timeout: 3_000`, `test.slow()` for a test that waits for a pod. In CI one retry, and `failOnFlakyTests` fails a test that passed only on retry +- Run through npm scripts: `npm run test:e2e:deps` and `npm run test:e2e:browsers` once, then `npm run test:e2e` against the compose stack on :5001 (`npm run dev` can't reach the API from the host) +- `frontend/e2e/fixtures.ts` registers one account per worker through the API and signs in the seeded `admin` (password from `ADMIN_USER_PASSWORD`, default `admin123`). Each test gets a fresh context: `page` (guest), `userPage` or `adminPage`; `api` is a client signed in as the worker's user and `execution` starts an execution through it +- Specs are flat `test()` calls with no `describe` wrappers or helpers defined inside functions. Assert with retrying `expect`, never `waitForTimeout` or a polling loop; hold a request with `page.route(url, holdRequest)` to see a loading state. Labels come from `fieldTitle` and option lists from the generated enums and schemas +- Users are seeded by the `user-seed` compose service (`scripts/seed_users.py`) on every `./deploy.sh dev`; rerun it with `docker compose run --rm user-seed`. For manual checks log in as `user` / `user123` or `admin` / `admin123` ## CI Gates (All Must Pass) diff --git a/docs/testing/frontend-testing.md b/docs/testing/frontend-testing.md index 22edc56e..0482273c 100644 --- a/docs/testing/frontend-testing.md +++ b/docs/testing/frontend-testing.md @@ -80,9 +80,11 @@ When a `vi.mock` factory refers to mock functions, create them in `vi.hoisted()` --8<-- "frontend/playwright.config.ts:config" ``` -Outside CI, Playwright starts `npm run dev` on `https://localhost:5001` unless something already listens there, such as the compose frontend. The suites need a backend with seeded users either way. +The suite runs against the stack on `https://localhost:5001`, which is the compose frontend that `./deploy.sh dev` starts. `npm run dev` can't stand in for it, since its proxy sends `/api` to a hostname that only resolves inside the compose network. The only account the suite expects is the seeded admin, whose password it reads from `ADMIN_USER_PASSWORD` like seed_users.py does, falling back to `admin123`. -[`e2e/fixtures.ts`](https://github.com/HardMax71/Integr8sCode/blob/main/frontend/e2e/fixtures.ts) logs in once per worker. The worker-scoped `userContext` and `adminContext` hold the logged-in browser contexts, and each test gets a fresh `userPage` or `adminPage` from them. Tests of the login flow itself use the plain `page` and `clearSession()`. Every page also collects JS coverage for the monocart reporter, which writes `coverage/e2e/`. +[`e2e/fixtures.ts`](https://github.com/HardMax71/Integr8sCode/blob/main/frontend/e2e/fixtures.ts) registers one account per worker through the API and signs it and the seeded admin in once per worker, so two workers never share a user's settings, scripts or notifications. Every test gets a fresh browser context: `page` for a guest, and `userPage` or `adminPage` starting with the worker's session cookies, so nothing one test leaves in localStorage reaches the next. The `api` fixture is an API client signed in as the worker's user, and `execution` starts an execution through it for the tests that need one, such as the notification and admin table tests. + +Tests don't sleep or write polling loops. They assert on what the page shows with Playwright's retrying assertions, and hold a request with `page.route` to look at a loading state. Labels come from `fieldTitle` and option lists from the generated enums and schemas, the same sources the pages use. svelte-check and Prettier cover `e2e/` as well. In CI a failed test is retried once to record a second trace, and a test that passes only on the retry still fails the run. Every page also collects JS coverage for the monocart reporter, which writes `coverage/e2e/`. ## In CI diff --git a/frontend/e2e/access.spec.ts b/frontend/e2e/access.spec.ts new file mode 100644 index 00000000..9e7872f9 --- /dev/null +++ b/frontend/e2e/access.spec.ts @@ -0,0 +1,29 @@ +import type { Request } from '@playwright/test'; +import { expect, test } from './fixtures'; +import { ADMIN_ROUTES } from '#lib/admin/constants.ts'; + +/** Whether the page called the admin API, which a guard that runs before the page loads must prevent */ +function isAdminApiCall(request: Request): boolean { + return new URL(request.url()).pathname.startsWith('/api/v1/admin/'); +} + +for (const path of ['/editor', '/settings', '/notifications']) { + test(`${path} sends a guest to login and remembers the page`, async ({ page }) => { + await page.goto(path); + await expect(page).toHaveURL(`/login?${new URLSearchParams({ redirect: path })}`); + }); +} + +for (const { path } of ADMIN_ROUTES) { + test(`${path} sends a guest to login and remembers the page`, async ({ page }) => { + await page.goto(path); + await expect(page).toHaveURL(`/login?${new URLSearchParams({ redirect: path })}`); + }); + + test(`${path} sends a user who is not an admin home before the page asks the admin API`, async ({ userPage }) => { + await userPage.goto(path); + await expect(userPage).toHaveURL('/'); + await expect(userPage.getByText('Admin access required')).toBeVisible(); + expect((await userPage.requests()).filter(isAdminApiCall)).toEqual([]); + }); +} diff --git a/frontend/e2e/admin-events.spec.ts b/frontend/e2e/admin-events.spec.ts index 2d689d1d..6b193d77 100644 --- a/frontend/e2e/admin-events.spec.ts +++ b/frontend/e2e/admin-events.spec.ts @@ -1,66 +1,50 @@ -import { test, expect, describeAdminCommonTests, describeAdminAccessControl, expectTableOrEmptyState, expectTableColumn } from './fixtures'; - -const PATH = '/admin/events' as const; -const EMPTY_PATTERN = /No events found/i; - -test.describe('Admin Events', () => { - describeAdminCommonTests(test, PATH); - - test('shows action buttons', async ({ adminPage }) => { - await adminPage.goto(PATH); - await expect(adminPage.getByRole('button', { name: /Filters/i })).toBeVisible(); - await expect(adminPage.getByRole('button', { name: /Export/i })).toBeVisible(); - await expect(adminPage.getByRole('button', { name: /Refresh/i })).toBeVisible(); - }); - - test.describe('Filtering', () => { - test('filter panel shows date range inputs', async ({ adminPage }) => { - await adminPage.goto(PATH); - await adminPage.getByRole('button', { name: /Filters/i }).click(); - await expect(adminPage.locator('input[type="datetime-local"], input[type="date"]').first()).toBeVisible(); - }); - }); - - test.describe('Export', () => { - test('can open export dropdown', async ({ adminPage }) => { - await adminPage.goto(PATH); - await adminPage.getByRole('button', { name: /Export/i }).click(); - await expect(adminPage.getByRole('menuitem', { name: 'Export as csv' })).toBeVisible(); - await expect(adminPage.getByRole('menuitem', { name: 'Export as json' })).toBeVisible(); - }); - }); - - test.describe('Table', () => { - test('shows events table or empty state', async ({ adminPage }) => { - await adminPage.goto(PATH); - await expectTableOrEmptyState(adminPage, EMPTY_PATTERN); - }); +import { expect, test } from './fixtures'; +import { ExportFormat } from '#lib/api/types.gen.ts'; +import { fieldTitle } from '#lib/formatters.ts'; + +test('the event browser shows the filter, export and refresh buttons', async ({ adminPage }) => { + await adminPage.goto('/admin/events'); + for (const action of ['Filters', 'Export', 'Refresh']) { + await expect(adminPage.getByRole('button', { name: action })).toBeVisible(); + } +}); - test('shows Timestamp column when data exists', async ({ adminPage }) => { - await adminPage.goto(PATH); - await expectTableColumn(adminPage, 'Timestamp', EMPTY_PATTERN); - }); +test('the filter panel has the time range inputs', async ({ adminPage }) => { + await adminPage.goto('/admin/events'); + await adminPage.getByRole('button', { name: 'Filters' }).click(); + await expect(adminPage.getByLabel(fieldTitle('start_time'))).toBeVisible(); + await expect(adminPage.getByLabel(fieldTitle('end_time'))).toBeVisible(); +}); - test('opens the details of an event with its timestamp', async ({ adminPage }) => { - await adminPage.goto(PATH); - await adminPage.getByRole('button', { name: /View details for event/ }).first().click(); - const dialog = adminPage.getByRole('dialog', { name: 'Event Details' }); - await expect(dialog).toBeVisible(); - await expect(dialog.locator('dt:text-is("Timestamp") + dd')).toContainText(/\d{4}/); - }); - }); +test('the export menu offers every export format', async ({ adminPage }) => { + await adminPage.goto('/admin/events'); + await adminPage.getByRole('button', { name: 'Export' }).click(); + for (const format of Object.values(ExportFormat)) { + await expect(adminPage.getByRole('menuitem', { name: `Export as ${format}` })).toBeVisible(); + } +}); - test.describe('Refresh', () => { - test('can manually refresh events', async ({ adminPage }) => { - await adminPage.goto(PATH); - const [response] = await Promise.all([ - adminPage.waitForResponse(resp => resp.url().includes('/api/v1/admin/events') && resp.status() === 200), - adminPage.getByRole('button', { name: /Refresh/i }).click(), - ]); - expect(response.ok()).toBe(true); - await expect(adminPage.getByRole('heading', { name: 'Event Browser' })).toBeVisible(); - }); - }); +test("a user's events show in the table and open their details", async ({ adminPage, account }) => { + await adminPage.goto('/admin/events'); + await adminPage.getByRole('button', { name: 'Filters' }).click(); + await adminPage.getByLabel(fieldTitle('user_id')).fill(account.user_id); + await adminPage.getByRole('button', { name: 'Apply' }).click(); + const table = adminPage.getByRole('table', { name: 'Events' }); + for (const key of ['timestamp', 'event_type', 'user_id']) { + await expect(table.getByRole('columnheader', { name: fieldTitle(key), exact: true })).toBeVisible(); + } + await table + .getByRole('button', { name: /^View details for event / }) + .first() + .click(); + const dialog = adminPage.getByRole('dialog', { name: 'Event Details' }); + await expect(dialog).toContainText(`"user_id": "${account.user_id}"`); }); -describeAdminAccessControl(test, PATH); +test('refresh fetches the events again', async ({ adminPage }) => { + await adminPage.goto('/admin/events'); + await expect(adminPage.getByRole('table', { name: 'Events' })).toBeVisible(); + const refreshed = adminPage.waitForResponse('**/api/v1/admin/events/browse'); + await adminPage.getByRole('button', { name: 'Refresh' }).click(); + expect((await refreshed).ok()).toBe(true); +}); diff --git a/frontend/e2e/admin-executions.spec.ts b/frontend/e2e/admin-executions.spec.ts index 9d7d8619..58c77c8f 100644 --- a/frontend/e2e/admin-executions.spec.ts +++ b/frontend/e2e/admin-executions.spec.ts @@ -1,62 +1,33 @@ -import { test, expect, describeAdminCommonTests, describeAdminAccessControl, expectTableOrEmptyState, expectTableColumn } from './fixtures'; - -const PATH = '/admin/executions' as const; -const EMPTY_PATTERN = /No executions found/i; - -test.describe('Admin Executions', () => { - describeAdminCommonTests(test, PATH); - - test('shows refresh button', async ({ adminPage }) => { - await adminPage.goto(PATH); - await expect(adminPage.getByRole('button', { name: /Refresh/i })).toBeVisible(); - }); - - test('shows filter controls', async ({ adminPage }) => { - await adminPage.goto(PATH); - await expect(adminPage.getByLabel('Status', { exact: true })).toBeVisible(); - await expect(adminPage.getByLabel('Priority', { exact: true })).toBeVisible(); - await expect(adminPage.getByLabel('User Id', { exact: true })).toBeVisible(); - }); - - test.describe('Filters', () => { - test('status filter dropdown has expected options', async ({ adminPage }) => { - await adminPage.goto(PATH); - const statusSelect = adminPage.getByLabel('Status', { exact: true }); - await expect(statusSelect).toBeVisible(); - await expect(statusSelect.locator('option')).toHaveCount(9); // All + 8 statuses - }); - - test('priority filter dropdown has expected options', async ({ adminPage }) => { - await adminPage.goto(PATH); - const prioritySelect = adminPage.getByLabel('Priority', { exact: true }); - await expect(prioritySelect).toBeVisible(); - await expect(prioritySelect.locator('option')).toHaveCount(6); // All + 5 priorities - }); - }); - - test.describe('Table', () => { - test('shows executions table or empty state', async ({ adminPage }) => { - await adminPage.goto(PATH); - await expectTableOrEmptyState(adminPage, EMPTY_PATTERN); - }); - - test('shows Execution Id column when data exists', async ({ adminPage }) => { - await adminPage.goto(PATH); - await expectTableColumn(adminPage, 'Execution Id', EMPTY_PATTERN); - }); - }); +import { expect, test } from './fixtures'; +import { ExecutionStatus, QueuePriority } from '#lib/api/types.gen.ts'; +import { fieldTitle } from '#lib/formatters.ts'; + +test('the status and priority filters offer every value the API knows', async ({ adminPage }) => { + await adminPage.goto('/admin/executions'); + await expect(adminPage.getByLabel(fieldTitle('status'), { exact: true }).locator('option')).toHaveText([ + 'All', + ...Object.values(ExecutionStatus), + ]); + await expect(adminPage.getByLabel(fieldTitle('priority'), { exact: true }).locator('option')).toHaveText([ + 'All', + ...Object.values(QueuePriority), + ]); +}); - test.describe('Refresh', () => { - test('can manually refresh executions', async ({ adminPage }) => { - await adminPage.goto(PATH); - const [response] = await Promise.all([ - adminPage.waitForResponse(resp => resp.url().includes('/api/v1/admin/executions') && resp.status() === 200), - adminPage.getByRole('button', { name: /Refresh/i }).click(), - ]); - expect(response.ok()).toBe(true); - await expect(adminPage.getByRole('heading', { name: 'Execution Management' })).toBeVisible(); - }); - }); +test("a user's new execution shows in the table", async ({ adminPage, account, execution }) => { + await adminPage.goto('/admin/executions'); + await adminPage.getByLabel(fieldTitle('user_id'), { exact: true }).fill(account.user_id); + const table = adminPage.getByRole('table', { name: 'Executions' }); + await expect(table.getByRole('combobox', { name: `Priority for ${execution.execution_id}` })).toBeVisible(); + for (const key of ['execution_id', 'user_id', 'lang', 'status', 'priority', 'created_at']) { + await expect(table.getByRole('columnheader', { name: fieldTitle(key), exact: true })).toBeVisible(); + } }); -describeAdminAccessControl(test, PATH); +test('refresh fetches the executions again', async ({ adminPage }) => { + await adminPage.goto('/admin/executions'); + await expect(adminPage.getByRole('heading', { name: 'Execution Management' })).toBeVisible(); + const refreshed = adminPage.waitForResponse(/\/api\/v1\/admin\/executions\/\?/); + await adminPage.getByRole('button', { name: 'Refresh' }).click(); + expect((await refreshed).ok()).toBe(true); +}); diff --git a/frontend/e2e/admin-sagas.spec.ts b/frontend/e2e/admin-sagas.spec.ts index 08c09697..26e2f99f 100644 --- a/frontend/e2e/admin-sagas.spec.ts +++ b/frontend/e2e/admin-sagas.spec.ts @@ -1,66 +1,38 @@ -import { test, expect, describeAdminCommonTests, describeAdminAccessControl, expectTableOrEmptyState, expectTableColumn } from './fixtures'; - -const PATH = '/admin/sagas' as const; -const EMPTY_PATTERN = /No sagas found/i; - -test.describe('Admin Sagas', () => { - describeAdminCommonTests(test, PATH); - - test('shows auto-refresh control', async ({ adminPage }) => { - await adminPage.goto(PATH); - await expect(adminPage.getByText(/Auto-refresh/i)).toBeVisible(); - }); - - test.describe('Filtering', () => { - test('shows search input', async ({ adminPage }) => { - await adminPage.goto(PATH); - await expect(adminPage.getByLabel('Search', { exact: true })).toBeVisible(); - }); - - test('shows state filter dropdown', async ({ adminPage }) => { - await adminPage.goto(PATH); - const stateSelect = adminPage.getByLabel('State', { exact: true }); - await expect(stateSelect).toBeVisible(); - await expect(stateSelect.locator('option')).toHaveText(['All', 'created', 'running', 'compensating', 'completed', 'failed', 'timeout', 'cancelled']); - }); - - test('can clear filters', async ({ adminPage }) => { - await adminPage.goto(PATH); - const searchInput = adminPage.getByLabel('Search', { exact: true }); - await searchInput.fill('test-filter'); - await adminPage.getByRole('button', { name: /Clear/i }).click(); - await expect(searchInput).toHaveValue(''); - }); - }); - - test.describe('Table', () => { - test('shows sagas table or empty state', async ({ adminPage }) => { - await adminPage.goto(PATH); - await expectTableOrEmptyState(adminPage, EMPTY_PATTERN); - }); - - test('shows State column when data exists', async ({ adminPage }) => { - await adminPage.goto(PATH); - await expectTableColumn(adminPage, 'State', EMPTY_PATTERN); - }); - }); +import { expect, test } from './fixtures'; +import { SagaState } from '#lib/api/types.gen.ts'; +import { fieldTitle } from '#lib/formatters.ts'; + +test('the state filter offers every saga state the API knows', async ({ adminPage }) => { + await adminPage.goto('/admin/sagas'); + await expect(adminPage.getByLabel(fieldTitle('state'), { exact: true }).locator('option')).toHaveText([ + 'All', + ...Object.values(SagaState), + ]); +}); - test.describe('Auto-Refresh', () => { - test('can toggle auto-refresh', async ({ adminPage }) => { - await adminPage.goto(PATH); - const toggle = adminPage.locator('input[type="checkbox"]').first(); - const initial = await toggle.isChecked(); - await toggle.click(); - expect(await toggle.isChecked()).toBe(!initial); - }); +test('clear empties the search', async ({ adminPage }) => { + await adminPage.goto('/admin/sagas'); + const search = adminPage.getByLabel(fieldTitle('search'), { exact: true }); + await search.fill('anything'); + await adminPage.getByRole('button', { name: 'Clear' }).click(); + await expect(search).toHaveValue(''); +}); - test('can change refresh rate', async ({ adminPage }) => { - await adminPage.goto(PATH); - const rateSelect = adminPage.locator('#refresh-rate'); - await rateSelect.selectOption('10'); - await expect(rateSelect).toHaveValue('10'); - }); - }); +test('the saga of a new execution shows up', async ({ adminPage, execution }) => { + await adminPage.goto('/admin/sagas'); + await adminPage.getByLabel(fieldTitle('execution_id'), { exact: true }).fill(execution.execution_id); + // The worker starts the saga a moment after the API accepts the execution; the page polls every 5 seconds + await expect(adminPage.getByRole('table', { name: 'Sagas' }).getByRole('row')).toHaveCount(2, { timeout: 15_000 }); + for (const key of ['saga_name', 'state', 'created_at']) { + await expect(adminPage.getByRole('columnheader', { name: fieldTitle(key), exact: true })).toBeVisible(); + } }); -describeAdminAccessControl(test, PATH); +test('auto-refresh turns off and its rate changes', async ({ adminPage }) => { + await adminPage.goto('/admin/sagas'); + const rate = adminPage.getByLabel('Refresh every'); + await rate.selectOption('10'); + await expect(rate).toHaveValue('10'); + await adminPage.getByRole('checkbox', { name: 'Auto-refresh' }).uncheck(); + await expect(rate).toBeHidden(); +}); diff --git a/frontend/e2e/admin-settings.spec.ts b/frontend/e2e/admin-settings.spec.ts index 7e3739e1..55187348 100644 --- a/frontend/e2e/admin-settings.spec.ts +++ b/frontend/e2e/admin-settings.spec.ts @@ -1,77 +1,26 @@ -import { test, expect, describeAdminCommonTests, describeAdminAccessControl, expectToastVisible } from './fixtures'; - -const PATH = '/admin/settings' as const; - -const SETTINGS_SECTIONS = [ - { - name: 'Execution Limits', - labels: ['Max Timeout Seconds', 'Memory Limit', 'Cpu Limit', 'Max Concurrent Executions', 'Max Executions Per User'], - }, - { - name: 'Security Settings', - labels: ['Password Min Length', 'Session Timeout Minutes', 'Max Login Attempts', 'Lockout Duration Minutes'], - }, - { name: 'Monitoring Settings', labels: ['Metrics Retention Days', 'Log Level', 'Enable Tracing', 'Sampling Rate'] }, -] as const; - -test.describe('Admin Settings', () => { - describeAdminCommonTests(test, PATH); - - test('shows configuration card', async ({ adminPage }) => { - await adminPage.goto(PATH); - await expect(adminPage.getByText('Configuration')).toBeVisible(); - }); - - for (const section of SETTINGS_SECTIONS) { - test(`shows ${section.name} section with all inputs`, async ({ adminPage }) => { - await adminPage.goto(PATH); - await expect(adminPage.getByText(section.name)).toBeVisible(); - for (const label of section.labels) { - await expect(adminPage.getByLabel(label, { exact: true })).toBeVisible(); - } - }); - } - - test('can modify max timeout value', async ({ adminPage }) => { - await adminPage.goto(PATH); - const input = adminPage.getByLabel('Max Timeout Seconds'); - const current = await input.inputValue(); - await input.fill('120'); - await expect(input).toHaveValue('120'); - await input.fill(current); - }); - - test('log level select has correct options', async ({ adminPage }) => { - await adminPage.goto(PATH); - const select = adminPage.getByLabel('Log Level'); - await expect(select.locator('option').first()).toBeAttached(); - const options = await select.locator('option').allTextContents(); - expect(options).toContain('DEBUG'); - expect(options).toContain('INFO'); - expect(options).toContain('WARNING'); - expect(options).toContain('ERROR'); - }); - - test('can change log level', async ({ adminPage }) => { - await adminPage.goto(PATH); - const select = adminPage.getByLabel('Log Level'); - const original = await select.inputValue(); - await select.selectOption('DEBUG'); - await expect(select).toHaveValue('DEBUG'); - await select.selectOption(original); - }); - - test('shows save and reset buttons', async ({ adminPage }) => { - await adminPage.goto(PATH); - await expect(adminPage.getByRole('button', { name: 'Save Settings' })).toBeVisible(); +import { expect, test } from './fixtures'; +import { LogLevel } from '#lib/api/types.gen.ts'; +import { zSystemSettingsSchemaInput } from '#lib/api/zod.gen.ts'; +import { fieldTitle } from '#lib/formatters.ts'; + +test('the form has a labelled control for every system setting', async ({ adminPage }) => { + await adminPage.goto('/admin/settings'); + for (const section of ['Execution Limits', 'Security Settings', 'Monitoring Settings']) { + await expect(adminPage.getByRole('heading', { name: section })).toBeVisible(); + } + for (const key of Object.keys(zSystemSettingsSchemaInput.shape)) { + await expect(adminPage.getByLabel(fieldTitle(key), { exact: true })).toBeVisible(); + } await expect(adminPage.getByRole('button', { name: 'Reset to Defaults' })).toBeVisible(); - }); +}); - test('can save settings', async ({ adminPage }) => { - await adminPage.goto(PATH); - await adminPage.getByRole('button', { name: 'Save Settings' }).click(); - await expectToastVisible(adminPage); - }); +test('the log level offers every level the API knows', async ({ adminPage }) => { + await adminPage.goto('/admin/settings'); + await expect(adminPage.getByLabel(fieldTitle('log_level')).locator('option')).toHaveText(Object.values(LogLevel)); }); -describeAdminAccessControl(test, PATH); +test('saving the loaded settings confirms it', async ({ adminPage }) => { + await adminPage.goto('/admin/settings'); + await adminPage.getByRole('button', { name: 'Save Settings' }).click(); + await expect(adminPage.getByText('Settings saved successfully')).toBeVisible(); +}); diff --git a/frontend/e2e/admin-users.spec.ts b/frontend/e2e/admin-users.spec.ts index 32a858dd..b236e256 100644 --- a/frontend/e2e/admin-users.spec.ts +++ b/frontend/e2e/admin-users.spec.ts @@ -1,103 +1,59 @@ -import { test, expect, describeAdminCommonTests, describeAdminAccessControl, TIMEOUTS } from './fixtures'; - -const PATH = '/admin/users' as const; - -// Helper to navigate and wait for users data to load -async function gotoAndWaitForUsers(adminPage: import('@playwright/test').Page) { - await adminPage.goto(PATH); - // Wait for table rows to appear (seeded users exist), not "Users (0)" empty state - await adminPage.locator('table tbody tr').first().waitFor({ timeout: TIMEOUTS.adminTable }); -} - -test.describe('Admin Users', () => { - // Increase timeout for tests that wait for API data to load - test.describe.configure({ timeout: 20000 }); - - describeAdminCommonTests(test, PATH); - - test('shows create user and refresh buttons', async ({ adminPage }) => { - await adminPage.goto(PATH); - await expect(adminPage.getByRole('button', { name: /Create User/i }).first()).toBeVisible(); - await expect(adminPage.getByRole('button', { name: /Refresh/i })).toBeVisible(); - }); - - test('shows users table with correct columns', async ({ adminPage }) => { - await gotoAndWaitForUsers(adminPage); - await expect(adminPage.locator('table').first()).toBeVisible(); - await expect(adminPage.getByRole('columnheader', { name: 'Username' })).toBeVisible(); - await expect(adminPage.getByRole('columnheader', { name: 'Email' })).toBeVisible(); - await expect(adminPage.getByRole('columnheader', { name: 'Role' })).toBeVisible(); - await expect(adminPage.getByRole('columnheader', { name: 'Created At' })).toBeVisible(); - await expect(adminPage.getByRole('columnheader', { name: 'Is Active' })).toBeVisible(); - }); - - test('displays seeded users in table', async ({ adminPage }) => { - await gotoAndWaitForUsers(adminPage); - await expect(adminPage.locator('text=user').first()).toBeVisible(); - }); - - test('can search for users', async ({ adminPage }) => { - await gotoAndWaitForUsers(adminPage); - const searchInput = adminPage.getByLabel('Search', { exact: true }); - await searchInput.fill('admin'); - await expect(adminPage.locator('td, [class*="card"]').filter({ hasText: 'admin' }).first()).toBeVisible(); - }); - - test.describe('Create Modal', () => { - test('can open create user modal', async ({ adminPage }) => { - await adminPage.goto(PATH); - await adminPage.getByRole('button', { name: /Create User/i }).first().click(); - await expect(adminPage.getByRole('heading', { name: 'Create New User' })).toBeVisible(); - }); - - test('create modal shows all form fields', async ({ adminPage }) => { - await adminPage.goto(PATH); - await adminPage.getByRole('button', { name: /Create User/i }).first().click(); - await expect(adminPage.locator('#user-form-username')).toBeVisible(); - await expect(adminPage.locator('#user-form-email')).toBeVisible(); - await expect(adminPage.locator('#user-form-password')).toBeVisible(); - await expect(adminPage.locator('#user-form-role')).toBeVisible(); - }); - - test('can close create modal with cancel button', async ({ adminPage }) => { - await adminPage.goto(PATH); - await adminPage.getByRole('button', { name: /Create User/i }).first().click(); - await expect(adminPage.getByRole('heading', { name: 'Create New User' })).toBeVisible(); - await adminPage.getByRole('button', { name: 'Cancel' }).click(); - await expect(adminPage.getByRole('heading', { name: 'Create New User' })).not.toBeVisible(); - }); +import { randomUUID } from 'node:crypto'; +import { expect, test } from './fixtures'; +import { UserRole } from '#lib/api/types.gen.ts'; +import { fieldTitle } from '#lib/formatters.ts'; + +test('the users page shows the create and refresh buttons', async ({ adminPage }) => { + await adminPage.goto('/admin/users'); + await expect(adminPage.getByRole('button', { name: 'Create User' })).toBeVisible(); + await expect(adminPage.getByRole('button', { name: 'Refresh' })).toBeVisible(); +}); - test('can fill and submit create user form', async ({ adminPage }) => { - await adminPage.goto(PATH); - await adminPage.getByRole('button', { name: /Create User/i }).first().click(); - const uniqueUsername = `testuser_${Date.now()}`; - await adminPage.locator('#user-form-username').fill(uniqueUsername); - await adminPage.locator('#user-form-email').fill(`${uniqueUsername}@example.com`); - await adminPage.locator('#user-form-password').fill('TestPassword123!'); - await adminPage.getByLabel('Create New User').getByRole('button', { name: 'Create User' }).click(); - await expect(adminPage.getByRole('heading', { name: 'Create New User' })).not.toBeVisible({ timeout: 10000 }); - }); - }); +test('a search finds an account and the table shows its columns', async ({ adminPage, account }) => { + await adminPage.goto('/admin/users'); + await adminPage.getByLabel(fieldTitle('search'), { exact: true }).fill(account.username); + const row = adminPage.getByRole('row').filter({ hasText: account.username }); + await expect(row).toBeVisible(); + await expect(row).toContainText(UserRole.USER); + for (const key of ['username', 'email', 'role', 'created_at', 'is_active']) { + await expect(adminPage.getByRole('columnheader', { name: fieldTitle(key), exact: true })).toBeVisible(); + } +}); - test.describe('Edit', () => { - test('can open edit modal for existing user', async ({ adminPage }) => { - await gotoAndWaitForUsers(adminPage); - const editButton = adminPage.locator('table tbody tr').first().locator('button[title="Edit User"]'); - await expect(editButton).toBeVisible(); - await editButton.click(); - await expect(adminPage.getByRole('heading', { name: 'Edit User' })).toBeVisible(); - }); +test('the create dialog shows the account fields and cancels', async ({ adminPage }) => { + await adminPage.goto('/admin/users'); + await adminPage.getByRole('button', { name: 'Create User' }).click(); + const dialog = adminPage.getByRole('dialog', { name: 'Create New User' }); + for (const key of ['username', 'email', 'password', 'role']) { + await expect(dialog.getByLabel(fieldTitle(key))).toBeVisible(); + } + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await expect(dialog).toBeHidden(); +}); - test('edit modal pre-fills user data', async ({ adminPage }) => { - await gotoAndWaitForUsers(adminPage); - const editButton = adminPage.locator('table tbody tr').first().locator('button[title="Edit User"]'); - await expect(editButton).toBeVisible(); - await editButton.click(); - await expect(adminPage.getByRole('heading', { name: 'Edit User' })).toBeVisible(); - const value = await adminPage.locator('#user-form-username').inputValue(); - expect(value.length).toBeGreaterThan(0); - }); - }); +test('a created user shows up in the table', async ({ adminPage }) => { + const username = `e2e-${randomUUID()}`; + await adminPage.goto('/admin/users'); + await adminPage.getByRole('button', { name: 'Create User' }).click(); + const dialog = adminPage.getByRole('dialog', { name: 'Create New User' }); + await dialog.getByLabel(fieldTitle('username')).fill(username); + await dialog.getByLabel(fieldTitle('email')).fill(`${username}@example.com`); + await dialog.getByLabel(fieldTitle('password')).fill(randomUUID()); + await dialog.getByRole('button', { name: 'Create User' }).click(); + await expect(dialog).toBeHidden(); + await adminPage.getByLabel(fieldTitle('search'), { exact: true }).fill(username); + await expect(adminPage.getByRole('row').filter({ hasText: username })).toBeVisible(); }); -describeAdminAccessControl(test, PATH); +test('the edit dialog opens with the account filled in', async ({ adminPage, account }) => { + await adminPage.goto('/admin/users'); + await adminPage.getByLabel(fieldTitle('search'), { exact: true }).fill(account.username); + await adminPage + .getByRole('row') + .filter({ hasText: account.username }) + .getByRole('button', { name: 'Edit User' }) + .click(); + const dialog = adminPage.getByRole('dialog', { name: 'Edit User' }); + await expect(dialog.getByLabel(fieldTitle('username'))).toHaveValue(account.username); + await expect(dialog.getByLabel(fieldTitle('email'))).toHaveValue(account.email); +}); diff --git a/frontend/e2e/admin.spec.ts b/frontend/e2e/admin.spec.ts new file mode 100644 index 00000000..4b3225b4 --- /dev/null +++ b/frontend/e2e/admin.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from './fixtures'; +import { ADMIN_ROUTES } from '#lib/admin/constants.ts'; + +for (const route of ADMIN_ROUTES) { + test(`${route.path} shows its heading and marks its link in the admin sidebar`, async ({ adminPage }) => { + await adminPage.goto(route.path); + await expect(adminPage.getByRole('heading', { name: route.pageHeading, level: 1 })).toBeVisible(); + await expect(adminPage.getByRole('heading', { name: 'Admin Panel' })).toBeVisible(); + const sidebar = adminPage.getByRole('navigation', { name: 'Admin' }); + for (const link of ADMIN_ROUTES) { + await expect(sidebar.getByRole('link', { name: link.sidebarLabel, exact: true })).toBeVisible(); + } + await expect(sidebar.getByRole('link', { name: route.sidebarLabel, exact: true })).toHaveAttribute( + 'aria-current', + 'page', + ); + }); +} diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts index cc4033e6..f29a1ab3 100644 --- a/frontend/e2e/auth.spec.ts +++ b/frontend/e2e/auth.spec.ts @@ -1,96 +1,72 @@ -import { test, expect, clearSession, loginAsUser, TEST_USERS } from './fixtures'; +import { randomUUID } from 'node:crypto'; +import { expect, holdRequest, test } from './fixtures'; +import { fieldTitle } from '#lib/formatters.ts'; -const PATH = '/login'; - -async function navigateToLogin(page: import('@playwright/test').Page): Promise { - await clearSession(page); - await page.goto(PATH); -} - -async function fillLoginForm(page: import('@playwright/test').Page, username: string, password: string): Promise { - await page.locator('#username').fill(username); - await page.locator('#password').fill(password); - await page.locator('button[type="submit"]').click(); -} - -test.describe('Authentication', () => { - test.beforeEach(async ({ page }) => { - await navigateToLogin(page); - }); - - test('shows login page with form elements', async ({ page }) => { +test('the login page shows its form', async ({ page }) => { + await page.goto('/login'); await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); - await expect(page.locator('#username')).toBeVisible(); - await expect(page.locator('#password')).toBeVisible(); - await expect(page.locator('button[type="submit"]')).toBeVisible(); - }); + await expect(page.getByLabel(fieldTitle('username'))).toBeVisible(); + await expect(page.getByLabel(fieldTitle('password'))).toBeVisible(); + await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible(); +}); - test('prevents submission and shows validation for empty form', async ({ page }) => { - await page.locator('button[type="submit"]').click(); +test('an empty login form marks its fields as required', async ({ page }) => { + await page.goto('/login'); + await page.getByRole('button', { name: 'Sign in' }).click(); await expect(page).toHaveURL(/\/login/); - const usernameInput = page.locator('#username'); - await expect(usernameInput).toBeFocused(); - const isInvalid = await usernameInput.evaluate((el: HTMLInputElement) => !el.validity.valid); - expect(isInvalid).toBe(true); - const validationMessage = await usernameInput.evaluate((el: HTMLInputElement) => el.validationMessage); - expect(validationMessage.length).toBeGreaterThan(0); - }); + await expect(page.getByLabel(fieldTitle('username'))).toHaveAccessibleDescription('Required'); + await expect(page.getByLabel(fieldTitle('password'))).toHaveAccessibleDescription('Required'); +}); - test('shows error with invalid credentials', async ({ page }) => { - await fillLoginForm(page, 'invaliduser', 'wrongpassword'); +test('unknown credentials show the error above the form', async ({ page }) => { + await page.goto('/login'); + await page.getByLabel(fieldTitle('username')).fill(`e2e-${randomUUID()}`); + await page.getByLabel(fieldTitle('password')).fill(randomUUID()); + await page.getByRole('button', { name: 'Sign in' }).click(); await expect(page.getByTestId('error-message')).toBeVisible(); - }); + await expect(page).toHaveURL(/\/login/); +}); - test('redirects to editor on successful login', async ({ page }) => { - await fillLoginForm(page, TEST_USERS.user.username, TEST_USERS.user.password); +test('signing in opens the editor', async ({ page, account }) => { + await page.goto('/login'); + await page.getByLabel(fieldTitle('username')).fill(account.username); + await page.getByLabel(fieldTitle('password')).fill(account.password); + await page.getByRole('button', { name: 'Sign in' }).click(); + await expect(page.getByText('Login successful! Welcome back.')).toBeVisible(); await expect(page.getByRole('heading', { name: 'Code Editor' })).toBeVisible(); await expect(page).toHaveURL(/\/editor/); - }); - - test('shows loading state during login', async ({ page }) => { - await page.locator('#username').fill(TEST_USERS.user.username); - await page.locator('#password').fill(TEST_USERS.user.password); - const submitButton = page.locator('button[type="submit"]'); - await submitButton.click(); - await expect(submitButton).toContainText(/Logging in/); - }); +}); - test('redirects unauthenticated users from protected routes', async ({ page }) => { - await page.goto('/editor'); - await expect(page).toHaveURL(/\/login/); - }); +test('the sign in button shows progress while the request is pending', async ({ page, account }) => { + await page.route('**/api/v1/auth/login', holdRequest); + await page.goto('/login'); + await page.getByLabel(fieldTitle('username')).fill(account.username); + await page.getByLabel(fieldTitle('password')).fill(account.password); + await page.getByRole('button', { name: 'Sign in' }).click(); + await expect(page.getByRole('button', { name: 'Logging in...' })).toBeDisabled(); +}); - test('preserves redirect path after login', async ({ page }) => { +test('signing in returns to the page that asked for it', async ({ page, account }) => { await page.goto('/settings'); - await expect(page).toHaveURL(/\/login/); - await fillLoginForm(page, TEST_USERS.user.username, TEST_USERS.user.password); + await expect(page).toHaveURL(/\/login\?redirect=%2Fsettings/); + await page.getByLabel(fieldTitle('username')).fill(account.username); + await page.getByLabel(fieldTitle('password')).fill(account.password); + await page.getByRole('button', { name: 'Sign in' }).click(); await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible(); - await expect(page).toHaveURL(/\/settings/); - }); - - test('has link to registration page', async ({ page }) => { - const registerLink = page.getByRole('link', { name: 'create a new account' }); - await expect(registerLink).toBeVisible(); - }); + await expect(page).toHaveURL(/\/settings$/); +}); - test('can navigate to registration page', async ({ page }) => { +test('the login page links to registration', async ({ page }) => { + await page.goto('/login'); await page.getByRole('link', { name: 'create a new account' }).click(); await expect(page).toHaveURL(/\/register/); - }); }); -test.describe('Logout', () => { - test.beforeEach(async ({ page }) => { - await loginAsUser(page); - }); - - test('can logout from authenticated state', async ({ page }) => { - const userMenuButton = page.getByRole('button', { name: 'User menu' }); - await expect(userMenuButton).toBeVisible(); - await userMenuButton.click(); - const logoutButton = page.getByRole('menuitem', { name: 'Logout' }); - await expect(logoutButton).toBeVisible(); - await logoutButton.click(); - await expect(page).toHaveURL(/\/login/); - }); +test('logging out returns to the login page', async ({ userPage }) => { + await userPage.goto('/editor'); + await userPage.getByRole('button', { name: 'User menu' }).click(); + await userPage.getByRole('menuitem', { name: 'Logout' }).click(); + await expect(userPage).toHaveURL(/\/login/); + await userPage.goto('/editor'); + await expect(userPage).toHaveURL(/\/login/); }); diff --git a/frontend/e2e/editor.spec.ts b/frontend/e2e/editor.spec.ts index 06403ac6..658dff28 100644 --- a/frontend/e2e/editor.spec.ts +++ b/frontend/e2e/editor.spec.ts @@ -1,188 +1,143 @@ -import { - test, expect, runExampleAndExecute, expectToastVisible, describeAuthRequired, - loadExampleScript, openScriptOptions, saveScriptAs, expandSavedScripts, -} from './fixtures'; - -const PATH = '/editor'; - -test.describe('Editor Page', () => { - test('displays editor page with all main elements', async ({ userPage }) => { - await userPage.goto(PATH); +import { randomUUID } from 'node:crypto'; +import type { ConsoleMessage } from '@playwright/test'; +import { acceptDialog, expect, holdRequest, test } from './fixtures'; +import { ExecutionStatus, type ResourceLimits } from '#lib/api/types.gen.ts'; +import { fieldTitle } from '#lib/formatters.ts'; + +/** Whether a console message reports something the Content Security Policy blocked */ +function isCspViolation(message: ConsoleMessage): boolean { + return message.text().includes('Content Security Policy'); +} + +test('the editor shows the code area, the output panel and the run button', async ({ userPage }) => { + await userPage.goto('/editor'); await expect(userPage.getByRole('heading', { name: 'Code Editor' })).toBeVisible(); await expect(userPage.locator('.cm-editor')).toBeVisible(); - await expect(userPage.getByText('Execution Output')).toBeVisible(); - await expect(userPage.getByRole('button', { name: /Run Script/i })).toBeVisible(); - }); + await expect(userPage.getByRole('heading', { name: 'Execution Output' })).toBeVisible(); + await expect(userPage.getByRole('button', { name: 'Run Script' })).toBeEnabled(); +}); - test('loads CodeMirror styles under the CSP', async ({ userPage }) => { - const violations: string[] = []; - userPage.on('console', (m) => { - if (m.text().includes('Content Security Policy')) violations.push(m.text()); - }); - await userPage.goto(PATH); +test('CodeMirror styles load under the CSP', async ({ userPage }) => { + await userPage.goto('/editor'); await expect(userPage.locator('.cm-gutters')).toHaveCSS('display', 'flex'); - expect(violations).toEqual([]); - }); - - test('shows language selector with available languages', async ({ userPage }) => { - await userPage.goto(PATH); - const languageButton = userPage.getByRole('button', { name: 'Select language and version' }); - await expect(languageButton).toBeVisible(); - await languageButton.click(); - await expect(userPage.getByRole('menu', { name: 'Select language and version' })).toBeVisible(); - }); - - test('can select different language', async ({ userPage }) => { - await userPage.goto(PATH); - const languageButton = userPage.getByRole('button', { name: 'Select language and version' }); - await languageButton.click(); - const pythonButton = userPage.getByRole('menuitem', { name: /python/i }); - await expect(pythonButton).toBeVisible(); - await pythonButton.hover(); - const versionMenu = userPage.getByRole('menu', { name: /python versions/i }); - await expect(versionMenu).toBeVisible(); - const versionOption = versionMenu.getByRole('menuitemradio').first(); - await versionOption.click({ force: true }); - await expect(languageButton).toContainText(/python/i); - }); - - test('shows file actions when panel opened', async ({ userPage }) => { - await userPage.goto(PATH); - await openScriptOptions(userPage); - await expect(userPage.getByText('File Actions')).toBeVisible(); - await expect(userPage.getByRole('button', { name: /New/i })).toBeVisible(); - await expect(userPage.getByRole('button', { name: /Upload/i })).toBeVisible(); - await expect(userPage.locator('button[title="Save current script"]')).toBeVisible(); - await expect(userPage.getByRole('button', { name: /Export/i })).toBeVisible(); - }); - - test('can load example script', async ({ userPage }) => { - await userPage.goto(PATH); - await loadExampleScript(userPage); - const content = await userPage.locator('.cm-content').textContent(); - expect(content).toBeTruthy(); - expect(content!.length).toBeGreaterThan(0); - }); - - test('can input script name', async ({ userPage }) => { - await userPage.goto(PATH); - const scriptNameInput = userPage.locator('#scriptNameInput'); - await expect(scriptNameInput).toBeVisible(); - await scriptNameInput.fill(''); - await scriptNameInput.fill('My Test Script'); - await expect(scriptNameInput).toHaveValue('My Test Script'); - }); + expect((await userPage.consoleMessages()).filter(isCspViolation)).toEqual([]); }); -test.describe('Editor Execution', () => { - // K8s pod execution can take 15-20s in CI (pod creation + execution + result processing) - test.describe.configure({ timeout: 30000 }); - - test('can execute simple python script', async ({ userPage }) => { - await userPage.goto(PATH); - const result = await runExampleAndExecute(userPage); - expect(result.status).toBe('completed'); - expect(result.exit_code).toBe(0); - expect(result.stdout ?? '').toContain('Hello from a Python'); - }); - - test('shows execution output on successful run', async ({ userPage }) => { - await userPage.goto(PATH); - const result = await runExampleAndExecute(userPage); - await expect(userPage.getByText('Stdout', { exact: true }).first()).toBeVisible({ timeout: 5000 }); - await expect(userPage.getByTestId('output-pre').first()).toContainText('Hello from a Python'); - expect(result.stdout ?? '').toContain('Hello from a Python'); - }); - - test('shows resource usage after execution', async ({ userPage }) => { - await userPage.goto(PATH); - const result = await runExampleAndExecute(userPage); - await expect(userPage.getByText('Resource Usage', { exact: true })).toBeVisible({ timeout: 5000 }); - await expect(userPage.getByText('Execution Time Wall Seconds', { exact: true })).toBeVisible(); - await expect(userPage.getByText('Cpu Time Jiffies', { exact: true })).toBeVisible(); - await expect(userPage.getByText('Peak Memory Kb', { exact: true })).toBeVisible(); - expect(result.resource_usage).toBeTruthy(); - }); - - test('run button is disabled during execution', async ({ userPage }) => { - await userPage.goto(PATH); - await loadExampleScript(userPage); - const runButton = userPage.getByRole('button', { name: /Run Script/i }); - await runButton.click(); - const executingButton = userPage.getByRole('button', { name: /Executing/i }); - await expect(executingButton).toBeVisible({ timeout: 5000 }); - await expect(executingButton).toBeDisabled(); - }); +test('the language menu switches to another runtime', async ({ userPage, api }) => { + const limits = (await (await api.get('/api/v1/k8s-limits')).json()) as ResourceLimits; + const [lang, info] = Object.entries(limits.supported_runtimes).at(-1)!; + const version = info.versions[0]!; + await userPage.goto('/editor'); + const trigger = userPage.getByRole('button', { name: 'Select language and version' }); + await trigger.click(); + await userPage.getByRole('menuitem', { name: lang }).hover(); + await userPage + .getByRole('menu', { name: `${lang} versions` }) + .getByRole('menuitemradio', { name: version }) + .click(); + await expect(trigger).toHaveText(`${lang} ${version}`); }); -test.describe('Editor Script Management', () => { - test('can save script when authenticated', async ({ userPage }) => { - await userPage.goto(PATH); - await saveScriptAs(userPage, `Test Script ${Date.now()}`); - }); - - test('can create new script', async ({ userPage }) => { - await userPage.goto(PATH); - await loadExampleScript(userPage); - await openScriptOptions(userPage); - await userPage.getByRole('button', { name: /New/i }).click(); - await expect(userPage.locator('#scriptNameInput')).toHaveValue(''); - }); - - test('shows saved scripts section when authenticated', async ({ userPage }) => { - await userPage.goto(PATH); - await openScriptOptions(userPage); +test('the options panel shows the file actions and the saved scripts', async ({ userPage }) => { + await userPage.goto('/editor'); + await userPage.getByRole('button', { name: 'Toggle Script Options' }).click(); + await expect(userPage.getByText('File Actions')).toBeVisible(); + for (const action of ['New', 'Upload', 'Save', 'Export']) { + await expect(userPage.getByRole('button', { name: action, exact: true })).toBeVisible(); + } await expect(userPage.getByRole('heading', { name: 'Saved Scripts' })).toBeVisible(); - }); - - test('can load a previously saved script', async ({ userPage }) => { - await userPage.goto(PATH); - const scriptName = `Load Test ${Date.now()}`; - await saveScriptAs(userPage, scriptName); - - // Create new script to clear state - await userPage.getByRole('button', { name: /New/i }).click(); - await expect(userPage.locator('#scriptNameInput')).toHaveValue(''); +}); - // Expand the saved scripts list to find the saved script - await expandSavedScripts(userPage); +test('the example button loads a script', async ({ userPage }) => { + await userPage.goto('/editor'); + await userPage.getByRole('button', { name: 'Example', exact: true }).click(); + await expect(userPage.getByText(/^Loaded example script for /)).toBeVisible(); + await expect(userPage.locator('.cm-content')).not.toBeEmpty(); +}); - const savedScript = userPage.getByText(scriptName, { exact: true }).first(); - await expect(savedScript).toBeVisible({ timeout: 3000 }); - await savedScript.click(); - await expectToastVisible(userPage); - await expect(userPage.locator('#scriptNameInput')).toHaveValue(scriptName); - }); +test('running a script shows its output, status and resource usage', async ({ userPage }) => { + test.slow(); + const marker = randomUUID(); + await userPage.goto('/editor'); + await userPage.locator('.cm-content').fill(`print("${marker}")`); + await userPage.getByRole('button', { name: 'Run Script' }).click(); + await expect(userPage.getByTestId('output-pre')).toHaveText(marker, { timeout: 20_000 }); + await expect( + userPage.getByText(new RegExp(`^${fieldTitle('status')}\\s*${ExecutionStatus.COMPLETED}$`)), + ).toBeVisible(); + for (const key of ['resource_usage', 'execution_time_wall_seconds', 'cpu_time_jiffies', 'peak_memory_kb']) { + await expect(userPage.getByText(fieldTitle(key), { exact: true })).toBeVisible(); + } + await expect(userPage.getByRole('button', { name: 'Run Script' })).toBeEnabled(); +}); - test('can delete a saved script', async ({ userPage }) => { - await userPage.goto(PATH); - const scriptName = `Delete Test ${Date.now()}`; - await saveScriptAs(userPage, scriptName); +test('the run button is disabled while the execution starts', async ({ userPage }) => { + await userPage.route('**/api/v1/execute', holdRequest); + await userPage.goto('/editor'); + await userPage.locator('.cm-content').fill('print(1)'); + await userPage.getByRole('button', { name: 'Run Script' }).click(); + await expect(userPage.getByRole('button', { name: 'Executing...' })).toBeDisabled(); + await expect(userPage.getByText('Starting...')).toBeVisible(); +}); - // Expand the saved scripts list to find the saved script - await expandSavedScripts(userPage); +test('a script saves under its name', async ({ userPage }) => { + const name = `e2e-${randomUUID()}`; + await userPage.goto('/editor'); + await userPage.locator('.cm-content').fill(`print("${name}")`); + await userPage.getByLabel(fieldTitle('name')).fill(name); + await userPage.getByRole('button', { name: 'Toggle Script Options' }).click(); + await userPage.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(userPage.getByText('Script saved successfully.')).toBeVisible(); +}); - const scriptRow = userPage.getByText(scriptName, { exact: true }).first(); - await expect(scriptRow).toBeVisible({ timeout: 3000 }); - const deleteBtn = userPage.getByRole('button', { name: `Delete ${scriptName}` }); - await expect(deleteBtn).toBeVisible({ timeout: 2000 }); - userPage.on('dialog', dialog => dialog.accept()); - await deleteBtn.click(); - await expectToastVisible(userPage); - }); +test('a new script clears the name', async ({ userPage }) => { + await userPage.goto('/editor'); + await userPage.getByLabel(fieldTitle('name')).fill(`e2e-${randomUUID()}`); + await userPage.getByRole('button', { name: 'Toggle Script Options' }).click(); + await userPage.getByRole('button', { name: 'New', exact: true }).click(); + await expect(userPage.getByText('New script started.')).toBeVisible(); + await expect(userPage.getByLabel(fieldTitle('name'))).toHaveValue(''); +}); - test('can export script as file', async ({ userPage }) => { - await userPage.goto(PATH); - await loadExampleScript(userPage); - await userPage.locator('#scriptNameInput').fill('export-test'); - await openScriptOptions(userPage); +test('a saved script loads back from the list', async ({ userPage }) => { + const name = `e2e-${randomUUID()}`; + await userPage.goto('/editor'); + await userPage.locator('.cm-content').fill(`print("${name}")`); + await userPage.getByLabel(fieldTitle('name')).fill(name); + await userPage.getByRole('button', { name: 'Toggle Script Options' }).click(); + await userPage.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(userPage.getByText('Script saved successfully.')).toBeVisible(); + await userPage.getByRole('button', { name: 'New', exact: true }).click(); + await expect(userPage.getByLabel(fieldTitle('name'))).toHaveValue(''); + await userPage.getByRole('button', { name: 'Show Saved Scripts' }).click(); + await userPage.getByText(name, { exact: true }).click(); + await expect(userPage.getByText(`Loaded script: ${name}`)).toBeVisible(); + await expect(userPage.getByLabel(fieldTitle('name'))).toHaveValue(name); + await expect(userPage.locator('.cm-content')).toHaveText(`print("${name}")`); +}); - const [download] = await Promise.all([ - userPage.waitForEvent('download', { timeout: 5000 }), - userPage.getByRole('button', { name: /Export/i }).click(), - ]); - expect(download.suggestedFilename()).toContain('export-test'); - }); +test('a saved script can be deleted from the list', async ({ userPage }) => { + const name = `e2e-${randomUUID()}`; + await userPage.goto('/editor'); + await userPage.locator('.cm-content').fill(`print("${name}")`); + await userPage.getByLabel(fieldTitle('name')).fill(name); + await userPage.getByRole('button', { name: 'Toggle Script Options' }).click(); + await userPage.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(userPage.getByText('Script saved successfully.')).toBeVisible(); + await userPage.getByRole('button', { name: 'Show Saved Scripts' }).click(); + userPage.once('dialog', acceptDialog); + await userPage.getByRole('button', { name: `Delete ${name}` }).click(); + await expect(userPage.getByText('Script deleted successfully.')).toBeVisible(); + await expect(userPage.getByText(name, { exact: true })).toHaveCount(0); }); -describeAuthRequired(test, PATH); +test('export downloads the script under its name', async ({ userPage }) => { + const name = `e2e-${randomUUID()}`; + await userPage.goto('/editor'); + await userPage.locator('.cm-content').fill(`print("${name}")`); + await userPage.getByLabel(fieldTitle('name')).fill(name); + await userPage.getByRole('button', { name: 'Toggle Script Options' }).click(); + const download = userPage.waitForEvent('download'); + await userPage.getByRole('button', { name: 'Export', exact: true }).click(); + expect((await download).suggestedFilename()).toContain(name); +}); diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index 7543aa28..101c6208 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -1,315 +1,130 @@ -import { test as base, expect, type Page, type BrowserContext } from '@playwright/test'; +import { randomUUID } from 'node:crypto'; +import { + test as base, + expect, + type APIRequest, + type APIRequestContext, + type BrowserContextOptions, + type Dialog, + type Page, + type TestInfo, +} from '@playwright/test'; import { attachCoverageReport } from 'monocart-reporter'; -import { ADMIN_ROUTES, type AdminPath } from '../src/lib/admin/constants'; -import type { ExecutionResult } from '../src/lib/api'; - - -export const TEST_USERS = { - user: { username: 'user', password: 'user123' }, - admin: { username: 'admin', password: 'admin123' }, -} as const; - -export const TIMEOUTS = { - toast: 5_000, - navigation: 10_000, - adminTable: 15_000, - executionStart: 5_000, - executionComplete: 45_000, - executionStatus: 10_000, - coverageLoad: 2_000, -} as const; - -// Worker-scoped fixtures: authenticate ONCE per worker, reuse context for all tests -type WorkerFixtures = { - userContext: BrowserContext; - adminContext: BrowserContext; -}; - -type TestFixtures = { - userPage: Page; - adminPage: Page; - page: Page; -}; - -async function _performLogin(page: Page, username: string, password: string): Promise { - await page.goto('/login'); - await page.locator('#username').fill(username); - await page.locator('#password').fill(password); - await page.locator('button[type="submit"]').click(); - await expect(page.getByRole('heading', { name: 'Code Editor' })).toBeVisible({ timeout: TIMEOUTS.navigation }); -} - -export const test = base.extend({ - // Override default page fixture to collect coverage - page: async ({ page }, use, testInfo) => { +import type { + ExecutionRequest, + ExecutionResponse, + LoginResponse, + UserCreate, + UserResponse, +} from '#lib/api/types.gen.ts'; + +/** The admin that seed_users.py creates, with the password it reads from the same variable */ +const ADMIN = { username: 'admin', password: process.env.ADMIN_USER_PASSWORD ?? 'admin123' }; + +export interface Account { + user_id: string; + username: string; + email: string; + password: string; +} + +/** What a browser context or an API client needs to act as a signed-in user */ +interface Session { + storageState: Exclude; + csrfToken: string; +} + +interface WorkerFixtures { + account: Account; + userSession: Session; + adminSession: Session; +} + +interface TestFixtures { + userPage: Page; + adminPage: Page; + api: APIRequestContext; + execution: ExecutionResponse; +} + +/** Registers a new account, so no two workers ever share a user's settings, scripts or notifications */ +async function register(request: APIRequest, baseURL: string | undefined): Promise { + await using api = await request.newContext({ baseURL, ignoreHTTPSErrors: true }); + const username = `e2e-${randomUUID()}`; + const body: UserCreate = { username, email: `${username}@example.com`, password: randomUUID() }; + const response = await api.post('/api/v1/auth/register', { data: body }); + await expect(response).toBeOK(); + const user = (await response.json()) as UserResponse; + return { user_id: user.user_id, username, email: body.email, password: body.password }; +} + +/** Logs in through the API and keeps the cookies it sets */ +async function logIn( + request: APIRequest, + baseURL: string | undefined, + credentials: { username: string; password: string }, +): Promise { + await using api = await request.newContext({ baseURL, ignoreHTTPSErrors: true }); + const response = await api.post('/api/v1/auth/login', { form: credentials }); + await expect(response).toBeOK(); + const { csrf_token } = (await response.json()) as LoginResponse; + return { storageState: await api.storageState(), csrfToken: csrf_token }; +} + +/** Hands the page to the test and attaches the JS coverage it collected for the monocart reporter */ +async function useWithCoverage(page: Page, use: (page: Page) => Promise, testInfo: TestInfo): Promise { await page.coverage.startJSCoverage({ resetOnNavigation: false }); - try { - await use(page); - } finally { - if (!page.isClosed()) { - const coverage = await page.coverage.stopJSCoverage(); - await attachCoverageReport(coverage, testInfo); - } - } - }, - - // Worker-scoped: one login per worker, shared across all tests in that worker - userContext: [async ({ browser }, use) => { - const context = await browser.newContext({ ignoreHTTPSErrors: true }); - const page = await context.newPage(); - await _performLogin(page, TEST_USERS.user.username, TEST_USERS.user.password); - await page.close(); - await use(context); - await context.close(); - }, { scope: 'worker' }], - - adminContext: [async ({ browser }, use) => { - const context = await browser.newContext({ ignoreHTTPSErrors: true }); - const page = await context.newPage(); - await _performLogin(page, TEST_USERS.admin.username, TEST_USERS.admin.password); - await page.close(); - await use(context); - await context.close(); - }, { scope: 'worker' }], - - // Test-scoped: new page per test, but reuses authenticated context - userPage: async ({ userContext }, use, testInfo) => { - const page = await userContext.newPage(); - await page.coverage.startJSCoverage({ resetOnNavigation: false }); - try { - await use(page); - } finally { - if (!page.isClosed()) { - const coverage = await page.coverage.stopJSCoverage(); - await attachCoverageReport(coverage, testInfo); - await page.close(); - } - } - }, - - adminPage: async ({ adminContext }, use, testInfo) => { - const page = await adminContext.newPage(); - await page.coverage.startJSCoverage({ resetOnNavigation: false }); - try { - await use(page); - } finally { - if (!page.isClosed()) { - const coverage = await page.coverage.stopJSCoverage(); - await attachCoverageReport(coverage, testInfo); - await page.close(); - } - } - }, -}); - -// Helper functions using the default page (for tests that don't need pre-auth) -export async function loginAsUser(page: Page): Promise { - await _performLogin(page, TEST_USERS.user.username, TEST_USERS.user.password); -} - -export async function loginAsAdmin(page: Page): Promise { - await _performLogin(page, TEST_USERS.admin.username, TEST_USERS.admin.password); -} - -export async function clearSession(page: Page): Promise { - await page.context().clearCookies(); - const url = page.url(); - // Navigate to root first if on about:blank so we can access storage - if (!url || url === 'about:blank') { - await page.goto('/'); - await page.waitForLoadState('domcontentloaded'); - } - await page.evaluate(() => { - localStorage.clear(); - sessionStorage.clear(); - }); -} - -export function getAdminRoute(path: AdminPath) { - const route = ADMIN_ROUTES.find(r => r.path === path); - if (!route) throw new Error(`Unknown admin path: ${path}`); - return route; -} - -export async function navigateToAdminPage(page: Page, path: AdminPath): Promise { - const route = getAdminRoute(path); - await page.goto(path); - await expect(page.getByRole('heading', { name: route.pageHeading })).toBeVisible(); -} - -export async function expectAdminSidebar(page: Page): Promise { - await expect(page.getByText('Admin Panel')).toBeVisible({ timeout: TIMEOUTS.navigation }); - for (const route of ADMIN_ROUTES) { - await expect(page.getByRole('link', { name: route.sidebarLabel })).toBeVisible(); - } -} - -export async function expectActiveNavLink(page: Page, linkName: string): Promise { - await expect(page.getByRole('link', { name: linkName })).toHaveClass(/bg-primary/); -} - -export async function expectToastVisible(page: Page, timeout = TIMEOUTS.toast): Promise { - // svelte-sonner uses data-sonner-toast attribute - await expect(page.locator('[data-sonner-toast]').first()).toBeVisible({ timeout }); -} - -export async function expectRedirectToLogin(page: Page): Promise { - await expect(page).toHaveURL(/\/login/); -} - -export async function expectRedirectToHome(page: Page): Promise { - await expect(page).toHaveURL('/'); -} - -export async function expectTableOrEmptyState( - page: Page, - emptyTextPattern: RegExp, - timeout = TIMEOUTS.navigation -): Promise { - const tableRow = page.locator('table tbody tr').first(); - const emptyState = page.getByText(emptyTextPattern).first(); - await expect(tableRow.or(emptyState).first()).toBeVisible({ timeout }); - return await tableRow.isVisible().catch(() => false); + await use(page); + await attachCoverageReport(await page.coverage.stopJSCoverage(), testInfo); } -export async function expectTableColumn(page: Page, columnName: string, emptyPattern: RegExp): Promise { - const hasTable = await expectTableOrEmptyState(page, emptyPattern); - if (hasTable) { - await expect(page.getByRole('columnheader', { name: columnName })).toBeVisible(); - } -} - -const TERMINAL_EXEC_STATUSES = new Set(['completed', 'failed', 'timeout', 'cancelled', 'error']); - -async function waitForExecutionResult(page: Page, executionId: string, timeoutMs = TIMEOUTS.executionComplete): Promise { - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - const response = await page.request.get(`/api/v1/executions/${executionId}/result`); - - if (response.status() === 404) { - await page.waitForTimeout(500); - continue; - } +/** A route handler that never answers, so a test can look at the UI while the request is pending */ +export function holdRequest(): void {} - expect(response.ok(), `Unexpected status ${response.status()} for execution result`).toBeTruthy(); - const result = await response.json() as ExecutionResult; - - if (TERMINAL_EXEC_STATUSES.has(result.status)) { - return result; - } - - await page.waitForTimeout(500); - } - - throw new Error(`Execution ${executionId} did not reach terminal state within ${timeoutMs}ms`); +/** Accepts the next confirm() the page shows */ +export function acceptDialog(dialog: Dialog): Promise { + return dialog.accept(); } -export async function runExampleAndExecute(page: Page): Promise { - await page.getByRole('button', { name: 'Example', exact: true }).click(); - await expect(page.locator('.cm-content')).not.toBeEmpty({ timeout: TIMEOUTS.coverageLoad }); - - const executeResponsePromise = page.waitForResponse((response) => - response.request().method() === 'POST' && response.url().includes('/api/v1/execute') - ); - - await page.getByRole('button', { name: /Run Script/i }).click(); - const executeResponse = await executeResponsePromise; - expect(executeResponse.ok(), `Execute request failed with status ${executeResponse.status()}`).toBeTruthy(); - const executeBody = await executeResponse.json() as { execution_id: string; status: string }; - expect(executeBody.execution_id).toBeTruthy(); - - await expect(page.getByRole('button', { name: /Executing/i })).toBeVisible({ timeout: TIMEOUTS.executionStart }); - const success = page.getByText('Status', { exact: true }).first(); - const failure = page.getByText('Execution Failed'); - // K8s pod creation + execution can take 20-30s in CI - await expect(success.or(failure).first()).toBeVisible({ timeout: TIMEOUTS.executionComplete }); - await expect(page.getByText(/Status\s*completed/i).first()).toBeVisible({ timeout: TIMEOUTS.executionStatus }); - - const result = await waitForExecutionResult(page, executeBody.execution_id); - expect(result.status).toBe('completed'); - return result; -} - -export async function loadExampleScript(page: Page): Promise { - await page.getByRole('button', { name: 'Example', exact: true }).click(); - await expect(page.locator('.cm-content')).not.toBeEmpty({ timeout: 3000 }); -} - -export async function openScriptOptions(page: Page): Promise { - await page.getByRole('button', { name: 'Toggle Script Options' }).click(); -} - -export async function saveScriptAs(page: Page, name: string): Promise { - await loadExampleScript(page); - await page.locator('#scriptNameInput').fill(name); - await openScriptOptions(page); - await page.locator('button[title="Save current script"]').click(); - await expectToastVisible(page); -} - -export async function expandSavedScripts(page: Page): Promise { - const toggle = page.getByRole('button', { name: /Show Saved Scripts/i }); - await toggle.click(); -} - -export async function expectAuthRequired(page: Page, path: string): Promise { - await clearSession(page); - await page.goto(path); - await expectRedirectToLogin(page); -} - -export async function navigateToPage(page: Page, path: string, headingName: string, headingLevel: 1 | 2 = 1): Promise { - await page.goto(path); - await expect(page.getByRole('heading', { name: headingName, level: headingLevel })).toBeVisible(); -} - -export function describeAuthRequired(testFn: typeof base, path: string): void { - testFn.describe('Access Control', () => { - testFn('redirects to login when not authenticated', async ({ page }) => { - await expectAuthRequired(page, path); - }); - }); -} - -export function describeAdminAccessControl(testFn: typeof base, path: AdminPath): void { - testFn.describe('Access Control', () => { - testFn('redirects non-admin users to home', async ({ userPage }) => { - const adminCalls: string[] = []; - userPage.on('request', (req) => { - if (req.url().includes('/api/v1/admin/')) adminCalls.push(req.url()); - }); - await userPage.goto(path); - await expectRedirectToHome(userPage); - await expect(userPage.getByText('Admin access required')).toBeVisible(); - expect(adminCalls).toEqual([]); - }); - - testFn('redirects unauthenticated users to login', async ({ page }) => { - await clearSession(page); - await page.goto(path); - await expectRedirectToLogin(page); - }); - }); -} - -export function describeAdminCommonTests(testFn: typeof base, path: AdminPath): void { - const route = getAdminRoute(path); - - testFn('displays page with header', async ({ adminPage }) => { - await adminPage.goto(path); - await expect(adminPage.getByRole('heading', { name: route.pageHeading })).toBeVisible({ timeout: TIMEOUTS.navigation }); - }); - - testFn('shows admin sidebar navigation', async ({ adminPage }) => { - await adminPage.goto(path); - await expectAdminSidebar(adminPage); - }); - - testFn('nav link is active in sidebar', async ({ adminPage }) => { - await adminPage.goto(path); - await expectActiveNavLink(adminPage, route.sidebarLabel); - }); -} +export const test = base.extend({ + account: [ + async ({ playwright }, use, { project }) => use(await register(playwright.request, project.use.baseURL)), + { scope: 'worker' }, + ], + userSession: [ + async ({ playwright, account }, use, { project }) => + use(await logIn(playwright.request, project.use.baseURL, account)), + { scope: 'worker' }, + ], + adminSession: [ + async ({ playwright }, use, { project }) => use(await logIn(playwright.request, project.use.baseURL, ADMIN)), + { scope: 'worker' }, + ], + + // Every page is a fresh context: a guest's `page`, or one that starts with the worker's session cookies + page: async ({ page }, use, testInfo) => useWithCoverage(page, use, testInfo), + userPage: async ({ browser, userSession }, use, testInfo) => { + await using context = await browser.newContext({ storageState: userSession.storageState }); + await useWithCoverage(await context.newPage(), use, testInfo); + }, + adminPage: async ({ browser, adminSession }, use, testInfo) => { + await using context = await browser.newContext({ storageState: adminSession.storageState }); + await useWithCoverage(await context.newPage(), use, testInfo); + }, + + api: async ({ playwright, userSession }, use) => { + await using api = await playwright.request.newContext({ + storageState: userSession.storageState, + extraHTTPHeaders: { 'X-CSRF-Token': userSession.csrfToken }, + }); + await use(api); + }, + execution: async ({ api }, use) => { + const body: ExecutionRequest = { script: `print("${randomUUID()}")` }; + const response = await api.post('/api/v1/execute', { data: body }); + await expect(response).toBeOK(); + await use((await response.json()) as ExecutionResponse); + }, +}); -export { expect, ADMIN_ROUTES, type AdminPath, type Page }; +export { expect }; diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts index eb04d1a9..50910e81 100644 --- a/frontend/e2e/home.spec.ts +++ b/frontend/e2e/home.spec.ts @@ -1,147 +1,63 @@ -import { test, expect, clearSession } from './fixtures'; +import { expect, test } from './fixtures'; -test.describe('Home Page', () => { - test.beforeEach(async ({ page }) => { - await clearSession(page); +test('the home page shows the hero, the call to action and the features', async ({ page }) => { await page.goto('/'); - await page.waitForLoadState('domcontentloaded'); - }); - - test('displays hero section with main heading', async ({ page }) => { await expect(page.getByRole('heading', { level: 1 })).toContainText('Code, Run'); - await expect(page.getByRole('heading', { level: 1 })).toContainText('Integrate'); - }); - - test('shows welcome message with product name', async ({ page }) => { await expect(page.getByText('Welcome to Integr8sCode')).toBeVisible(); - await expect(page.getByText('seamless online execution environment')).toBeVisible(); - }); - - test('shows start coding CTA button', async ({ page }) => { - const ctaButton = page.getByRole('link', { name: 'Start Coding Now' }); - await expect(ctaButton).toBeVisible(); - await expect(ctaButton).toHaveAttribute('href', '/editor'); - }); - - test('displays features section', async ({ page }) => { + await expect(page.getByRole('link', { name: 'Start Coding Now' })).toHaveAttribute('href', '/editor'); await expect(page.getByText('Core Features')).toBeVisible(); - await expect(page.getByText('Everything you need for quick execution')).toBeVisible(); - }); - - test('shows all three feature cards', async ({ page }) => { - await expect(page.getByText('Instant Execution')).toBeVisible(); - await expect(page.getByText('Secure & Efficient')).toBeVisible(); - await expect(page.getByText('Real-time Results')).toBeVisible(); - }); + for (const feature of ['Instant Execution', 'Secure & Efficient', 'Real-time Results']) { + await expect(page.getByText(feature)).toBeVisible(); + } }); -test.describe('Home Page Header', () => { - test.beforeEach(async ({ page }) => { - await clearSession(page); +test('the header offers login, registration and the theme toggle to a guest', async ({ page }) => { await page.goto('/'); - await page.waitForLoadState('domcontentloaded'); - }); - - test('shows header with logo', async ({ page }) => { - await expect(page.locator('header')).toBeVisible(); - await expect(page.locator('header').getByText('Integr8sCode')).toBeVisible(); - }); - - test('logo links to home page', async ({ page }) => { - const logoLink = page.locator('header a').filter({ hasText: 'Integr8sCode' }); - await expect(logoLink).toHaveAttribute('href', '/'); - }); - - test('shows login and register buttons when not authenticated', async ({ page }) => { - await expect(page.locator('header').getByRole('link', { name: 'Login' })).toBeVisible(); - await expect(page.locator('header').getByRole('link', { name: 'Register' })).toBeVisible(); - }); - - test('shows theme toggle button', async ({ page }) => { - await expect(page.locator('header button[title="Toggle theme"]')).toBeVisible(); - }); + const header = page.getByRole('banner'); + await expect(header.getByRole('link', { name: 'Integr8sCode' })).toHaveAttribute('href', '/'); + await expect(header.getByRole('link', { name: 'Login' })).toBeVisible(); + await expect(header.getByRole('link', { name: 'Register' })).toBeVisible(); + await expect(header.getByRole('button', { name: 'Toggle theme' })).toBeVisible(); }); -test.describe('Home Page Navigation', () => { - test.beforeEach(async ({ page }) => { - await clearSession(page); +test('the call to action sends a guest to login', async ({ page }) => { await page.goto('/'); - await page.waitForLoadState('domcontentloaded'); - }); - - test('clicking CTA navigates to login when unauthenticated', async ({ page }) => { - await expect(page.getByRole('link', { name: 'Start Coding Now' })).toBeVisible(); await page.getByRole('link', { name: 'Start Coding Now' }).click(); await expect(page).toHaveURL(/\/login/); - }); - - test('can navigate to login from header', async ({ page }) => { - await page.locator('header').getByRole('link', { name: 'Login' }).click(); - await expect(page).toHaveURL(/\/login/); - }); +}); - test('can navigate to register from header', async ({ page }) => { - await page.locator('header').getByRole('link', { name: 'Register' }).click(); +test('the header links lead to login, registration and back home', async ({ page }) => { + await page.goto('/'); + const header = page.getByRole('banner'); + await header.getByRole('link', { name: 'Register' }).click(); await expect(page).toHaveURL(/\/register/); - }); - - test('clicking logo returns to home', async ({ page }) => { - await expect(page.locator('header')).toBeVisible(); - await page.locator('header').getByRole('link', { name: 'Login' }).click(); + await header.getByRole('link', { name: 'Login' }).click(); await expect(page).toHaveURL(/\/login/); - await expect(page.locator('header a').filter({ hasText: 'Integr8sCode' })).toBeVisible(); - await page.locator('header a').filter({ hasText: 'Integr8sCode' }).click(); + await header.getByRole('link', { name: 'Integr8sCode' }).click(); await expect(page).toHaveURL('/'); - }); }); -test.describe('Home Page Responsive', () => { - test('displays correctly on mobile viewport', async ({ page }) => { +test('the mobile menu opens with the guest links', async ({ page }) => { await page.setViewportSize({ width: 375, height: 667 }); - await clearSession(page); await page.goto('/'); - await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); await expect(page.getByRole('link', { name: 'Start Coding Now' })).toBeVisible(); - }); - - test('shows mobile menu button on small screens', async ({ page }) => { - await page.setViewportSize({ width: 375, height: 667 }); - await clearSession(page); - await page.goto('/'); - const menuButton = page.locator('header button').filter({ has: page.locator('svg') }).last(); - await expect(menuButton).toBeVisible(); - }); - - test('can open mobile menu', async ({ page }) => { - await page.setViewportSize({ width: 375, height: 667 }); - await clearSession(page); - await page.goto('/'); - const menuButton = page.locator('header button').filter({ has: page.locator('svg') }).last(); - await menuButton.click(); - // Mobile menu Login link should be visible after opening menu (assertion waits for animation) - await expect(page.getByRole('link', { name: 'Login' }).first()).toBeVisible({ timeout: 3000 }); - }); + await page.getByRole('button', { name: 'Open menu' }).click(); + const menu = page.getByTestId('mobile-menu'); + await expect(menu.getByRole('link', { name: 'Login' })).toBeVisible(); + await expect(menu.getByRole('link', { name: 'Register' })).toBeVisible(); }); -test.describe('Privacy Page', () => { - test.beforeEach(async ({ page }) => { - await clearSession(page); +test('the privacy page shows its sections', async ({ page }) => { await page.goto('/privacy'); - }); - - test('displays privacy policy heading', async ({ page }) => { await expect(page.getByRole('heading', { name: 'Privacy Policy' })).toBeVisible(); - }); - - test('shows last updated date', async ({ page }) => { await expect(page.getByText('Last updated:')).toBeVisible(); - }); - - test('shows key privacy sections', async ({ page }) => { - await expect(page.getByText("Who's responsible for your data?")).toBeVisible(); - await expect(page.getByText('What information do I collect?')).toBeVisible(); - await expect(page.getByText('Your rights (GDPR stuff)')).toBeVisible(); - await expect(page.getByText('About cookies')).toBeVisible(); - await expect(page.getByText('Get in touch')).toBeVisible(); - }); + for (const section of [ + "Who's responsible for your data?", + 'What information do I collect?', + 'Your rights (GDPR stuff)', + 'About cookies', + 'Get in touch', + ]) { + await expect(page.getByRole('heading', { name: section })).toBeVisible(); + } }); diff --git a/frontend/e2e/notifications.spec.ts b/frontend/e2e/notifications.spec.ts index 5aabd93a..6dcb088b 100644 --- a/frontend/e2e/notifications.spec.ts +++ b/frontend/e2e/notifications.spec.ts @@ -1,117 +1,60 @@ -import { test, expect, describeAuthRequired, expectToastVisible } from './fixtures'; - -const PATH = '/notifications'; -const HEADING = 'Notifications'; - -// Helper to navigate and wait for notifications API response -async function gotoAndWaitForNotifications(page: import('@playwright/test').Page) { - const notificationsResponse = page.waitForResponse( - response => response.url().includes('/api/v1/notifications') && response.status() === 200 - ); - await page.goto(PATH); - await notificationsResponse; -} - -test.describe('Notifications Page', () => { - test('displays notifications page with header', async ({ userPage }) => { - await userPage.goto(PATH); - await expect(userPage.getByRole('heading', { name: HEADING, level: 1 })).toBeVisible(); - }); - - test('shows filter controls', async ({ userPage }) => { - await userPage.goto(PATH); - await expect(userPage.getByLabel('Include Tags')).toBeVisible(); - await expect(userPage.getByLabel('Exclude Tags')).toBeVisible(); - await expect(userPage.getByLabel('Tag Prefix')).toBeVisible(); +import { randomUUID } from 'node:crypto'; +import { expect, test } from './fixtures'; +import { NotificationChannel, NotificationSeverity } from '#lib/api/types.gen.ts'; +import { fieldTitle } from '#lib/formatters.ts'; + +test('the notifications page shows the tag filters', async ({ userPage }) => { + await userPage.goto('/notifications'); + await expect(userPage.getByRole('heading', { name: 'Notifications', level: 1 })).toBeVisible(); + for (const key of ['include_tags', 'exclude_tags', 'tag_prefix']) { + await expect(userPage.getByLabel(fieldTitle(key))).toBeVisible(); + } await expect(userPage.getByRole('button', { name: 'Filter' })).toBeVisible(); - }); - - test('can enter filter values', async ({ userPage }) => { - await userPage.goto(PATH); - const includeTagsInput = userPage.getByLabel('Include Tags'); - await includeTagsInput.fill('execution,completed'); - await expect(includeTagsInput).toHaveValue('execution,completed'); - const excludeTagsInput = userPage.getByLabel('Exclude Tags'); - await excludeTagsInput.fill('external_alert'); - await expect(excludeTagsInput).toHaveValue('external_alert'); - }); +}); - test('can apply filters', async ({ userPage }) => { - await userPage.goto(PATH); - await userPage.getByLabel('Include Tags').fill('test'); +test('a filter that matches nothing shows the empty state', async ({ userPage }) => { + await userPage.goto('/notifications'); + await userPage.getByLabel(fieldTitle('include_tags')).fill(`exec:${randomUUID()}`); await userPage.getByRole('button', { name: 'Filter' }).click(); - await expect(userPage.getByRole('heading', { name: HEADING, level: 1 })).toBeVisible(); - }); - - test('shows empty state or notifications', async ({ userPage }) => { - await gotoAndWaitForNotifications(userPage); - const emptyState = userPage.getByText('No notifications yet'); - const notificationCard = userPage.getByRole('article').first(); - await expect(emptyState.or(notificationCard)).toBeVisible({ timeout: 5000 }); - }); + await expect(userPage.getByText('No notifications yet')).toBeVisible(); }); -// The following tests require at least one notification to be present. -// They are skipped in clean environments where no notifications have been generated. -// To run these tests: execute a script first to generate a completion notification, -// then re-run this suite. -test.describe('Notifications Interaction', () => { - test.skip('notification cards show severity badges when present', async ({ userPage }) => { - await gotoAndWaitForNotifications(userPage); - const notificationCard = userPage.getByRole('article').first(); - await expect(notificationCard).toBeVisible({ timeout: 5000 }); - const severityBadge = notificationCard.locator('span').filter({ hasText: /^(low|medium|high|urgent)$/i }).first(); - const hasBadge = await severityBadge.isVisible().catch(() => false); - if (hasBadge) { - await expect(severityBadge).toContainText(/low|medium|high|urgent/i); - } - }); - - test.skip('notification cards show timestamp when present', async ({ userPage }) => { - await gotoAndWaitForNotifications(userPage); - const notificationCard = userPage.getByRole('article').first(); - await expect(notificationCard).toBeVisible({ timeout: 5000 }); - await expect(notificationCard.getByText(/ago|Just now|\d{1,2}:\d{2}|\d{4}-\d{2}-\d{2}/)).toBeVisible(); - }); +test('a finished execution arrives as an unread notification', async ({ userPage, execution }) => { + test.slow(); + await userPage.goto('/notifications'); + const card = userPage.getByRole('article').filter({ hasText: execution.execution_id }); + await expect(card).toBeVisible({ timeout: 20_000 }); + await expect(card.getByText(NotificationChannel.IN_APP, { exact: true })).toBeVisible(); + await expect(card.getByText(new RegExp(`^(${Object.values(NotificationSeverity).join('|')})$`))).toBeVisible(); + await expect(card.getByText(/just now|\d+m ago/)).toBeVisible(); + await expect(card.getByTitle('Mark as read')).toBeVisible(); }); -test.describe('Notification Actions', () => { - test.skip('can mark notification as read by clicking', async ({ userPage }) => { - await gotoAndWaitForNotifications(userPage); - const notificationCard = userPage.getByRole('article').first(); - await expect(notificationCard).toBeVisible({ timeout: 5000 }); - const isUnread = await notificationCard.evaluate(el => el.classList.toString().includes('bg-info-soft')); - if (isUnread) { - await notificationCard.getByTitle('Mark as read').click(); - await expect(notificationCard.getByText('read', { exact: true })).toBeVisible({ timeout: 3000 }); - } - }); - - test.skip('can delete a notification', async ({ userPage }) => { - await gotoAndWaitForNotifications(userPage); - const notificationCard = userPage.getByRole('article').first(); - await expect(notificationCard).toBeVisible({ timeout: 5000 }); - const deleteBtn = notificationCard.locator('button').filter({ has: userPage.locator('svg') }).first(); - await expect(deleteBtn).toBeVisible(); - await deleteBtn.click(); - await expectToastVisible(userPage); - }); - - test.skip('can mark all as read', async ({ userPage }) => { - await gotoAndWaitForNotifications(userPage); - const markAllBtn = userPage.getByRole('button', { name: /mark all as read/i }); - await expect(markAllBtn).toBeVisible({ timeout: 5000 }); - await markAllBtn.click(); - await expectToastVisible(userPage); - }); +test('a notification can be marked as read and then deleted', async ({ userPage, execution }) => { + test.slow(); + await userPage.goto('/notifications'); + const card = userPage.getByRole('article').filter({ hasText: execution.execution_id }); + await expect(card).toBeVisible({ timeout: 20_000 }); + await card.getByTitle('Mark as read').click(); + await expect(card.getByTitle('Mark as read')).toHaveCount(0); + await card.getByRole('button', { name: 'Delete notification' }).click(); + await expect(userPage.getByText('Notification deleted')).toBeVisible(); + await expect(card).toHaveCount(0); }); -test.describe('Notification Center Header Component', () => { - test('shows notification icon in header when authenticated', async ({ userPage }) => { - await userPage.goto(PATH); - const bellIcon = userPage.locator('header').locator('[aria-label*="notification"], button').filter({ has: userPage.locator('svg') }); - await expect(bellIcon.first()).toBeVisible(); - }); +test('mark all as read clears every unread notification', async ({ userPage, execution }) => { + test.slow(); + await userPage.goto('/notifications'); + const card = userPage.getByRole('article').filter({ hasText: execution.execution_id }); + await expect(card).toBeVisible({ timeout: 20_000 }); + await userPage.getByRole('button', { name: 'Mark all as read' }).click(); + await expect(userPage.getByText('All notifications marked as read')).toBeVisible(); + await expect(card.getByTitle('Mark as read')).toHaveCount(0); }); -describeAuthRequired(test, PATH); +test('the header shows the notification bell to a signed-in user', async ({ userPage }) => { + await userPage.goto('/notifications'); + await expect( + userPage.getByRole('banner').getByRole('button', { name: 'Notifications', exact: true }), + ).toBeVisible(); +}); diff --git a/frontend/e2e/register.spec.ts b/frontend/e2e/register.spec.ts index 76ee7f98..e5f86f06 100644 --- a/frontend/e2e/register.spec.ts +++ b/frontend/e2e/register.spec.ts @@ -1,115 +1,72 @@ -import { test, expect, clearSession } from './fixtures'; +import { randomUUID } from 'node:crypto'; +import { expect, test } from './fixtures'; +import { fieldTitle } from '#lib/formatters.ts'; -const PATH = '/register'; - -async function navigateToRegister(page: import('@playwright/test').Page): Promise { - await clearSession(page); - await page.goto(PATH); -} - -async function fillRegistrationForm( - page: import('@playwright/test').Page, - data: { username: string; email: string; password: string; confirmPassword: string } -): Promise { - await page.locator('#username').fill(data.username); - await page.locator('#email').fill(data.email); - await page.locator('#password').fill(data.password); - await page.locator('#confirm-password').fill(data.confirmPassword); -} - -test.describe('Registration', () => { - test.beforeEach(async ({ page }) => { - await navigateToRegister(page); - }); - - test('shows registration form with all required fields', async ({ page }) => { +test('the registration page shows its form', async ({ page }) => { + await page.goto('/register'); await expect(page.getByRole('heading', { name: 'Create a new account' })).toBeVisible(); - await expect(page.locator('#username')).toBeVisible(); - await expect(page.locator('#email')).toBeVisible(); - await expect(page.locator('#password')).toBeVisible(); - await expect(page.locator('#confirm-password')).toBeVisible(); - await expect(page.locator('button[type="submit"]')).toHaveText('Create Account'); - }); - - test('has link to login page', async ({ page }) => { - await expect(page.getByRole('link', { name: 'sign in to your existing account' })).toBeVisible(); - }); + for (const name of ['username', 'email', 'password', 'confirm_password']) { + await expect(page.getByLabel(fieldTitle(name), { exact: true })).toBeVisible(); + } + await expect(page.getByRole('button', { name: 'Create Account' })).toBeVisible(); +}); - test('can navigate to login page', async ({ page }) => { +test('the registration page links to login', async ({ page }) => { + await page.goto('/register'); await page.getByRole('link', { name: 'sign in to your existing account' }).click(); await expect(page).toHaveURL(/\/login/); - }); +}); - test('validates required fields on empty submission', async ({ page }) => { - await page.locator('button[type="submit"]').click(); +test('an empty registration form marks its fields as required', async ({ page }) => { + await page.goto('/register'); + await page.getByRole('button', { name: 'Create Account' }).click(); await expect(page).toHaveURL(/\/register/); - const usernameInput = page.locator('#username'); - await expect(usernameInput).toBeFocused(); - const isInvalid = await usernameInput.evaluate((el: HTMLInputElement) => !el.validity.valid); - expect(isInvalid).toBe(true); - }); - - test('shows error when passwords do not match', async ({ page }) => { - await fillRegistrationForm(page, { - username: 'testuser', - email: 'test@example.com', - password: 'Password123!', - confirmPassword: 'DifferentPassword123!', - }); - await page.locator('button[type="submit"]').click(); - await expect(page.locator('#confirm-password')).toHaveAccessibleDescription('Passwords do not match'); - await expect(page.locator('#confirm-password')).toHaveAttribute('aria-invalid', 'true'); - }); + await expect(page.getByLabel(fieldTitle('username'), { exact: true })).toHaveAccessibleDescription('Required'); +}); - test('shows error when password is too short', async ({ page }) => { - await fillRegistrationForm(page, { - username: 'testuser', - email: 'test@example.com', - password: 'short', - confirmPassword: 'short', - }); - await page.locator('button[type="submit"]').click(); - await expect(page.locator('#password')).toHaveAccessibleDescription('Must be at least 8 characters'); - }); +test('passwords that differ are flagged on the confirmation', async ({ page }) => { + await page.goto('/register'); + await page.getByLabel(fieldTitle('username'), { exact: true }).fill(`e2e-${randomUUID()}`); + await page.getByLabel(fieldTitle('email'), { exact: true }).fill(`${randomUUID()}@example.com`); + await page.getByLabel(fieldTitle('password'), { exact: true }).fill(randomUUID()); + await page.getByLabel(fieldTitle('confirm_password'), { exact: true }).fill(randomUUID()); + await page.getByRole('button', { name: 'Create Account' }).click(); + const confirmation = page.getByLabel(fieldTitle('confirm_password'), { exact: true }); + await expect(confirmation).toHaveAccessibleDescription('Passwords do not match'); + await expect(confirmation).toHaveAttribute('aria-invalid', 'true'); +}); - test('submits form and shows loading or redirects', async ({ page }) => { - const uniqueId = Date.now(); - await fillRegistrationForm(page, { - username: `newuser_${uniqueId}`, - email: `newuser_${uniqueId}@example.com`, - password: 'ValidPassword123!', - confirmPassword: 'ValidPassword123!', - }); - const submitButton = page.locator('button[type="submit"]'); - await submitButton.click(); - // Either see loading state OR redirect to login (both indicate successful submission) - const loadingOrRedirect = await Promise.race([ - expect(submitButton).toContainText(/Registering/).then(() => 'loading'), - expect(page).toHaveURL(/\/login/, { timeout: 10000 }).then(() => 'redirect'), - ]).catch(() => 'timeout'); - expect(['loading', 'redirect']).toContain(loadingOrRedirect); - }); +test('a short password is flagged with the minimum length', async ({ page }) => { + await page.goto('/register'); + await page.getByLabel(fieldTitle('password'), { exact: true }).fill('short'); + await page.getByLabel(fieldTitle('confirm_password'), { exact: true }).fill('short'); + await page.getByRole('button', { name: 'Create Account' }).click(); + await expect(page.getByLabel(fieldTitle('password'), { exact: true })).toHaveAccessibleDescription( + 'Must be at least 8 characters', + ); +}); - test('shows error for duplicate username', async ({ page }) => { - await fillRegistrationForm(page, { - username: 'user', - email: 'unique@example.com', - password: 'ValidPassword123!', - confirmPassword: 'ValidPassword123!', - }); - await page.locator('button[type="submit"]').click(); - await expect(page.getByTestId('error-message')).toBeVisible({ timeout: 5000 }); - }); +test('a taken username shows the error above the form', async ({ page, account }) => { + const password = randomUUID(); + await page.goto('/register'); + await page.getByLabel(fieldTitle('username'), { exact: true }).fill(account.username); + await page.getByLabel(fieldTitle('email'), { exact: true }).fill(`${randomUUID()}@example.com`); + await page.getByLabel(fieldTitle('password'), { exact: true }).fill(password); + await page.getByLabel(fieldTitle('confirm_password'), { exact: true }).fill(password); + await page.getByRole('button', { name: 'Create Account' }).click(); + await expect(page.getByTestId('error-message')).toBeVisible(); + await expect(page).toHaveURL(/\/register/); +}); - test('successful registration redirects to login', async ({ page }) => { - const uniqueId = Date.now(); - await fillRegistrationForm(page, { - username: `newuser_${uniqueId}`, - email: `newuser_${uniqueId}@example.com`, - password: 'ValidPassword123!', - confirmPassword: 'ValidPassword123!', - }); - await page.locator('button[type="submit"]').click(); - await expect(page).toHaveURL(/\/login/, { timeout: 10000 }); - }); +test('registering leads to the login page', async ({ page }) => { + const username = `e2e-${randomUUID()}`; + const password = randomUUID(); + await page.goto('/register'); + await page.getByLabel(fieldTitle('username'), { exact: true }).fill(username); + await page.getByLabel(fieldTitle('email'), { exact: true }).fill(`${username}@example.com`); + await page.getByLabel(fieldTitle('password'), { exact: true }).fill(password); + await page.getByLabel(fieldTitle('confirm_password'), { exact: true }).fill(password); + await page.getByRole('button', { name: 'Create Account' }).click(); + await expect(page.getByText('Registration successful! Please log in.')).toBeVisible(); + await expect(page).toHaveURL(/\/login/); }); diff --git a/frontend/e2e/settings.spec.ts b/frontend/e2e/settings.spec.ts index d9c908cf..1f9d639f 100644 --- a/frontend/e2e/settings.spec.ts +++ b/frontend/e2e/settings.spec.ts @@ -1,180 +1,91 @@ -import { test, expect, expectToastVisible, describeAuthRequired, clearSession, TEST_USERS } from './fixtures'; - -const PATH = '/settings'; -const HEADING = 'Settings'; - -test.describe('Settings Page', () => { - test('displays settings page with all tabs', async ({ userPage }) => { - await userPage.goto(PATH); - await expect(userPage.getByRole('heading', { name: HEADING, level: 1 })).toBeVisible(); - await expect(userPage.getByRole('button', { name: 'General' })).toBeVisible(); - await expect(userPage.getByRole('button', { name: 'Editor' })).toBeVisible(); - await expect(userPage.locator('main').getByRole('button', { name: 'Notifications', exact: true })).toBeVisible(); +import { acceptDialog, expect, test } from './fixtures'; +import { Theme } from '#lib/api/types.gen.ts'; +import { fieldTitle } from '#lib/formatters.ts'; + +test('the settings page shows its tabs and the history button', async ({ userPage }) => { + await userPage.goto('/settings'); + await expect(userPage.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible(); + const main = userPage.getByRole('main'); + for (const tab of ['General', fieldTitle('editor'), fieldTitle('notifications')]) { + await expect(main.getByRole('button', { name: tab, exact: true })).toBeVisible(); + } await expect(userPage.getByRole('button', { name: 'View History' })).toBeVisible(); - }); - - test('general tab shows theme selection', async ({ userPage }) => { - await userPage.goto(PATH); - await expect(userPage.getByRole('heading', { name: 'General', exact: true })).toBeVisible(); - await expect(userPage.getByText('Theme')).toBeVisible(); - await expect(userPage.getByLabel('Theme', { exact: true })).toBeVisible(); - }); - - test('can open theme dropdown and see options', async ({ userPage }) => { - await userPage.goto(PATH); - await userPage.getByLabel('Theme', { exact: true }).click(); - await expect(userPage.getByRole('option', { name: 'light', exact: true })).toBeVisible(); - await expect(userPage.getByRole('option', { name: 'dark', exact: true })).toBeVisible(); - await expect(userPage.getByRole('option', { name: 'system', exact: true })).toBeVisible(); - }); - - test('can change theme to dark', async ({ userPage }) => { - await userPage.goto(PATH); - await userPage.getByLabel('Theme', { exact: true }).click(); - await userPage.getByRole('option', { name: 'dark', exact: true }).click(); - const hasDarkClass = await userPage.evaluate(() => document.documentElement.classList.contains('dark')); - expect(hasDarkClass).toBe(true); - }); - - test('can change theme to light', async ({ userPage }) => { - await userPage.goto(PATH); - await userPage.getByLabel('Theme', { exact: true }).click(); - await userPage.getByRole('option', { name: 'light', exact: true }).click(); - const hasDarkClass = await userPage.evaluate(() => document.documentElement.classList.contains('dark')); - expect(hasDarkClass).toBe(false); - }); + await expect(userPage.getByRole('button', { name: 'Save Settings' })).toBeVisible(); }); -test.describe('Settings Editor Tab', () => { - test('shows editor settings section', async ({ userPage }) => { - await userPage.goto(PATH); - await userPage.getByRole('button', { name: 'Editor' }).click(); - await expect(userPage.getByRole('heading', { name: 'Editor', exact: true })).toBeVisible(); - await expect(userPage.getByText('Font Size')).toBeVisible(); - await expect(userPage.getByText('Tab Size')).toBeVisible(); - }); - - test('can change font size', async ({ userPage }) => { - await userPage.goto(PATH); - await userPage.getByRole('button', { name: 'Editor' }).click(); - const fontSizeInput = userPage.getByLabel('Font Size'); - await fontSizeInput.fill(''); - await fontSizeInput.fill('16'); - await expect(fontSizeInput).toHaveValue('16'); - }); +test('the theme picker offers every theme and applies the pick at once', async ({ userPage }) => { + await userPage.goto('/settings'); + await userPage.getByLabel(fieldTitle('theme'), { exact: true }).click(); + for (const theme of Object.values(Theme)) { + await expect(userPage.getByRole('option', { name: theme, exact: true })).toBeVisible(); + } + await userPage.getByRole('option', { name: Theme.DARK, exact: true }).click(); + await expect(userPage.locator('html')).toHaveClass(/\bdark\b/); + await userPage.getByLabel(fieldTitle('theme'), { exact: true }).click(); + await userPage.getByRole('option', { name: Theme.LIGHT, exact: true }).click(); + await expect(userPage.locator('html')).not.toHaveClass(/\bdark\b/); +}); - test('can change tab size', async ({ userPage }) => { - await userPage.goto(PATH); - await userPage.getByRole('button', { name: 'Editor' }).click(); - const tabSize = userPage.getByLabel('Tab Size'); +test('the editor tab edits font size, tab size and the toggles', async ({ userPage }) => { + await userPage.goto('/settings'); + await userPage + .getByRole('main') + .getByRole('button', { name: fieldTitle('editor'), exact: true }) + .click(); + await expect(userPage.getByRole('heading', { name: fieldTitle('editor'), exact: true })).toBeVisible(); + const fontSize = userPage.getByLabel(fieldTitle('font_size')); + await fontSize.fill('16'); + await expect(fontSize).toHaveValue('16'); + const tabSize = userPage.getByLabel(fieldTitle('tab_size')); await tabSize.selectOption('2'); await expect(tabSize).toHaveValue('2'); - }); - - test('can toggle word wrap setting', async ({ userPage }) => { - await userPage.goto(PATH); - await userPage.getByRole('button', { name: 'Editor' }).click(); - const wordWrapLabel = userPage.locator('label').filter({ hasText: 'Word Wrap' }); - const checkbox = wordWrapLabel.locator('input[type="checkbox"]'); - const initialState = await checkbox.isChecked(); - await wordWrapLabel.click(); - expect(await checkbox.isChecked()).toBe(!initialState); - }); + for (const key of ['use_tabs', 'word_wrap', 'show_line_numbers']) { + const toggle = userPage.getByRole('checkbox', { name: fieldTitle(key) }); + const checked = await toggle.isChecked(); + await toggle.click(); + await expect(toggle).toBeChecked({ checked: !checked }); + } }); -test.describe('Settings Notifications Tab', () => { - test('shows notification settings section', async ({ userPage }) => { - await userPage.goto(PATH); - await userPage.locator('main').getByRole('button', { name: 'Notifications', exact: true }).click(); - await expect(userPage.getByRole('heading', { name: 'Notifications', exact: true })).toBeVisible(); - await expect(userPage.getByText('Notification Types')).toBeVisible(); - }); - - test('shows all notification type toggles', async ({ userPage }) => { - await userPage.goto(PATH); - await userPage.locator('main').getByRole('button', { name: 'Notifications', exact: true }).click(); - await expect(userPage.getByText('Execution Completed')).toBeVisible(); - await expect(userPage.getByText('Execution Failed')).toBeVisible(); - await expect(userPage.getByText('System Updates')).toBeVisible(); - await expect(userPage.getByText('Security Alerts')).toBeVisible(); - }); - - test('can toggle notification preferences', async ({ userPage }) => { - await userPage.goto(PATH); - await userPage.locator('main').getByRole('button', { name: 'Notifications', exact: true }).click(); - const label = userPage.locator('label').filter({ hasText: 'Execution Completed' }); - const checkbox = label.locator('input[type="checkbox"]'); - const initialState = await checkbox.isChecked(); - await label.click(); - expect(await checkbox.isChecked()).toBe(!initialState); - }); +test('the notifications tab toggles each notification type', async ({ userPage }) => { + await userPage.goto('/settings'); + await userPage + .getByRole('main') + .getByRole('button', { name: fieldTitle('notifications'), exact: true }) + .click(); + await expect(userPage.getByRole('heading', { name: fieldTitle('notifications'), exact: true })).toBeVisible(); + for (const key of ['execution_completed', 'execution_failed', 'system_updates', 'security_alerts']) { + const toggle = userPage.getByRole('checkbox', { name: fieldTitle(key) }); + const checked = await toggle.isChecked(); + await toggle.click(); + await expect(toggle).toBeChecked({ checked: !checked }); + } }); -test.describe('Settings Save and History', () => { - test('shows save button', async ({ userPage }) => { - await userPage.goto(PATH); - await expect(userPage.getByRole('button', { name: 'Save Settings' })).toBeVisible(); - }); - - test('can save settings', async ({ userPage }) => { - await userPage.goto(PATH); - await userPage.getByRole('button', { name: 'Editor' }).click(); - const fontSizeInput = userPage.getByLabel('Font Size'); - const currentValue = await fontSizeInput.inputValue(); - await fontSizeInput.fill(currentValue === '14' ? '15' : '14'); +test('a saved change shows in the history and can be restored', async ({ userPage }) => { + await userPage.goto('/settings'); + await userPage + .getByRole('main') + .getByRole('button', { name: fieldTitle('editor'), exact: true }) + .click(); + const fontSize = userPage.getByLabel(fieldTitle('font_size')); + await fontSize.fill((await fontSize.inputValue()) === '14' ? '15' : '14'); await userPage.getByRole('button', { name: 'Save Settings' }).click(); - await expectToastVisible(userPage); - }); - - test('can open settings history modal', async ({ userPage }) => { - await userPage.goto(PATH); - await userPage.getByRole('button', { name: 'View History' }).click(); - await expect(userPage.getByRole('heading', { name: 'Settings History' })).toBeVisible(); - }); + await expect(userPage.getByText('Settings saved successfully')).toBeVisible(); - test('can close settings history modal', async ({ userPage }) => { - await userPage.goto(PATH); await userPage.getByRole('button', { name: 'View History' }).click(); - await userPage.getByRole('button', { name: 'Close', exact: true }).click(); - await expect(userPage.getByRole('heading', { name: 'Settings History' })).not.toBeVisible(); - }); - - test('can restore settings from history', async ({ userPage }) => { - // First make a change and save to ensure history exists - await userPage.goto(PATH); - await userPage.getByRole('button', { name: 'Editor' }).click(); - const fontSizeInput = userPage.getByLabel('Font Size'); - const currentValue = await fontSizeInput.inputValue(); - await fontSizeInput.fill(currentValue === '14' ? '15' : '14'); - await userPage.getByRole('button', { name: 'Save Settings' }).click(); - await expectToastVisible(userPage); - - // Open history and look for restore button - await userPage.getByRole('button', { name: 'View History' }).click(); - await expect(userPage.getByRole('heading', { name: 'Settings History' })).toBeVisible(); - - const restoreBtn = userPage.getByRole('button', { name: 'Restore' }).first(); - await expect(restoreBtn).toBeVisible({ timeout: 3000 }); - userPage.on('dialog', dialog => dialog.accept()); - await restoreBtn.click(); - await expectToastVisible(userPage); - }); + const history = userPage.getByRole('dialog', { name: 'Settings History' }); + await expect(history).toBeVisible(); + userPage.once('dialog', acceptDialog); + await history.getByRole('button', { name: 'Restore' }).first().click(); + await expect(userPage.getByText('Settings restored successfully')).toBeVisible(); }); -test.describe('Settings Access Control', () => { - test('redirects to login when not authenticated', async ({ page }) => { - await clearSession(page); - await page.goto(PATH); - await expect(page).toHaveURL(/\/login/); - }); - - test('preserves settings page as redirect target after login', async ({ page }) => { - await clearSession(page); - await page.goto(PATH); - await expect(page).toHaveURL(/\/login/); - await page.locator('#username').fill(TEST_USERS.user.username); - await page.locator('#password').fill(TEST_USERS.user.password); - await page.locator('button[type="submit"]').click(); - await expect(page.getByRole('heading', { name: HEADING, level: 1 })).toBeVisible({ timeout: 10000 }); - }); +test('the history dialog closes', async ({ userPage }) => { + await userPage.goto('/settings'); + await userPage.getByRole('button', { name: 'View History' }).click(); + const history = userPage.getByRole('dialog', { name: 'Settings History' }); + await expect(history).toBeVisible(); + await history.getByRole('button', { name: 'Close', exact: true }).click(); + await expect(history).toBeHidden(); }); diff --git a/frontend/e2e/theme.spec.ts b/frontend/e2e/theme.spec.ts index c660765f..6c24acce 100644 --- a/frontend/e2e/theme.spec.ts +++ b/frontend/e2e/theme.spec.ts @@ -1,54 +1,30 @@ -import { test, expect } from '@playwright/test'; +import { expect, test } from './fixtures'; -async function hasDarkTheme(page: import('@playwright/test').Page): Promise { - return page.evaluate(() => document.documentElement.classList.contains('dark')); -} - -test.describe('Theme', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/login'); - await page.evaluate(() => localStorage.removeItem('app-theme')); - }); - - test('auto theme follows system light preference', async ({ page }) => { +test('without a stored choice the theme follows a light system preference', async ({ page }) => { await page.emulateMedia({ colorScheme: 'light' }); await page.goto('/login'); - await page.waitForLoadState('networkidle'); - expect(await hasDarkTheme(page)).toBe(false); - }); + await expect(page.locator('html')).not.toHaveClass(/\bdark\b/); +}); - test('auto theme follows system dark preference', async ({ page }) => { +test('without a stored choice the theme follows a dark system preference', async ({ page }) => { await page.emulateMedia({ colorScheme: 'dark' }); await page.goto('/login'); - await page.waitForLoadState('networkidle'); - expect(await hasDarkTheme(page)).toBe(true); - }); - - test('explicit dark theme overrides system preference', async ({ page }) => { - await page.emulateMedia({ colorScheme: 'light' }); - await page.goto('/login'); - await page.evaluate(() => localStorage.setItem('app-theme', 'dark')); - await page.reload(); - await page.waitForLoadState('networkidle'); - expect(await hasDarkTheme(page)).toBe(true); - }); + await expect(page.locator('html')).toHaveClass(/\bdark\b/); +}); - test('explicit light theme overrides system preference', async ({ page }) => { +test('the toggle overrides the system preference and the choice survives navigation and reload', async ({ page }) => { await page.emulateMedia({ colorScheme: 'dark' }); await page.goto('/login'); - await page.evaluate(() => localStorage.setItem('app-theme', 'light')); + const toggle = page.getByRole('button', { name: 'Toggle theme' }); + // The toggle goes from system to light, then to dark + await toggle.click(); + await expect(page.locator('html')).not.toHaveClass(/\bdark\b/); + await page.emulateMedia({ colorScheme: 'light' }); + await toggle.click(); + await expect(page.locator('html')).toHaveClass(/\bdark\b/); + await page.getByRole('link', { name: 'create a new account' }).click(); + await expect(page).toHaveURL(/\/register/); + await expect(page.locator('html')).toHaveClass(/\bdark\b/); await page.reload(); - await page.waitForLoadState('networkidle'); - expect(await hasDarkTheme(page)).toBe(false); - }); - - test('theme persists across page navigation', async ({ page }) => { - await page.goto('/login'); - await page.evaluate(() => localStorage.setItem('app-theme', 'dark')); - await page.goto('/register'); - await page.waitForLoadState('networkidle'); - const storedTheme = await page.evaluate(() => localStorage.getItem('app-theme')); - expect(storedTheme).toBe('dark'); - expect(await hasDarkTheme(page)).toBe(true); - }); + await expect(page.locator('html')).toHaveClass(/\bdark\b/); }); diff --git a/frontend/package.json b/frontend/package.json index 0ce68929..9dbde6e8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,8 +19,8 @@ "test:e2e:deps": "playwright install-deps chromium", "test:e2e:browsers": "playwright install chromium", "stylelint": "stylelint \"src/**/*.css\"", - "format": "prettier --write \"src/**/*.{ts,svelte,json}\"", - "format:check": "prettier --check \"src/**/*.{ts,svelte,json}\"" + "format": "prettier --write \"{src,e2e}/**/*.{ts,svelte,json}\"", + "format:check": "prettier --check \"{src,e2e}/**/*.{ts,svelte,json}\"" }, "imports": { "#lib/*": "./src/lib/*", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 283b0440..037a4569 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -6,10 +6,11 @@ export default defineConfig({ fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, + failOnFlakyTests: !!process.env.CI, workers: process.env.CI ? 2 : undefined, - timeout: 10000, // 10s max per test + timeout: 10_000, expect: { - timeout: 3000, // 3s for assertions + timeout: 3_000, }, reporter: [ ...(process.env.CI ? [['list'] as const, ['html'] as const, ['github'] as const] : [['list'] as const]), @@ -38,12 +39,5 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'] }, }, ], - webServer: process.env.CI ? undefined : { - command: 'npm run dev', - url: 'https://localhost:5001', - reuseExistingServer: true, - ignoreHTTPSErrors: true, - timeout: 120000, - }, }); // --8<-- [end:config] diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index c0f60435..18ba88c9 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -10,6 +10,6 @@ "checkJs": false, "sourceMap": true }, - "include": ["src"], + "include": ["src", "e2e", "playwright.config.ts"], "exclude": ["node_modules", "build"] }