Skip to content
Open
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ coverage
.idea
tsconfig.tsbuildinfo
*.tsbuildinfo
playwright-report
test-results
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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`:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/AI-USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions e2e/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
26 changes: 26 additions & 0 deletions e2e/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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'] } }],
});
44 changes: 44 additions & 0 deletions e2e/tests/a11y.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
132 changes: 132 additions & 0 deletions e2e/tests/search.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
9 changes: 9 additions & 0 deletions e2e/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2023", "DOM"],
"types": ["node"],
"noEmit": true
},
"include": ["playwright.config.ts", "tests/**/*"]
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading