From ef29a1af0a2292d691f7854e78f978b7a7775a95 Mon Sep 17 00:00:00 2001 From: Even Date: Thu, 5 Nov 2026 16:49:01 +0100 Subject: [PATCH 1/2] test: #20 Playwright E2E journey and axe-core scans Adds an e2e workspace with pnpm test:e2e. Covers search, filter, sort, detail, review persistence across reload, case-insensitive search, a keyboard-only path and WCAG 2.1 AA axe scans. Runs against a db:seed stack via E2E_BASE_URL. --- .gitignore | 2 + e2e/package.json | 16 +++++ e2e/playwright.config.ts | 26 ++++++++ e2e/tests/a11y.spec.ts | 44 +++++++++++++ e2e/tests/search.spec.ts | 132 +++++++++++++++++++++++++++++++++++++++ e2e/tsconfig.json | 9 +++ package.json | 1 + pnpm-lock.yaml | 51 +++++++++++++++ pnpm-workspace.yaml | 2 +- 9 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/tests/a11y.spec.ts create mode 100644 e2e/tests/search.spec.ts create mode 100644 e2e/tsconfig.json diff --git a/.gitignore b/.gitignore index f013f4a..e4b0a13 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ coverage .idea tsconfig.tsbuildinfo *.tsbuildinfo +playwright-report +test-results diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..af4acd3 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,16 @@ +{ + "name": "@t19/e2e", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "test:e2e": "playwright test", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@axe-core/playwright": "^4.14.0", + "@playwright/test": "^1.64.1", + "@types/node": "^24.19.2", + "typescript": "~5.9.3" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..00a603c --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * The tests run against a stack that is already up: client, API and a database + * loaded with `pnpm db:seed`. Nothing here starts servers, so the same tests can + * point at a local dev stack or any other deployment through E2E_BASE_URL. + * + * The base URL must end with a slash so that `page.goto('./')` and relative + * paths resolve under /project2/. + */ +const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:5173/project2/'; + +export default defineConfig({ + testDir: './tests', + // The journey writes to the shared database, so keep runs predictable. + fullyParallel: false, + workers: 1, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? 'list' : [['list'], ['html', { open: 'never' }]], + use: { + baseURL: baseURL.endsWith('/') ? baseURL : `${baseURL}/`, + trace: 'retain-on-failure', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/e2e/tests/a11y.spec.ts b/e2e/tests/a11y.spec.ts new file mode 100644 index 0000000..59669ea --- /dev/null +++ b/e2e/tests/a11y.spec.ts @@ -0,0 +1,44 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test, type Page } from '@playwright/test'; + +/** + * axe-core in a real browser. Unlike the jsdom checks in the client unit tests, + * this sees real layout and computed colours, so colour contrast is included. + * The tags limit the scan to WCAG 2.1 A and AA, which is the level we aim for. + */ +const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']; + +async function expectNoViolations(page: Page) { + const { violations } = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze(); + // Map to id and target so a failure says what broke and where. + expect( + violations.map(({ id, nodes }) => ({ id, targets: nodes.map((n) => n.target) })), + ).toEqual([]); +} + +test('start page has no axe violations', async ({ page }) => { + await page.goto('./'); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + await expectNoViolations(page); +}); + +test('results with open filters have no axe violations', async ({ page }) => { + await page.goto('./?q=blue'); + await expect(page.getByRole('status').filter({ hasText: 'Showing' })).toBeVisible(); + await page.getByRole('button', { name: 'Filters' }).click(); + await expect(page.getByRole('group', { name: 'Genre' })).toBeVisible(); + await expectNoViolations(page); +}); + +test('empty results have no axe violations', async ({ page }) => { + await page.goto('./?q=zzzznomatch'); + await expect(page.getByRole('heading', { name: 'No albums found' })).toBeVisible(); + await expectNoViolations(page); +}); + +test('album detail page has no axe violations', async ({ page }) => { + await page.goto('./?q=abbey'); + await page.getByRole('link', { name: 'Abbey Road' }).click(); + await expect(page.getByRole('heading', { level: 1, name: 'Abbey Road' })).toBeVisible(); + await expectNoViolations(page); +}); diff --git a/e2e/tests/search.spec.ts b/e2e/tests/search.spec.ts new file mode 100644 index 0000000..d6ca9fd --- /dev/null +++ b/e2e/tests/search.spec.ts @@ -0,0 +1,132 @@ +import { expect, test, type Page } from '@playwright/test'; + +/** + * End-to-end tests against the curated catalog from `pnpm db:seed`. The Beatles + * have two albums there, Abbey Road (1969) and Revolver (1966), which is enough + * to see a filter, a sort order and a detail page do their job. + * + * Every test gets a fresh browser context, so a fresh localStorage and a new + * simulated user. Reviews written here belong to that user only. + */ + +async function search(page: Page, query: string) { + await page.getByRole('searchbox', { name: 'Search by album or artist' }).fill(query); + await page.getByRole('button', { name: 'Search' }).click(); +} + +function resultTitles(page: Page) { + return page.getByRole('region', { name: 'Search results' }).getByRole('heading', { + level: 3, + }); +} + +test('search, filter, sort, open an album and keep a review across a reload', async ({ + page, +}) => { + await page.goto('./'); + + await search(page, 'beatles'); + await expect(page).toHaveURL(/[?&]q=beatles/); + await expect(page.getByRole('status').filter({ hasText: 'Showing' })).toHaveText( + 'Showing 2 of 2 albums.', + ); + + // Filter: the decade facet is computed over the whole result set. + await page.getByRole('button', { name: 'Filters' }).click(); + await page.getByRole('checkbox', { name: /1960s/ }).check(); + await expect(page).toHaveURL(/[?&]decades=1960/); + await expect(page.getByRole('button', { name: 'Filters (1)' })).toBeVisible(); + await page.keyboard.press('Escape'); + + // Sort: both directions, so the test cannot pass by accident on insertion order. + const sort = page.getByRole('combobox', { name: 'Sort by' }); + await sort.selectOption({ label: 'Title Z–A' }); + await expect(page).toHaveURL(/[?&]sort=TITLE_DESC/); + await expect(resultTitles(page)).toHaveText(['Revolver', 'Abbey Road']); + + await sort.selectOption({ label: 'Title A–Z' }); + await expect(page).toHaveURL(/[?&]sort=TITLE_ASC/); + await expect(resultTitles(page)).toHaveText(['Abbey Road', 'Revolver']); + + // Detail page. + await page.getByRole('link', { name: 'Abbey Road' }).click(); + await expect(page).toHaveURL(/\/album\/\d+/); + await expect(page.getByRole('heading', { level: 1, name: 'Abbey Road' })).toBeVisible(); + await expect(page.getByText('The Beatles')).toBeVisible(); + + // Write a review. The comment is unique so an old run cannot satisfy the check. + const comment = `E2E review ${Date.now()}`; + await page.getByRole('radio', { name: '4 stars' }).check(); + await page.getByRole('textbox', { name: /Comment/ }).fill(comment); + await page.getByRole('button', { name: 'Publish review' }).click(); + await expect(page.getByRole('status')).toHaveText('Your review was published.'); + + // Reload: the review comes back from the server for the same simulated user. + await page.reload(); + const myReview = page.getByRole('listitem').filter({ hasText: comment }); + await expect(myReview).toBeVisible(); + await expect(myReview.getByText('You', { exact: true })).toBeVisible(); + await expect(myReview.getByRole('img', { name: '4 out of 5 stars' })).toBeVisible(); + await expect(page.getByRole('radio', { name: '4 stars' })).toBeChecked(); + await expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible(); + + // Back to discovery restores the same search, filter and sort from the URL. + await page.getByRole('link', { name: '← Back to discovery' }).click(); + await expect(page).toHaveURL(/[?&]q=beatles/); + await expect(page).toHaveURL(/[?&]decades=1960/); + await expect(page.getByRole('combobox', { name: 'Sort by' })).toHaveValue('TITLE_ASC'); + await expect(resultTitles(page)).toHaveText(['Abbey Road', 'Revolver']); + + // Clean up so repeated runs do not pile reviews onto the shared catalog. + await page.getByRole('link', { name: 'Abbey Road' }).click(); + await page.getByRole('button', { name: 'Delete review' }).click(); + await expect(page.getByRole('status')).toHaveText('Your review was deleted.'); + await expect(page.getByText(comment)).toHaveCount(0); +}); + +test('search is case-insensitive', async ({ page }) => { + await page.goto('./'); + + await search(page, 'ABBEY'); + await expect(page.getByRole('link', { name: 'Abbey Road' })).toBeVisible(); + const upper = await resultTitles(page).allTextContents(); + + await search(page, 'abbey'); + await expect(page.getByRole('link', { name: 'Abbey Road' })).toBeVisible(); + await expect(resultTitles(page)).toHaveText(upper); +}); + +test('keyboard only: skip link, search and open a result', async ({ page }) => { + await page.goto('./'); + + // The skip link is the first stop and becomes visible when focused. + await page.keyboard.press('Tab'); + const skipLink = page.getByRole('link', { name: 'Skip to content' }); + await expect(skipLink).toBeFocused(); + await expect(skipLink).toBeInViewport(); + + // Following it skips the header navigation and lands on the search box. + await page.keyboard.press('Enter'); + await page.keyboard.press('Tab'); + const searchBox = page.getByRole('searchbox', { name: 'Search by album or artist' }); + await expect(searchBox).toBeFocused(); + + await page.keyboard.type('abbey'); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/[?&]q=abbey/); + const result = page.getByRole('link', { name: 'Abbey Road' }); + await expect(result).toBeVisible(); + + // Tab forward until the result link has focus. The cap keeps a broken focus + // order from looping forever; a visible focus ring is checked on arrival. + for (let i = 0; i < 10; i++) { + if (await result.evaluate((el) => el === document.activeElement)) break; + await page.keyboard.press('Tab'); + } + await expect(result).toBeFocused(); + const outline = await result.evaluate((el) => getComputedStyle(el).outlineStyle); + expect(outline).not.toBe('none'); + + await page.keyboard.press('Enter'); + await expect(page.getByRole('heading', { level: 1, name: 'Abbey Road' })).toBeVisible(); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..f1529a1 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023", "DOM"], + "types": ["node"], + "noEmit": true + }, + "include": ["playwright.config.ts", "tests/**/*"] +} diff --git a/package.json b/package.json index 51914c3..a234d57 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "typecheck": "pnpm -r typecheck", "test": "pnpm -r test", "test:watch": "pnpm --filter @t19/client test:watch", + "test:e2e": "pnpm --filter @t19/e2e test:e2e", "db:migrate": "pnpm --filter @t19/server db:migrate", "db:seed": "pnpm --filter @t19/server db:seed", "db:seed:large": "pnpm --filter @t19/server db:seed:large", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6be07d1..f816fe9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,6 +97,21 @@ importers: specifier: ^4.1.11 version: 4.1.11(@types/node@24.19.2)(jsdom@29.1.1)(vite@8.3.1(@types/node@24.19.2)(esbuild@0.28.2)(tsx@4.23.15)) + e2e: + devDependencies: + '@axe-core/playwright': + specifier: ^4.14.0 + version: 4.14.0(playwright-core@1.64.1) + '@playwright/test': + specifier: ^1.64.1 + version: 1.64.1 + '@types/node': + specifier: ^24.19.2 + version: 24.19.2 + typescript: + specifier: ~5.9.3 + version: 5.9.3 + server: dependencies: dataloader: @@ -173,6 +188,12 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@axe-core/playwright@4.14.0': + resolution: {integrity: sha512-SGUhG5PIHd2eMdpgBF2PyQLOhX+qyDR2PT4cmoS4Qk2ak+jX/rrSvst9l0PGv5WTHolKpUMvVmoHpr4cZgf1RQ==} + engines: {node: '>=22.12.0'} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -881,6 +902,11 @@ packages: '@oxc-project/types@0.151.0': resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} + '@playwright/test@1.64.1': + resolution: {integrity: sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==} + engines: {node: '>=20'} + hasBin: true + '@repeaterjs/repeater@3.1.0': resolution: {integrity: sha512-TaoVksZRSx2KWYYpyLQtMQXXeS98VsgZImzW65xmiVgbYhXLk+aEsmzPLirqVuE4/XuUapH2iMtxUzaBNDzdSQ==} @@ -2242,6 +2268,16 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + playwright-core@1.64.1: + resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.1: + resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==} + engines: {node: '>=20'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2744,6 +2780,11 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@axe-core/playwright@4.14.0(playwright-core@1.64.1)': + dependencies: + axe-core: 4.14.0 + playwright-core: 1.64.1 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -3263,6 +3304,10 @@ snapshots: '@oxc-project/types@0.151.0': {} + '@playwright/test@1.64.1': + dependencies: + playwright: 1.64.1 + '@repeaterjs/repeater@3.1.0': {} '@rolldown/binding-android-arm-eabi@1.2.11': @@ -4674,6 +4719,12 @@ snapshots: picomatch@4.0.7: {} + playwright-core@1.64.1: {} + + playwright@1.64.1: + dependencies: + playwright-core: 1.64.1 + possible-typed-array-names@1.1.0: {} postcss@8.5.28: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d47151d..a7a13a5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,4 @@ -# Workspace packages. e2e/ (Playwright) joins in M4. packages: - 'client' - 'server' + - 'e2e' From 50c58e93d8235608724b035b1aa07f960d6d04af Mon Sep 17 00:00:00 2001 From: Even Date: Thu, 5 Nov 2026 16:49:01 +0100 Subject: [PATCH 2/2] docs: #20 document the end-to-end tests --- AGENTS.md | 4 +++- README.md | 19 +++++++++++++++++-- docs/AI-USAGE.md | 1 + 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 598fd1a..4dd6e85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ and planned work. | `pnpm format` / `pnpm format:check` | Prettier | | `pnpm typecheck` | `tsc --noEmit` in every workspace | | `pnpm test` | Vitest, single run | +| `pnpm test:e2e` | Playwright against a running stack | Run `pnpm format:check && pnpm lint && pnpm typecheck && pnpm test` before opening a PR. CI enforces the same gates, plus build and the client bundle budget. @@ -83,7 +84,8 @@ server/ GraphQL Yoga + Drizzle + PostgreSQL, migrations, seeds, SQL checks docs/ architecture, data model, UI specification, workflow, AI log, VM runbook ``` -`schema.graphql` is the shared API contract; `e2e/` (Playwright) is planned. +`schema.graphql` is the shared API contract. `e2e/` holds the Playwright tests; +they need a running stack seeded with `db:seed` (see the README). Local setup and environment variables are documented in `README.md`. ## Infrastructure diff --git a/README.md b/README.md index 71f69a2..e68466f 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ CI also builds the project and checks the client bundle size. | `pnpm format` / `pnpm format:check` | Format or check formatting | | `pnpm typecheck` | Run TypeScript checks | | `pnpm test` | Run unit tests | +| `pnpm test:e2e` | Run Playwright end-to-end tests against a running stack | | `pnpm --filter @t19/server dev` | Start the GraphQL API | | `pnpm --filter @t19/server db:migrate` | Apply database migrations | | `pnpm --filter @t19/server db:seed` | Reset to the sample catalog | @@ -86,6 +87,20 @@ CI also builds the project and checks the client bundle size. Tests cover frontend components and backend logic. Additional PostgreSQL tests cover search, sorting, rating updates and filters. These run when `TEST_DATABASE_URL` is configured and require a separate test database with migrations applied. The [VM setup guide](docs/VM-setup.md) includes API smoke tests. +### End-to-end tests + +`e2e/` holds Playwright tests that run in a real browser against a running stack. They cover one full journey (search, filter, sort, open an album, write a review, reload and find it again), case-insensitive search, a keyboard-only path from the skip link to a result, and axe-core scans of the main views. They expect the sample catalog from `db:seed`, so do not point them at the deployed MusicBrainz catalog. + +```bash +pnpm --filter @t19/e2e exec playwright install chromium # once +pnpm --filter @t19/server db:seed +pnpm --filter @t19/server dev # API on :3001 +pnpm dev # client on :5173 +pnpm test:e2e +``` + +`E2E_BASE_URL` sets the client address (default `http://localhost:5173/project2/`). The journey deletes its review at the end, so repeated runs leave the database as it was. + ## Database seed data `db:seed` loads 16 curated albums for development. For scalability testing, `db:seed:large` generates 100,000 deterministic synthetic albums in batches of 1,000. You can change the size with `ALBUM_COUNT`: @@ -185,7 +200,7 @@ The read-only benchmark is available in `server/scripts/prefix-search-perf.sql`. The interface supports keyboard navigation, visible focus indicators, labelled form controls and a skip link to the main content. Search results, loading states and errors provide feedback to assistive technologies. The layout adapts to smaller screens and respects reduced-motion preferences. -Automated accessibility tests use axe-core to check key search states, including initial, results, no-results and error views. Component tests also cover keyboard interactions and focus behavior. Since these tests run in jsdom, they cannot fully verify visual accessibility, such as color contrast and layout at different zoom levels. Manual browser testing is still needed. +Automated accessibility tests use axe-core to check key search states, including initial, results, no-results and error views. Component tests also cover keyboard interactions and focus behavior. Since these tests run in jsdom, they cannot verify color contrast or layout. The Playwright tests therefore also run axe-core (WCAG 2.1 A and AA) in a real browser, where contrast is checked, and test a keyboard-only path with visible focus. Zoom and screen reader behavior are still tested manually. ### Sustainability measures @@ -245,7 +260,7 @@ The two `AlbumDetailPage` rows represent the separate chunks for the album detai **Personal crate:** Users can add or remove albums from their crate on both search results and album detail pages. User identity is simulated using a locally stored ID rather than a login system. -**Planned for M4:** A page for viewing all albums saved in the user's crate, the supporting `myCrate` query, and Playwright end-to-end tests. Browsing albums without entering a search term is also planned. +**Planned for M4:** A page for viewing all albums saved in the user's crate, and the supporting `myCrate` query. Browsing albums without entering a search term is also planned. ### Backend search behavior diff --git a/docs/AI-USAGE.md b/docs/AI-USAGE.md index 7cb270d..d1c1b54 100644 --- a/docs/AI-USAGE.md +++ b/docs/AI-USAGE.md @@ -75,6 +75,7 @@ the part that matters most. | 2026-10-28 | evenkkl | Claude | #12, review fixes on #66. Robert asked for an up-to-date branch, a correct README and green CI. Claude merged main, renumbered our migration to `0003` after #63's `0002`, kept both sides of the other conflicts, fixed a duplicate import from the merge, updated the README status sections and added the sort control to the Load more keyboard test. I checked the diff against main contains only our sorting work and these fixes. | | 2026-10-28 | evenkkl | Claude | #13 review fixes on PR #65: merged main (infinite scroll), resolved conflicts, added a Filters button for small screens and reconciled the README. Even settled the open review questions and updated the issue text. | | 2026-10-29 | evenkkl | Claude | #13 second review round on PR #65: merged main (sorting), selected filters stay in the facets, Filters dropdown on all screens, PostgreSQL tests for AND/OR and facet counts, doc wording. Even chose the dropdown layout. | +| 2026-11-05 | evenkkl | Claude | #20 Playwright E2E: Claude set up the e2e workspace and wrote the journey, case-insensitivity, keyboard-only and axe tests. Claude ran them against a local seeded stack and broke the UI on purpose to confirm they fail. | ## Notes