| name | playwright-e2e-templates |
| description | Copy-paste templates for writing Playwright E2E scenarios in AskMyDocs. Use when starting a new `frontend/e2e/*.spec.ts` file, setting up a new setup-project (admin/viewer/editor), or when you need a proven pattern for data-state waits, failure injection, CRUD flows, or wizard flows. Templates are aligned with R11 (testids) / R12 (coverage) / R13 (real data, external-only stubs). |
E2E scenario templates
All templates below follow the three hard rules: real backend, semantic
selectors, one happy + one failure per feature. Copy the closest
match, adjust testids and data-testid waits, run bash scripts/verify-e2e-real-data.sh before committing.
1. Skeleton — new feature spec file
import { test, expect } from './fixtures';
import { testid, waitForReady } from './helpers';
test.describe('<feature>', () => {
test('happy path — <what the user does>', async ({ page }) => {
await page.goto('/app/<route>');
await waitForReady(page, '<feature>-view');
await testid(page, '<feature>-primary-action').click();
await expect(testid(page, 'toast-success')).toBeVisible();
await expect(testid(page, '<feature>-view')).toHaveAttribute('data-state', 'ready');
});
test('failure path — <specific failure>', async ({ page }) => {
await page.goto('/app/<route>');
await testid(page, '<feature>-primary-action').click();
await expect(testid(page, '<field>-error')).toContainText(/<expected message>/i);
});
});
2. CRUD list + drawer form
test('admin creates a user and the table shows the row', async ({ page }) => {
await page.goto('/app/admin/users');
await waitForReady(page, 'users-table');
await testid(page, 'users-add').click();
await testid(page, 'user-form-email').fill('new@acme.io');
await testid(page, 'user-form-name').fill('New Hire');
await testid(page, 'user-form-role-picker').click();
await page.getByRole('option', { name: 'Viewer' }).click();
await testid(page, 'user-form-submit').click();
await expect(testid(page, 'toast-success')).toBeVisible();
const row = page.locator('[data-testid^="users-row-"]', { hasText: 'new@acme.io' });
await expect(row).toBeVisible();
});
test('duplicate email surfaces 422 per-field error', async ({ page }) => {
await page.goto('/app/admin/users');
await testid(page, 'users-add').click();
await testid(page, 'user-form-email').fill('admin@acme.io');
await testid(page, 'user-form-name').fill('Dup');
await testid(page, 'user-form-submit').click();
await expect(testid(page, 'user-form-email-error')).toContainText(/already/i);
});
3. Wizard (maintenance panel — artisan runner)
test('admin runs kb:rebuild-graph through the maintenance wizard', async ({ page }) => {
await page.goto('/app/admin/maintenance');
await waitForReady(page, 'maintenance-list');
await testid(page, 'maintenance-card-kb-rebuild-graph').click();
await testid(page, 'wizard-step-preview-run').click();
await expect(testid(page, 'wizard-preview-output')).toBeVisible();
await testid(page, 'wizard-confirm-checkbox').check();
await testid(page, 'wizard-confirm-continue').click();
await testid(page, 'wizard-run').click();
await expect(testid(page, 'wizard-result')).toHaveAttribute('data-state', 'ready');
await expect(testid(page, 'wizard-result')).toContainText(/exit 0/);
});
test('destructive command without confirm token is rejected', async ({ page }) => {
await page.goto('/app/admin/maintenance');
await testid(page, 'maintenance-card-kb-prune-deleted').click();
await testid(page, 'wizard-run').click();
await expect(testid(page, 'wizard-error')).toContainText(/confirm/i);
});
4. Markdown editor (KB viewer/editor)
test('admin edits a doc and save triggers re-ingest', async ({ page }) => {
await page.goto('/app/admin/kb');
await waitForReady(page, 'kb-tree');
await testid(page, 'kb-tree-node-policies/remote-work-policy').click();
await testid(page, 'kb-tab-source').click();
const editor = testid(page, 'kb-editor-cm');
await editor.click();
await page.keyboard.press('End');
await page.keyboard.type('\n\nUpdated by E2E.');
await testid(page, 'kb-editor-save').click();
await expect(testid(page, 'toast-success')).toBeVisible();
await testid(page, 'kb-tab-history').click();
await expect(testid(page, 'kb-history-latest')).toContainText(/re-ingested/i);
});
test('invalid frontmatter rejects save with 422', async ({ page }) => {
await page.goto('/app/admin/kb');
await testid(page, 'kb-tree-node-policies/remote-work-policy').click();
await testid(page, 'kb-tab-source').click();
await testid(page, 'kb-editor-cm').click();
await page.keyboard.press('Control+Home');
await page.keyboard.type('---\nstatus: NOT_A_VALID_STATUS\n---\n');
await testid(page, 'kb-editor-save').click();
await expect(testid(page, 'kb-editor-error')).toContainText(/status/i);
});
5. Charts (lazy-loaded)
test('chat volume chart renders real data', async ({ page }) => {
await page.goto('/app/admin');
await waitForReady(page, 'dashboard-chat-volume');
await expect(page.locator('[data-testid="dashboard-chat-volume"] svg')).toBeVisible();
});
test('empty-state chart (zero chats) shows the stub SVG', async ({ page, request }) => {
await request.post('/testing/seed', { data: { seeder: 'EmptyKbSeeder' } });
await page.goto('/app/admin');
await expect(testid(page, 'dashboard-chat-volume-empty')).toBeVisible();
});
6. RBAC forbidden (viewer storage state)
import { test, expect } from './fixtures';
import { testid } from './helpers';
test('viewer visiting /app/admin sees the forbidden surface', async ({ page }) => {
await page.goto('/app/admin');
await expect(testid(page, 'admin-forbidden')).toBeVisible();
});
7. Failure injection catalogue (allowed shapes)
await page.route('**/conversations/*/messages', (r) =>
r.request().method() === 'POST'
? r.fulfill({ status: 500, body: '{"message":"provider failure"}' })
: r.fallback(),
);
await page.route('**/api/kb/ingest', (r) => r.abort('timedout'));
await page.route('**/api.mailgun.net/**', (r) => r.fulfill({ status: 429 }));
await page.route('**/api/admin/metrics/**', (r) => r.fulfill({ status: 500 }));
8. Storage-state setup templates
import { test as setup, expect } from '@playwright/test';
const path = 'playwright/.auth/viewer.json';
setup('authenticate as viewer', async ({ page }) => {
await page.goto('/login');
await page.getByTestId('login-email').fill('viewer@acme.io');
await page.getByTestId('login-password').fill('secret123');
await page.getByTestId('login-submit').click();
await expect(page).toHaveURL(/\/app/);
await page.context().storageState({ path });
});
9. Do / Don't matrix
| Do | Don't |
|---|
getByTestId('users-row-42') | .user-row:nth-child(3) |
toHaveAttribute('data-state', 'ready') | waitForTimeout(1500) |
Reset DB via /testing/seed per fixture | page.route('**/api/admin/users', …) on happy path |
Stub **/api.openrouter.ai/** in ingest test | Stub **/api/kb/ingest on happy path |
Comment failure injections with R13: failure injection | Silently stub an internal route and ship |
Use expect(...).toBeVisible({ timeout }) | Hand-roll new Promise(resolve => ...) with MutationObserver |
10. Running the suite
npm run e2e
npm run e2e:ui
npm run e2e:headed
npm run e2e:report
E2E_SKIP_WEBSERVER=1 npm run e2e
11. Before opening the PR
bash scripts/verify-e2e-real-data.sh — no findings.
npm run e2e — green locally.
- Grep your new spec for
waitForTimeout, page.route('**/api/ — should be zero matches (or all marked with R13: failure injection comments).
- Confirm each touched feature has at least one test under
frontend/e2e/ that names it.