Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
6 changes: 4 additions & 2 deletions docs/testing/frontend-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 29 additions & 0 deletions frontend/e2e/access.spec.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
}
106 changes: 45 additions & 61 deletions frontend/e2e/admin-events.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
89 changes: 30 additions & 59 deletions frontend/e2e/admin-executions.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
96 changes: 34 additions & 62 deletions frontend/e2e/admin-sagas.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Loading
Loading