diff --git a/apps/website/e2e/nav-drawer.spec.ts b/apps/website/e2e/nav-drawer.spec.ts new file mode 100644 index 000000000..533adbb4e --- /dev/null +++ b/apps/website/e2e/nav-drawer.spec.ts @@ -0,0 +1,154 @@ +import { test, expect, type Page } from '@playwright/test'; + +/** + * jsdom has no CSS and no layout engine, so `src/components/shared/Nav.spec.tsx` + * can exercise every state transition of the mobile drawer (push, pop, focus, + * Escape) without ever being able to catch what only a real browser renders: + * the overlay's `top: calc(var(--nav-h) - 1px)` landing in the wrong place, an + * overlay host swallowing or mispositioning pointer events over a row (a + * failure mode this repo has shipped before), an `lg:hidden` regression + * leaking the hamburger onto desktop, or a scroll lock that never engages or + * never releases. These assertions are about geometry and real pointer + * delivery, so — like e2e/nav-panels.spec.ts for the desktop panels — they + * only mean anything in a real browser. + */ + +const dialog = (page: Page) => + page.getByRole('dialog', { name: 'Mobile navigation' }); + +test.describe('mobile nav drawer', () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + }); + + test('opens and shows the four root rows', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Open menu' }).click(); + + const drawer = dialog(page); + await expect(drawer).toBeVisible(); + + await expect(drawer.getByRole('button', { name: 'Libraries' })).toBeVisible(); + await expect(drawer.getByRole('button', { name: 'Docs' })).toBeVisible(); + await expect(drawer.getByRole('button', { name: 'Solutions' })).toBeVisible(); + + const pricing = drawer.getByRole('link', { name: 'Pricing' }); + await expect(pricing).toBeVisible(); + await expect(pricing).toHaveAttribute('href', '/pricing'); + }); + + test('a click on the Libraries row actually reaches it', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Open menu' }).click(); + + const drawer = dialog(page); + const librariesRow = drawer.getByRole('button', { name: 'Libraries' }); + await expect(librariesRow).toBeVisible(); + + // Independent of whether .click() "succeeds": ask the browser what + // element is actually topmost at the row's centre. An invisible overlay + // host sitting over the row — the exact failure mode this repo has + // shipped before — would still let Playwright's actionability checks + // pass (the row is visible and unobscured *by Playwright's own reading of + // the DOM*), but elementFromPoint reports what a real finger would hit. + const box = await librariesRow.boundingBox(); + if (!box) throw new Error('Libraries row has no box'); + const centre = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + const topmostIsRow = await page.evaluate( + ({ x, y }) => { + const el = document.elementFromPoint(x, y); + return Boolean(el?.closest('.nav-mobile-row')); + }, + centre, + ); + expect(topmostIsRow).toBe(true); + + await librariesRow.click(); + + // The level actually pushed: the four package links are showing, not + // just "the click handler ran" (which jsdom already proves). + await expect(drawer.getByRole('button', { name: 'Back to menu' })).toBeVisible(); + await expect(drawer.getByRole('link', { name: /@threadplane\/langgraph/ })).toBeVisible(); + await expect(drawer.getByRole('link', { name: /@threadplane\/ag-ui/ })).toBeVisible(); + await expect(drawer.getByRole('link', { name: /@threadplane\/chat/ })).toBeVisible(); + await expect(drawer.getByRole('link', { name: /@threadplane\/render/ })).toBeVisible(); + await expect(drawer.getByText('Not sure which one?')).toBeVisible(); + await expect(drawer.getByRole('link', { name: /Choosing an adapter/ })).toBeVisible(); + }); + + test('pushes into Libraries and pops back to the root rows', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Open menu' }).click(); + + const drawer = dialog(page); + await drawer.getByRole('button', { name: 'Libraries' }).click(); + + await expect(drawer.getByRole('button', { name: 'Back to menu' })).toBeVisible(); + await expect(drawer.getByRole('link', { name: /@threadplane\/langgraph/ })).toBeVisible(); + + await drawer.getByRole('button', { name: 'Back to menu' }).click(); + + await expect(drawer.getByRole('button', { name: 'Libraries' })).toBeVisible(); + await expect(drawer.getByRole('button', { name: 'Docs' })).toBeVisible(); + await expect(drawer.getByRole('button', { name: 'Solutions' })).toBeVisible(); + await expect(drawer.getByRole('link', { name: /@threadplane\/langgraph/ })).toHaveCount(0); + }); + + test('sits flush under the nav', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Open menu' }).click(); + + const drawer = dialog(page); + await expect(drawer).toBeVisible(); + + const navBox = await page.locator('nav.nav-bar').boundingBox(); + const drawerBox = await drawer.boundingBox(); + if (!navBox || !drawerBox) throw new Error('nav or drawer has no box'); + + // `top: calc(var(--nav-h) - 1px)` exists precisely so the drawer overlaps + // the nav's own 1px border rather than leaving a gap or an overshoot — + // the class of offset bug that has shipped on this repo before. + // `boundingBox()` returns {x, y, width, height} — not `bottom` — so the + // nav's bottom edge is derived, not read directly. + const navBottom = navBox.y + navBox.height; + expect(Math.abs(drawerBox.y - navBottom)).toBeLessThanOrEqual(1); + }); + + test('locks body scroll while open and releases it on close', async ({ page }) => { + await page.goto('/'); + + expect( + await page.evaluate(() => document.body.style.overflow), + ).not.toBe('hidden'); + + await page.getByRole('button', { name: 'Open menu' }).click(); + await expect(dialog(page)).toBeVisible(); + expect(await page.evaluate(() => document.body.style.overflow)).toBe('hidden'); + + await page.getByRole('button', { name: 'Close menu' }).click(); + await expect(dialog(page)).toBeHidden(); + expect( + await page.evaluate(() => document.body.style.overflow), + ).not.toBe('hidden'); + }); + + test('the hamburger is not present at desktop width', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/'); + await expect(page.getByRole('button', { name: 'Open menu' })).toBeHidden(); + }); + + test('opens pre-pushed to the docs level on /docs', async ({ page }) => { + await page.goto('/docs'); + await page.getByRole('button', { name: 'Open menu' }).click(); + + const drawer = dialog(page); + await expect(drawer).toBeVisible(); + await expect(drawer.getByRole('button', { name: 'Back to menu' })).toBeVisible(); + await expect(drawer.getByRole('button', { name: 'Search docs' })).toBeVisible(); + + // It opened at the docs level, not the root list of site triggers. + await expect(drawer.getByRole('button', { name: 'Libraries' })).toHaveCount(0); + await expect(drawer.getByRole('button', { name: 'Solutions' })).toHaveCount(0); + }); +}); diff --git a/apps/website/e2e/nav-height.spec.ts b/apps/website/e2e/nav-height.spec.ts index ac17e7a9c..dc06b7b01 100644 --- a/apps/website/e2e/nav-height.spec.ts +++ b/apps/website/e2e/nav-height.spec.ts @@ -17,32 +17,118 @@ import { test, expect } from '@playwright/test'; * bug this guards against was fifteen. */ const STEPS = [ - { width: 375, note: 'phone — px-6 py-4' }, - { width: 767, note: 'phone — last px before md' }, - { width: 768, note: 'tablet — md padding, no lg link row' }, - { width: 1023, note: 'tablet — last px before lg' }, - { width: 1024, note: 'desktop — lg link row appears' }, - { width: 1440, note: 'desktop' }, + { width: 375, note: 'phone — px-6 py-4', marketingNavH: 58, docsNavH: 58 }, + { width: 767, note: 'phone — last px before md', marketingNavH: 58, docsNavH: 58 }, + { width: 768, note: 'tablet — md padding, no lg link row', marketingNavH: 66, docsNavH: 58 }, + { width: 1023, note: 'tablet — last px before lg', marketingNavH: 66, docsNavH: 58 }, + { width: 1024, note: 'desktop — lg link row appears', marketingNavH: 81, docsNavH: 58 }, + { width: 1440, note: 'desktop', marketingNavH: 81, docsNavH: 58 }, ]; -for (const step of STEPS) { - test(`--nav-h matches the rendered nav at ${step.width}px (${step.note})`, async ({ page }) => { - await page.setViewportSize({ width: step.width, height: 800 }); - await page.goto('/docs/langgraph/getting-started/introduction'); +/** + * `--nav-h` is route-dependent as of the navbar redesign: marketing routes keep + * the measured 58/66/81 ladder, and /docs is a flat 58 at every width. Both + * have to be measured, because the declared value is rounded up off the + * rendered height and only a browser knows what that height is. + */ +const SURFACES = [ + { name: 'marketing', url: '/', expectedNavH: (step: (typeof STEPS)[number]) => step.marketingNavH }, + { + name: 'docs', + url: '/docs/langgraph/getting-started/introduction', + expectedNavH: (step: (typeof STEPS)[number]) => step.docsNavH, + }, +]; - const nav = page.locator('nav').first(); - await expect(nav).toBeVisible(); +for (const surface of SURFACES) { + for (const step of STEPS) { + test(`--nav-h matches the rendered nav on ${surface.name} at ${step.width}px (${step.note})`, async ({ + page, + }) => { + await page.setViewportSize({ width: step.width, height: 800 }); + await page.goto(surface.url); - const measured = await nav.evaluate((el) => el.getBoundingClientRect().height); - const variable = await page.evaluate(() => - parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--nav-h')), - ); + const nav = page.locator('nav').first(); + await expect(nav).toBeVisible(); - expect(variable).toBeGreaterThanOrEqual(measured); - expect(variable - measured).toBeLessThanOrEqual(1); - }); + const measured = await nav.evaluate((el) => el.getBoundingClientRect().height); + const variable = await page.evaluate(() => + parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--nav-h')), + ); + + expect( + variable, + `--nav-h (${variable}) drifted from the rendered nav (${measured}) on ` + + `${surface.name} at ${step.width}px. The variable no longer describes ` + + `reality, so every offset built on it is wrong. Re-measure the nav.`, + ).toBeGreaterThanOrEqual(measured); + expect( + variable - measured, + `--nav-h (${variable}) overshoots the rendered nav (${measured}) by ` + + `${(variable - measured).toFixed(2)}px on ${surface.name} at ` + + `${step.width}px. Overshoot becomes dead space above the content.`, + ).toBeLessThanOrEqual(1); + + // The two checks above are self-consistency only: they confirm --nav-h + // tracks whatever the nav happens to render, but they cannot see a + // regression where the *ladder itself* collapses — e.g. the marketing + // steps flattening to 58px like docs. If the declared value and the + // rendered nav moved together, every self-consistency check above would + // still pass. Pinning the declared value against the ladder we intend + // catches that; it is a separate property from "does the variable match + // what rendered." + expect( + variable, + `--nav-h is ${variable} on ${surface.name} at ${step.width}px, but this ` + + `ladder is meant to declare ${surface.expectedNavH(step)}px. This is a ` + + `DESIGN change, not drift — the checks above still passed, so the ` + + `variable and the nav moved together. Update this table only if the ` + + `new ladder is intended.`, + ).toBe(surface.expectedNavH(step)); + }); + } } +test('the docs nav does not grow with the breakpoint', async ({ page }) => { + const heights: number[] = []; + for (const width of [375, 768, 1440]) { + await page.setViewportSize({ width, height: 800 }); + await page.goto('/docs/langgraph/getting-started/introduction'); + heights.push( + await page + .locator('nav') + .first() + .evaluate((el) => el.getBoundingClientRect().height), + ); + } + const [phone] = heights; + for (const height of heights) expect(Math.abs(height - phone)).toBeLessThanOrEqual(1); +}); + +test('the marketing nav does grow with the breakpoint', async ({ page }) => { + // Direct counterpart to "the docs nav does not grow with the breakpoint" + // above: docs stays flat on purpose, and marketing is supposed to keep its + // ladder. Stating both intents as tests means a future change that + // accidentally flattens the marketing ladder (matching it to docs) fails + // here even though every self-consistency check elsewhere in this file + // would still pass. + await page.setViewportSize({ width: 375, height: 800 }); + await page.goto('/'); + const phoneHeight = await page + .locator('nav') + .first() + .evaluate((el) => el.getBoundingClientRect().height); + + await page.setViewportSize({ width: 1440, height: 800 }); + await page.goto('/'); + const desktopHeight = await page + .locator('nav') + .first() + .evaluate((el) => el.getBoundingClientRect().height); + + expect(desktopHeight - phoneHeight).toBeGreaterThan(15); +}); + test('the docs column starts directly under the nav at a tablet width', async ({ page }) => { // The 15px overshoot showed up here as dead space above the breadcrumb. await page.setViewportSize({ width: 900, height: 800 }); diff --git a/apps/website/e2e/nav-panels.spec.ts b/apps/website/e2e/nav-panels.spec.ts new file mode 100644 index 000000000..399589a73 --- /dev/null +++ b/apps/website/e2e/nav-panels.spec.ts @@ -0,0 +1,120 @@ +import { test, expect } from '@playwright/test'; + +/** + * jsdom has no layout engine, so the unit tests cannot see a panel that renders + * into a quarter of its own width — which is exactly what shipped when the + * Libraries grid's tracks were put on the wrong element. These assertions are + * about geometry, so they only mean anything in a real browser. + */ +test.describe('desktop nav panels', () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/'); + }); + + test('lays the four libraries out side by side, not stacked', async ({ page }) => { + await page.getByRole('button', { name: 'Libraries' }).click(); + + const items = page.locator('.nav-panel .nav-panel-item'); + await expect(items).toHaveCount(5); // four libraries plus the footer link + // The panel's entrance animation translates it into place over 140ms; the + // boxes below are read one at a time, so sampling mid-animation would + // catch each item at a different point along that transform. Let it settle + // first so the geometry assertions below reflect the resting layout. + await page.locator('.nav-panel').evaluate((el) => + Promise.all(el.getAnimations().map((animation) => animation.finished)), + ); + + const boxes = []; + for (let index = 0; index < 4; index += 1) { + const box = await items.nth(index).boundingBox(); + if (!box) throw new Error(`Library item ${index} has no box`); + boxes.push(box); + } + + // Four distinct columns: every item starts to the right of the previous one + // and shares its vertical position. + for (let index = 1; index < boxes.length; index += 1) { + expect(boxes[index].x).toBeGreaterThan(boxes[index - 1].x); + expect(Math.abs(boxes[index].y - boxes[0].y)).toBeLessThanOrEqual(2); + } + + // And together they occupy most of the panel rather than one track of it. + const panel = await page.locator('.nav-panel').boundingBox(); + if (!panel) throw new Error('Panel has no box'); + const spanned = boxes[3].x + boxes[3].width - boxes[0].x; + expect(spanned).toBeGreaterThan(panel.width * 0.8); + }); + + test('stacks the items within each column of a multi-column panel', async ({ page }) => { + await page.getByRole('button', { name: 'Solutions' }).click(); + + const firstColumn = page.locator('.nav-panel .nav-panel-col').first(); + const items = firstColumn.locator('.nav-panel-item'); + await expect(items).toHaveCount(3); + + const first = await items.nth(0).boundingBox(); + const second = await items.nth(1).boundingBox(); + if (!first || !second) throw new Error('Column items have no box'); + expect(second.y).toBeGreaterThan(first.y); + expect(Math.abs(second.x - first.x)).toBeLessThanOrEqual(2); + }); + + test('draws no borders or dividers inside a panel', async ({ page }) => { + await page.getByRole('button', { name: 'Docs' }).click(); + + const bordered = await page.locator('.nav-panel *').evaluateAll((nodes) => + nodes.filter((node) => { + const style = getComputedStyle(node as Element); + return ( + parseFloat(style.borderTopWidth) > 0 || + parseFloat(style.borderRightWidth) > 0 || + parseFloat(style.borderBottomWidth) > 0 || + parseFloat(style.borderLeftWidth) > 0 + ); + }).length, + ); + expect(bordered).toBe(0); + }); + + test('moves focus into the panel it just opened', async ({ page }) => { + await page.getByRole('button', { name: 'Libraries' }).click(); + await page.keyboard.press('Tab'); + + const focusedInsidePanel = await page.evaluate(() => + Boolean(document.activeElement?.closest('.nav-panel')), + ); + expect(focusedInsidePanel).toBe(true); + }); + + test('opens on hover after a grace period and survives the move into the panel', async ({ page }) => { + const trigger = page.getByRole('button', { name: 'Libraries' }); + // A plain `.hover()` jumps the pointer straight to the target's center in + // one step, so it never actually crosses the dead zone below the trigger + // row — the exact gap this test exists to cover. `mouse.move` with + // `steps` dispatches intermediate mousemove events along the path, which + // is what makes the dead zone's mouseleave/mouseenter pair fire for real. + const triggerBox = await trigger.boundingBox(); + if (!triggerBox) throw new Error('Trigger has no box'); + await page.mouse.move( + triggerBox.x + triggerBox.width / 2, + triggerBox.y + triggerBox.height / 2, + ); + await expect(page.locator('.nav-panel')).toBeVisible({ timeout: 2000 }); + + // The dead zone between the trigger row and the panel belongs to neither + // element; crossing it schedules a close that entering the panel must cancel. + const itemBox = await page + .locator('.nav-panel .nav-panel-item') + .first() + .boundingBox(); + if (!itemBox) throw new Error('Panel item has no box'); + await page.mouse.move( + itemBox.x + itemBox.width / 2, + itemBox.y + itemBox.height / 2, + { steps: 15 }, + ); + await page.waitForTimeout(400); + await expect(page.locator('.nav-panel')).toBeVisible(); + }); +}); diff --git a/apps/website/e2e/nav-surface.spec.ts b/apps/website/e2e/nav-surface.spec.ts new file mode 100644 index 000000000..669345c28 --- /dev/null +++ b/apps/website/e2e/nav-surface.spec.ts @@ -0,0 +1,83 @@ +import { test, expect } from '@playwright/test'; +import { HERO_ROUTES } from '../src/components/shared/nav-config'; + +/** + * A hand-maintained hero-route list drifts. A unit test over the list cannot + * catch a page that stopped rendering a hero, so the guard has to visit the + * page and read the computed background. + * + * The background is asserted with `toHaveCSS` rather than a one-shot + * `getComputedStyle` read: `.nav-bar` transitions `background` over 200ms, so + * the attribute flips a fifth of a second before the colour finishes moving, + * and a single read lands mid-fade on a partial alpha. `toHaveCSS` retries, + * which is what makes this assert the resting surface instead of the timing. + */ +for (const route of HERO_ROUTES) { + test(`the nav is transparent at rest on ${route}`, async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(route); + const nav = page.locator('nav').first(); + await expect(nav).toHaveAttribute('data-surface', 'transparent'); + await expect(nav).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + }); + + test(`the nav solidifies once ${route} is scrolled`, async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(route); + await page.mouse.wheel(0, 600); + + const nav = page.locator('nav').first(); + await expect(nav).toHaveAttribute('data-surface', 'solid'); + await expect(nav).not.toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + }); +} + +test('the nav is solid on a route with no hero', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/docs/langgraph/getting-started/introduction'); + await expect(page.locator('nav').first()).toHaveAttribute( + 'data-surface', + 'solid', + ); +}); + +/** + * Pins the contrast the docs CTA demotion (chrome.css) rests on: marketing + * keeps a filled button, docs flattens it to a text link. `nav-height.spec.ts` + * only proves the docs bar stopped growing — the same measured height would + * also result from a filled button that happened to be 25px tall, so nothing + * else asserts the surface actually changed. + * + * Marketing is asserted as "has a fill", not "has a yellow fill": at rest + * (scroll 0) `/` is a HERO_ROUTES page with a transparent `.nav-bar`, and the + * transparent-surface rule inverts the CTA to a navy fill rather than leaving + * it in its normal yellow — asserting a specific colour here would encode + * that scroll-position inversion and break the moment either theme changes. + * + * Both reads use `toHaveCSS`, not a one-shot `getComputedStyle`: the button + * itself transitions `background-color`/`color` over 120ms on mount, so an + * immediate read after `goto` can land mid-transition. + */ +test('the nav CTA is a filled button on marketing but a text link on docs', async ({ + page, +}) => { + await page.setViewportSize({ width: 1440, height: 900 }); + + await page.goto('/'); + const marketingCta = page + .locator('nav') + .first() + .getByRole('link', { name: 'Talk to Us' }); + await expect(marketingCta).not.toHaveCSS( + 'background-color', + 'rgba(0, 0, 0, 0)', + ); + + await page.goto('/docs/langgraph/getting-started/introduction'); + const docsCta = page + .locator('nav') + .first() + .getByRole('link', { name: 'Talk to Us' }); + await expect(docsCta).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + await expect(docsCta).toHaveCSS('color', 'rgb(21, 37, 62)'); +}); diff --git a/apps/website/src/components/shared/Nav.spec.tsx b/apps/website/src/components/shared/Nav.spec.tsx index 9c9f88e9f..fe6fe37d2 100644 --- a/apps/website/src/components/shared/Nav.spec.tsx +++ b/apps/website/src/components/shared/Nav.spec.tsx @@ -4,8 +4,9 @@ import { act, fireEvent, render, screen, waitFor, within } from '@testing-librar import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Nav } from './Nav'; -const { trackCtaClick, pathnameRef } = vi.hoisted(() => ({ +const { trackCtaClick, trackExternalLinkClick, pathnameRef } = vi.hoisted(() => ({ trackCtaClick: vi.fn(), + trackExternalLinkClick: vi.fn(), pathnameRef: { current: '/docs/langgraph/guides/streaming' }, })); @@ -16,13 +17,14 @@ vi.mock('next/navigation', () => ({ vi.mock('../../lib/analytics/client', () => ({ trackCtaClick, - trackExternalLinkClick: vi.fn(), + trackExternalLinkClick, })); describe('Docs mobile navigation', () => { beforeEach(() => { window.localStorage.clear(); trackCtaClick.mockClear(); + trackExternalLinkClick.mockClear(); pathnameRef.current = '/docs/langgraph/guides/streaming'; }); @@ -106,106 +108,281 @@ describe('Docs mobile navigation', () => { return { focus, observation }; }; - it('retires Examples from desktop navigation without changing primary destinations or demos', () => { + it('opens a panel per trigger and links each library from it', () => { pathnameRef.current = '/'; render(