diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ae989c..8f92398 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,3 +36,35 @@ jobs: - name: Build run: npm run build + + # Browser layout tests (Playwright, real Chromium at phone + desktop widths + # against a mocked API). Catches what jsdom cannot: overlapping text, + # sideways overflow, truncation that doesn't truncate. See playwright.config.js. + e2e: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + + - name: Layout tests + run: npm run test:e2e + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report + retention-days: 7 diff --git a/.gitignore b/.gitignore index 0f180ea..59869ac 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,8 @@ Thumbs.db .claude .vercel + +# Playwright (npm run test:e2e) +dist-e2e +playwright-report +test-results diff --git a/README.md b/README.md index b88b78f..e4a8cfd 100644 --- a/README.md +++ b/README.md @@ -29,11 +29,32 @@ Requires the Laravel API running at `VITE_API_BASE` (default `http://localhost:8 ## Tests + build ```bash -npm test # 84 Vitest tests +npm run lint # eslint (errors fail, warnings pass) +npm run typecheck # tsc --noEmit +npm test # Vitest + Testing Library, jsdom +npm run test:e2e # Playwright layout tests, real Chromium (see below) npm run build # production bundle to dist/ ``` -CI runs both on every push to `main`. +CI runs all of these on every push and pull request to `main`. + +### Layout tests (`e2e/`) + +The Vitest suite runs in jsdom, which has no layout engine: it can prove the +right text is in the DOM but not that two labels landed on top of each other +or that a row pushed the page sideways on a phone. `npm run test:e2e` builds +the real bundle, serves it, and drives Chromium at a phone width (Pixel 7, +dark mode) and a desktop width against a mocked API (`e2e/support/mockApi.js`, +no network). Each scenario asserts geometry with the helpers in +`e2e/support/layout.js`: + +- `expectNoTextOverlap(page, root)` walks every rendered text node and fails + if any two, from different elements, are painted on top of each other. +- `expectNoHorizontalOverflow(page)` fails if the document scrolls sideways. + +Add a scenario whenever a page gains a new layout state (a new header, a +collapsible group, a badge that can hold a long string). First run needs the +browser: `npx playwright install chromium`. ## Deploy diff --git a/e2e/roasters.spec.js b/e2e/roasters.spec.js new file mode 100644 index 0000000..49e75ae --- /dev/null +++ b/e2e/roasters.spec.js @@ -0,0 +1,56 @@ +import { test, expect } from '@playwright/test'; +import { installApi, ROASTERS } from './support/mockApi.js'; +import { expectNoHorizontalOverflow, expectNoTextOverlap } from './support/layout.js'; + +/** + * Roasters directory at phone and desktop widths. Each scenario renders the + * page, waits for real data, then asserts geometry: nothing overlaps, nothing + * overflows sideways. Runs for both Playwright projects (see the config). + */ + +const MANY = ROASTERS.slice(0, 12).map((r) => r.id); // > collapse threshold +const FEW = [ROASTERS[4].id, ROASTERS[5].id]; // ≤ threshold: group always open + +async function openRoasters(page) { + await page.goto('/roasters'); + // The page renders a card list and a table and hides one per breakpoint, + // so pick whichever copy is actually visible. + await expect(page.getByText('JJ Bean').locator('visible=true').first()).toBeVisible(); +} + +async function checkLayout(page) { + await expectNoHorizontalOverflow(page); + await expectNoTextOverlap(page, '#root'); +} + +test.describe('roasters directory layout', () => { + test('signed out', async ({ page }) => { + await installApi(page); + await openRoasters(page); + await checkLayout(page); + }); + + test('signed in with a few pinned roasters (group open)', async ({ page }) => { + await installApi(page, { signedIn: true, favoriteIds: FEW }); + await openRoasters(page); + await expect(page.getByRole('heading', { name: 'Pinned roasters (2)' }).first()).toBeVisible(); + await checkLayout(page); + }); + + test('signed in with many pinned roasters (group collapsed, then expanded)', async ({ page }) => { + await installApi(page, { signedIn: true, favoriteIds: MANY }); + await openRoasters(page); + + const toggle = page.getByRole('button', { name: /pinned roasters \(12\)/i }).first(); + await expect(toggle).toBeVisible(); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await checkLayout(page); + + await toggle.click(); + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await expect( + page.getByText('House of Funk Coffee Roasters').locator('visible=true').first(), + ).toBeVisible(); + await checkLayout(page); + }); +}); diff --git a/e2e/support/layout.js b/e2e/support/layout.js new file mode 100644 index 0000000..9539a5d --- /dev/null +++ b/e2e/support/layout.js @@ -0,0 +1,119 @@ +import { expect } from '@playwright/test'; + +/** + * Geometry assertions — the checks jsdom cannot make. + * + * findTextOverlaps() walks every rendered text node under `root`, takes its + * per-line boxes (Range.getClientRects, so a wrapped paragraph is several + * boxes rather than one big rectangle), clips each box by every overflow- + * hidden / clip-path ancestor (so a truncated label counts only where it is + * actually painted), and reports any two boxes from different elements that + * intersect by more than a couple of pixels in both axes. Text inside + * position:fixed/sticky ancestors is skipped — a bottom tab bar is supposed to + * float over content that scrolls beneath it. + */ +export async function findTextOverlaps(page, root = 'body') { + return page.evaluate((rootSel) => { + const rootEl = document.querySelector(rootSel); + if (!rootEl) return [{ error: `no element matches ${rootSel}` }]; + + const SKIP_TAGS = new Set([ + 'SCRIPT', + 'STYLE', + 'NOSCRIPT', + 'TEMPLATE', + 'SELECT', + 'OPTION', + 'TEXTAREA', + 'SVG', + 'TITLE', + ]); + const intersect = (a, b) => { + const left = Math.max(a.left, b.left); + const top = Math.max(a.top, b.top); + const right = Math.min(a.right, b.right); + const bottom = Math.min(a.bottom, b.bottom); + return right > left && bottom > top + ? { left, top, right, bottom, width: right - left, height: bottom - top } + : null; + }; + + const boxes = []; + const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT); + let node; + outer: while ((node = walker.nextNode())) { + const text = node.textContent.replace(/\s+/g, ' ').trim(); + if (!text) continue; + const el = node.parentElement; + if (!el) continue; + + let clip = { left: -Infinity, top: -Infinity, right: Infinity, bottom: Infinity }; + for (let a = el; a; a = a.parentElement) { + if (SKIP_TAGS.has(a.tagName.toUpperCase())) continue outer; + const cs = getComputedStyle(a); + if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') + continue outer; + if (cs.position === 'fixed' || cs.position === 'sticky') continue outer; + const clips = + cs.overflowX !== 'visible' || + cs.overflowY !== 'visible' || + cs.clipPath !== 'none' || + (cs.clip && cs.clip !== 'auto'); + if (clips && a !== document.documentElement && a !== document.body) { + const b = a.getBoundingClientRect(); + clip = { + left: Math.max(clip.left, b.left), + top: Math.max(clip.top, b.top), + right: Math.min(clip.right, b.right), + bottom: Math.min(clip.bottom, b.bottom), + }; + } + } + + const range = document.createRange(); + range.selectNodeContents(node); + for (const r of range.getClientRects()) { + const c = intersect(r, clip); + if (!c || c.width < 2 || c.height < 2) continue; + boxes.push({ text: text.slice(0, 60), el, rect: c }); + } + } + + // One report per element pair (a wrapped label can collide on several lines). + const overlaps = []; + const seen = new Set(); + for (let i = 0; i < boxes.length; i++) { + for (let j = i + 1; j < boxes.length; j++) { + const a = boxes[i]; + const b = boxes[j]; + if (a.el === b.el) continue; + const o = intersect(a.rect, b.rect); + if (!o || o.width <= 2 || o.height <= 2) continue; + const first = a.el.compareDocumentPosition(b.el) & Node.DOCUMENT_POSITION_FOLLOWING; + const [x, y] = first ? [a, b] : [b, a]; + const id = `${x.text}\u0000${y.text}`; + if (seen.has(id)) continue; + seen.add(id); + overlaps.push({ + a: x.text, + b: y.text, + px: { x: Math.round(o.width), y: Math.round(o.height) }, + }); + } + } + return overlaps; + }, root); +} + +export async function expectNoTextOverlap(page, root = 'body') { + expect(await findTextOverlaps(page, root), `text overlaps under ${root}`).toEqual([]); +} + +/** The page must not scroll sideways: the classic "one wide row breaks the phone layout" bug. */ +export async function expectNoHorizontalOverflow(page) { + const { scrollWidth, clientWidth } = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + })); + expect(scrollWidth, 'document scrollWidth vs viewport').toBeLessThanOrEqual(clientWidth); +} diff --git a/e2e/support/mockApi.js b/e2e/support/mockApi.js new file mode 100644 index 0000000..f035e09 --- /dev/null +++ b/e2e/support/mockApi.js @@ -0,0 +1,117 @@ +/** + * Hermetic API for the browser tests. The bundle is built with + * VITE_API_BASE=http://api.test; every request to that host is answered here, + * and every other off-box request (ipapi.co geolocation, Vercel insights, + * favicons) is aborted so a test can never depend on the network. + */ + +export const TOKEN_KEY = 'coffee_tracker_token'; + +const mk = (id, name, city, region, extra = {}) => ({ + id, + slug: name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''), + name, + city, + region, + country_code: 'CA', + has_shipping: true, + is_online_only: false, + latitude: null, + longitude: null, + favicon_url: null, + in_stock_count: 6, + coffees_count: 8, + cpg_min: 4.0, + cpg_max: 5.9, + cpg_min_all: 4.0, + cpg_max_all: 6.4, + shipping_cost: 12, + free_shipping_over: 60, + search_terms: '', + ...extra, +}); + +// Real-length names (the overlap needed a long preview string to show up). +export const ROASTERS = [ + mk(1, 'House of Funk Coffee Roasters', 'North Vancouver', 'BC'), + mk(2, 'Rocanini Coffee Roasters', 'Steveston', 'BC'), + mk(3, 'Pallet Coffee Roasters', 'Vancouver', 'BC'), + mk(4, 'Timbertrain Coffee Roasters', 'Vancouver', 'BC', { shipping_cost: 43 }), + mk(5, 'JJ Bean', 'Vancouver', 'BC'), + mk(6, '49th Parallel Coffee Roasters', 'Vancouver', 'BC', { shipping_cost: 0 }), + mk(7, 'Nemesis Coffee', 'Vancouver', 'BC'), + mk(8, 'Prototype Coffee', 'Vancouver', 'BC'), + mk(9, 'Matchstick Coffee Roasters', 'Vancouver', 'BC'), + mk(10, 'Elysian Coffee Roasters', 'Vancouver', 'BC'), + mk(11, 'Revolver Coffee', 'Vancouver', 'BC'), + mk(12, 'Agro Roasters', 'Calgary', 'AB'), + mk(13, 'Spilt Milk Coffee Roasters', 'Revelstoke', 'BC'), + mk(14, 'Monogram Coffee', 'Calgary', 'AB'), + mk(15, 'Phil & Sebastian Coffee Roasters', 'Calgary', 'AB'), + mk(16, 'Pilot Coffee Roasters', 'Toronto', 'ON'), + mk(17, 'De Mello Coffee Roasters', 'Toronto', 'ON'), + mk(18, 'Detour Coffee Roasters', 'Hamilton', 'ON'), + mk(19, 'Subtext Coffee Roasters', 'Toronto', 'ON'), + mk(20, 'Anchored Coffee', 'Dartmouth', 'NS', { is_online_only: true }), +]; + +const json = (route, body, status = 200) => + route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); + +/** + * Wire the mocked API into a page. + * + * @param {import('@playwright/test').Page} page + * @param {{ signedIn?: boolean, favoriteIds?: number[], roasters?: object[] }} opts + */ +export async function installApi( + page, + { signedIn = false, favoriteIds = [], roasters = ROASTERS } = {}, +) { + if (signedIn) { + await page.addInitScript( + ([key]) => { + localStorage.setItem(key, 'e2e-token'); + }, + [TOKEN_KEY], + ); + } + + // Registered first = matched last: anything not on our two hosts is cut off. + await page.route('**/*', (route) => { + const { hostname } = new URL(route.request().url()); + if (hostname === '127.0.0.1' || hostname === 'api.test') return route.fallback(); + return route.abort('blockedbyclient'); + }); + + await page.route('http://api.test/api/**', (route) => { + const req = route.request(); + const { pathname } = new URL(req.url()); + const method = req.method(); + + if (pathname === '/api/roasters/summary') return json(route, { roasters }); + if (pathname === '/api/me') { + return signedIn + ? json(route, { + user: { id: 1, email: 'e2e@example.com', display_name: 'E2E', email_verified: true }, + }) + : json(route, { message: 'Unauthenticated.' }, 401); + } + if (pathname === '/api/me/favorite-roasters' && method === 'GET') { + const items = favoriteIds + .map((id) => roasters.find((r) => r.id === id)) + .filter(Boolean) + .map((roaster) => ({ roaster })); + return json(route, { items }); + } + if (pathname === '/api/wishlist' && method === 'GET') return json(route, { items: [] }); + + // Anything else: empty success, but say so — an unexpected call is usually + // a fixture gap rather than something the layout test should fail on. + console.warn(`[mockApi] unhandled ${method} ${pathname}`); + return json(route, {}); + }); +} diff --git a/eslint.config.js b/eslint.config.js index 190035c..3eb76f0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,7 +15,16 @@ import prettier from 'eslint-config-prettier'; // genuine bugs (hook misuse, missing keys, undefined refs) are errors. export default tseslint.config( { - ignores: ['dist', 'coverage', 'public', 'node_modules', '_showcase.html'], + ignores: [ + 'dist', + 'dist-e2e', + 'playwright-report', + 'test-results', + 'coverage', + 'public', + 'node_modules', + '_showcase.html', + ], }, js.configs.recommended, @@ -83,7 +92,10 @@ export default tseslint.config( files: ['**/*.{ts,tsx}'], rules: { 'no-unused-vars': 'off', - '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], }, }, diff --git a/package-lock.json b/package-lock.json index 276d322..9310408 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@playwright/test": "^1.56.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -1288,6 +1289,22 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -6349,6 +6366,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index d6ced31..75fb877 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write .", - "format:check": "prettier --check ." + "format:check": "prettier --check .", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "@radix-ui/react-dialog": "^1.1.15", @@ -29,6 +31,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@playwright/test": "^1.56.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..7ee57ee --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,44 @@ +import { defineConfig, devices } from '@playwright/test'; + +// Browser-level layout tests. The Vitest suite runs in jsdom, which has no +// layout engine: it can prove the right text is in the DOM but not that two +// labels landed on top of each other, that a row overflows a phone screen, or +// that a truncation actually truncates. These tests build the real bundle, +// serve it, drive real Chromium at phone and desktop sizes against a mocked +// API, and assert geometry (see e2e/support/layout.js). +// +// npm run test:e2e # both viewports, headless +// npm run test:e2e -- --ui # Playwright UI mode +// npx playwright show-report # after a failure in CI (artifact) +const PORT = 4173; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: 0, + reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : [['list']], + use: { + baseURL: `http://127.0.0.1:${PORT}`, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [ + // A narrow, touch phone in dark mode — the combination the pinned-roasters + // header overlap shipped under. + { name: 'mobile', use: { ...devices['Pixel 7'], colorScheme: 'dark' } }, + { name: 'desktop', use: { ...devices['Desktop Chrome'], colorScheme: 'light' } }, + ], + webServer: { + // Production build (not the dev server) so the CSS the tests measure is the + // CSS that ships: Tailwind purging, minification, chunking all included. + // Separate outDir so a local `npm run build` and this never clobber each + // other. VITE_API_BASE points at a host that only exists inside the tests + // (page.route intercepts it) so nothing can reach a real API. + command: `npm run build -- --outDir dist-e2e && npm run preview -- --outDir dist-e2e --host 127.0.0.1 --port ${PORT} --strictPort`, + url: `http://127.0.0.1:${PORT}`, + reuseExistingServer: !process.env.CI, + env: { VITE_API_BASE: 'http://api.test' }, + timeout: 180_000, + }, +}); diff --git a/src/pages/RoastersPage.jsx b/src/pages/RoastersPage.jsx index 96d4427..39d7ec1 100644 --- a/src/pages/RoastersPage.jsx +++ b/src/pages/RoastersPage.jsx @@ -80,17 +80,17 @@ function shipLabel(r) { * Heading for the pinned-roasters group. A real