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
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,8 @@ Thumbs.db
.claude

.vercel

# Playwright (npm run test:e2e)
dist-e2e
playwright-report
test-results
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
56 changes: 56 additions & 0 deletions e2e/roasters.spec.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
119 changes: 119 additions & 0 deletions e2e/support/layout.js
Original file line number Diff line number Diff line change
@@ -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);
}
117 changes: 117 additions & 0 deletions e2e/support/mockApi.js
Original file line number Diff line number Diff line change
@@ -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, {});
});
}
Loading
Loading