Skip to content

Commit 9d4cfd6

Browse files
bloveclaude
andcommitted
test(website): open the mobile drawer in a real browser
Nav.spec.tsx covers the drill-in stack in jsdom, but jsdom has no CSS or layout engine, so nothing catches the overlay's top offset drifting, an overlay host swallowing row clicks, an lg:hidden regression exposing the hamburger at desktop, or a scroll lock that never engages/releases. Gives the mobile drawer the same real-browser treatment nav-panels.spec.ts gives the desktop panels. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 5ff7b8b commit 9d4cfd6

1 file changed

Lines changed: 154 additions & 0 deletions

File tree

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { test, expect, type Page } from '@playwright/test';
2+
3+
/**
4+
* jsdom has no CSS and no layout engine, so `src/components/shared/Nav.spec.tsx`
5+
* can exercise every state transition of the mobile drawer (push, pop, focus,
6+
* Escape) without ever being able to catch what only a real browser renders:
7+
* the overlay's `top: calc(var(--nav-h) - 1px)` landing in the wrong place, an
8+
* overlay host swallowing or mispositioning pointer events over a row (a
9+
* failure mode this repo has shipped before), an `lg:hidden` regression
10+
* leaking the hamburger onto desktop, or a scroll lock that never engages or
11+
* never releases. These assertions are about geometry and real pointer
12+
* delivery, so — like e2e/nav-panels.spec.ts for the desktop panels — they
13+
* only mean anything in a real browser.
14+
*/
15+
16+
const dialog = (page: Page) =>
17+
page.getByRole('dialog', { name: 'Mobile navigation' });
18+
19+
test.describe('mobile nav drawer', () => {
20+
test.beforeEach(async ({ page }) => {
21+
await page.setViewportSize({ width: 390, height: 844 });
22+
});
23+
24+
test('opens and shows the four root rows', async ({ page }) => {
25+
await page.goto('/');
26+
await page.getByRole('button', { name: 'Open menu' }).click();
27+
28+
const drawer = dialog(page);
29+
await expect(drawer).toBeVisible();
30+
31+
await expect(drawer.getByRole('button', { name: 'Libraries' })).toBeVisible();
32+
await expect(drawer.getByRole('button', { name: 'Docs' })).toBeVisible();
33+
await expect(drawer.getByRole('button', { name: 'Solutions' })).toBeVisible();
34+
35+
const pricing = drawer.getByRole('link', { name: 'Pricing' });
36+
await expect(pricing).toBeVisible();
37+
await expect(pricing).toHaveAttribute('href', '/pricing');
38+
});
39+
40+
test('a click on the Libraries row actually reaches it', async ({ page }) => {
41+
await page.goto('/');
42+
await page.getByRole('button', { name: 'Open menu' }).click();
43+
44+
const drawer = dialog(page);
45+
const librariesRow = drawer.getByRole('button', { name: 'Libraries' });
46+
await expect(librariesRow).toBeVisible();
47+
48+
// Independent of whether .click() "succeeds": ask the browser what
49+
// element is actually topmost at the row's centre. An invisible overlay
50+
// host sitting over the row — the exact failure mode this repo has
51+
// shipped before — would still let Playwright's actionability checks
52+
// pass (the row is visible and unobscured *by Playwright's own reading of
53+
// the DOM*), but elementFromPoint reports what a real finger would hit.
54+
const box = await librariesRow.boundingBox();
55+
if (!box) throw new Error('Libraries row has no box');
56+
const centre = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
57+
const topmostIsRow = await page.evaluate(
58+
({ x, y }) => {
59+
const el = document.elementFromPoint(x, y);
60+
return Boolean(el?.closest('.nav-mobile-row'));
61+
},
62+
centre,
63+
);
64+
expect(topmostIsRow).toBe(true);
65+
66+
await librariesRow.click();
67+
68+
// The level actually pushed: the four package links are showing, not
69+
// just "the click handler ran" (which jsdom already proves).
70+
await expect(drawer.getByRole('button', { name: 'Back to menu' })).toBeVisible();
71+
await expect(drawer.getByRole('link', { name: /@threadplane\/langgraph/ })).toBeVisible();
72+
await expect(drawer.getByRole('link', { name: /@threadplane\/ag-ui/ })).toBeVisible();
73+
await expect(drawer.getByRole('link', { name: /@threadplane\/chat/ })).toBeVisible();
74+
await expect(drawer.getByRole('link', { name: /@threadplane\/render/ })).toBeVisible();
75+
await expect(drawer.getByText('Not sure which one?')).toBeVisible();
76+
await expect(drawer.getByRole('link', { name: /Choosing an adapter/ })).toBeVisible();
77+
});
78+
79+
test('pushes into Libraries and pops back to the root rows', async ({ page }) => {
80+
await page.goto('/');
81+
await page.getByRole('button', { name: 'Open menu' }).click();
82+
83+
const drawer = dialog(page);
84+
await drawer.getByRole('button', { name: 'Libraries' }).click();
85+
86+
await expect(drawer.getByRole('button', { name: 'Back to menu' })).toBeVisible();
87+
await expect(drawer.getByRole('link', { name: /@threadplane\/langgraph/ })).toBeVisible();
88+
89+
await drawer.getByRole('button', { name: 'Back to menu' }).click();
90+
91+
await expect(drawer.getByRole('button', { name: 'Libraries' })).toBeVisible();
92+
await expect(drawer.getByRole('button', { name: 'Docs' })).toBeVisible();
93+
await expect(drawer.getByRole('button', { name: 'Solutions' })).toBeVisible();
94+
await expect(drawer.getByRole('link', { name: /@threadplane\/langgraph/ })).toHaveCount(0);
95+
});
96+
97+
test('sits flush under the nav', async ({ page }) => {
98+
await page.goto('/');
99+
await page.getByRole('button', { name: 'Open menu' }).click();
100+
101+
const drawer = dialog(page);
102+
await expect(drawer).toBeVisible();
103+
104+
const navBox = await page.locator('nav.nav-bar').boundingBox();
105+
const drawerBox = await drawer.boundingBox();
106+
if (!navBox || !drawerBox) throw new Error('nav or drawer has no box');
107+
108+
// `top: calc(var(--nav-h) - 1px)` exists precisely so the drawer overlaps
109+
// the nav's own 1px border rather than leaving a gap or an overshoot —
110+
// the class of offset bug that has shipped on this repo before.
111+
// `boundingBox()` returns {x, y, width, height} — not `bottom` — so the
112+
// nav's bottom edge is derived, not read directly.
113+
const navBottom = navBox.y + navBox.height;
114+
expect(Math.abs(drawerBox.y - navBottom)).toBeLessThanOrEqual(1);
115+
});
116+
117+
test('locks body scroll while open and releases it on close', async ({ page }) => {
118+
await page.goto('/');
119+
120+
expect(
121+
await page.evaluate(() => document.body.style.overflow),
122+
).not.toBe('hidden');
123+
124+
await page.getByRole('button', { name: 'Open menu' }).click();
125+
await expect(dialog(page)).toBeVisible();
126+
expect(await page.evaluate(() => document.body.style.overflow)).toBe('hidden');
127+
128+
await page.getByRole('button', { name: 'Close menu' }).click();
129+
await expect(dialog(page)).toBeHidden();
130+
expect(
131+
await page.evaluate(() => document.body.style.overflow),
132+
).not.toBe('hidden');
133+
});
134+
135+
test('the hamburger is not present at desktop width', async ({ page }) => {
136+
await page.setViewportSize({ width: 1440, height: 900 });
137+
await page.goto('/');
138+
await expect(page.getByRole('button', { name: 'Open menu' })).toBeHidden();
139+
});
140+
141+
test('opens pre-pushed to the docs level on /docs', async ({ page }) => {
142+
await page.goto('/docs');
143+
await page.getByRole('button', { name: 'Open menu' }).click();
144+
145+
const drawer = dialog(page);
146+
await expect(drawer).toBeVisible();
147+
await expect(drawer.getByRole('button', { name: 'Back to menu' })).toBeVisible();
148+
await expect(drawer.getByRole('button', { name: 'Search docs' })).toBeVisible();
149+
150+
// It opened at the docs level, not the root list of site triggers.
151+
await expect(drawer.getByRole('button', { name: 'Libraries' })).toHaveCount(0);
152+
await expect(drawer.getByRole('button', { name: 'Solutions' })).toHaveCount(0);
153+
});
154+
});

0 commit comments

Comments
 (0)