From ce395bd18118bb9f827d84d854e2cd52b1bc2341 Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:19:59 +0530 Subject: [PATCH] test(e2e/router): exercise both router strategies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a parameterised e2e suite that runs the same 22 scenarios against both router implementations (Navigation API and legacy History API). The legacy strategy is forced on via `window.__VP_USE_LEGACY_ROUTER__`, set via Playwright `addInitScript` on a second browser context — the shared browser and default `page` / `goto` from vitestSetup.ts are reused for the Navigation API run, so we don't relaunch Playwright. Scenarios covered, for each mode: - strategy selection reflects the flag - link click performs SPA navigation (no full reload) - hash anchor updates URL, scrolls into view, focuses target heading - back / forward navigate between entries - scroll position restored on back traversal - onBeforeRouteChange / onAfterRouteChange fire for full and hash navs - onBeforeRouteChange returning false cancels the nav - programmatic router.go - router.go with { replace: true } - same-URL click scrolls without pushing a new history entry - onBeforePageLoad / onAfterPageLoad fire - onBeforePageLoad returning false cancels page load - links inside .vp-raw trigger a full reload - external / download / target / non-HTML links are not intercepted - clicks on buttons inside links are not intercepted - route.path / route.hash / route.query stay in sync - unknown paths fall back to the 404 page - route.data.relativePath is populated from the loaded page A small init-script-installed `resolveRouterOrThrow()` finds the router via the Vue app's provides, so `page.evaluate` call sites stay terse and typed against the public `Router` interface — no `any` in test code. To support the flag, `hasNavigationApi()` now honours `window.__VP_USE_LEGACY_ROUTER__` as a forced opt-out; this is documented in-source as a test-only hook. --- __tests__/e2e/router/router.test.ts | 548 +++++++++++++++++++++++++ src/client/app/router/navigationApi.ts | 7 +- 2 files changed, 554 insertions(+), 1 deletion(-) create mode 100644 __tests__/e2e/router/router.test.ts diff --git a/__tests__/e2e/router/router.test.ts b/__tests__/e2e/router/router.test.ts new file mode 100644 index 000000000..b46d42b52 --- /dev/null +++ b/__tests__/e2e/router/router.test.ts @@ -0,0 +1,548 @@ +import type { BrowserContext, Page } from 'playwright-chromium' +import type { Router } from 'vitepress' + +/** + * The same suite runs twice — once with the Navigation API strategy (the + * default in modern Chromium) and once with the legacy History-API + * strategy, forced on by setting `window.__VP_USE_LEGACY_ROUTER__` before + * the app boots. This gives us a single source of truth for user-facing + * router behaviour across both implementations. + * + * The default (nav-api) mode reuses the shared `page` / `goto` created by + * vitestSetup.ts; the legacy mode adds a second browser context on the + * same Playwright browser with the flag pre-injected via an init script. + */ + +// ---- Types shared between Node-side helpers and browser-side evaluate bodies. +// These types are erased at runtime; they only help TS check the callbacks +// passed to `page.evaluate`. + +interface VueAppInternals { + _context: { provides: Record } +} +type AppEl = HTMLElement & { __vue_app__?: VueAppInternals } + +declare global { + interface Window { + __VP_USE_LEGACY_ROUTER__?: boolean + __spaMarker?: number + __hookLog?: Array<['before' | 'after', string]> + resolveRouterOrThrow: () => Router + } +} + +// Browser-side helper: finds the router via the Vue app on `#app` so every +// `page.evaluate(...)` below can just call `window.resolveRouterOrThrow()` instead +// of duplicating the provides-walking logic. Registered via init script on +// both browser contexts so it runs before any client code. +const RESOLVE_ROUTER_INIT_SCRIPT = (): void => { + window.resolveRouterOrThrow = function () { + const app = (document.querySelector('#app') as AppEl | null)?.__vue_app__ + if (!app) throw new Error('[router test] Vue app not mounted on #app') + const provides = app._context.provides + for (const sym of Object.getOwnPropertySymbols(provides)) { + const v = provides[sym] + if (v && typeof (v as Router).go === 'function' && (v as Router).route) { + return v as Router + } + } + throw new Error('[router test] router not found in provides') + } +} + +// ---- Legacy-mode page, reusing the browser from the shared setup ---- + +let legacyContext: BrowserContext +let legacyPage: Page +let legacyGoto: (path: string) => Promise + +beforeAll(async () => { + const browser = page.context().browser() + if (!browser) throw new Error('shared browser is not available') + + // Default context (nav-api mode) — inject the resolver. + await page.context().addInitScript(RESOLVE_ROUTER_INIT_SCRIPT) + + // Legacy context — flag the router off, then inject the resolver. + legacyContext = await browser.newContext() + await legacyContext.addInitScript(() => { + window.__VP_USE_LEGACY_ROUTER__ = true + }) + await legacyContext.addInitScript(RESOLVE_ROUTER_INIT_SCRIPT) + legacyPage = await legacyContext.newPage() + legacyGoto = async (path) => { + await legacyPage.goto(`http://localhost:${process.env['PORT']}${path}`) + await legacyPage.waitForSelector('#app .Layout') + } +}) + +afterAll(async () => { + await legacyPage.close() + await legacyContext.close() +}) + +// ---- Same scenarios, parameterised over both strategies ---- + +interface Mode { + name: 'navigation-api' | 'legacy' + getPage: () => Page + getGoto: () => (path: string) => Promise +} + +const MODES: Mode[] = [ + { name: 'navigation-api', getPage: () => page, getGoto: () => goto }, + { name: 'legacy', getPage: () => legacyPage, getGoto: () => legacyGoto } +] + +for (const mode of MODES) { + describe(`router [${mode.name}]`, () => { + let p: Page + let visit: (path: string) => Promise + + beforeAll(() => { + p = mode.getPage() + visit = mode.getGoto() + }) + + test('selects the expected router strategy', async () => { + await visit('/home') + const isLegacy = await p.evaluate( + () => window.__VP_USE_LEGACY_ROUTER__ === true + ) + expect(isLegacy).toBe(mode.name === 'legacy') + + // Navigation API is present in this Chromium in both modes; the flag + // is the only thing distinguishing the two runs. + const hasNavigation = await p.evaluate(() => 'navigation' in window) + expect(hasNavigation).toBe(true) + }) + + test('link click performs a SPA navigation (no full reload)', async () => { + await visit('/home') + // Tag the window — a full document reload would clear this. + await p.evaluate(() => { + window.__spaMarker = 42 + }) + + await p.click( + '.VPSidebar a[href="/frontmatter/multiple-levels-outline.html"]' + ) + await p.waitForURL(/\/frontmatter\/multiple-levels-outline/) + await p.waitForSelector('h1#h1-1') + + const marker = await p.evaluate(() => window.__spaMarker) + expect(marker).toBe(42) + expect(await p.textContent('h1#h1-1')).toMatch('h1 - 1') + }) + + test('hash anchor click updates URL, scrolls and focuses the target', async () => { + await visit('/frontmatter/multiple-levels-outline') + await p.evaluate(() => window.scrollTo(0, 0)) + + await p.click('.VPDocAsideOutline a[href="#h3-2"]') + await p.waitForFunction(() => location.hash === '#h3-2') + // Focus is applied via requestAnimationFrame; wait for it explicitly. + await p.waitForFunction(() => document.activeElement?.id === 'h3-2') + + const state = await p.evaluate(() => ({ + hash: location.hash, + scrollY: window.scrollY, + focusId: document.activeElement?.id ?? '', + targetTop: + document.getElementById('h3-2')?.getBoundingClientRect().top ?? 0 + })) + + expect(state.hash).toBe('#h3-2') + expect(state.focusId).toBe('h3-2') + expect(state.scrollY).toBeGreaterThan(0) + // Both paths respect scroll-margin, so the target lands near the top. + expect(state.targetTop).toBeLessThan(200) + }) + + test('back and forward navigate between entries', async () => { + await visit('/home') + await p.click( + '.VPSidebar a[href="/frontmatter/multiple-levels-outline.html"]' + ) + await p.waitForURL(/\/frontmatter\/multiple-levels-outline/) + await waitForH1(p, /h1 - 1/) + + await p.goBack() + await p.waitForURL(/\/home(\.html)?$/) + await waitForH1(p, /Lorem Ipsum/) + + await p.goForward() + await p.waitForURL(/\/frontmatter\/multiple-levels-outline/) + await waitForH1(p, /h1 - 1/) + }) + + test('scroll position is restored on back traversal', async () => { + await visit('/frontmatter/multiple-levels-outline') + await p.evaluate(() => { + const max = document.documentElement.scrollHeight - window.innerHeight + window.scrollTo(0, Math.min(400, max)) + }) + await p.waitForFunction(() => window.scrollY > 100) + const scrollBefore = await p.evaluate(() => window.scrollY) + + await callRouterGo(p, '/home') + await p.waitForURL(/\/home(\.html)?$/) + await waitForH1(p, /Lorem Ipsum/) + + await p.goBack() + await p.waitForURL(/\/frontmatter\/multiple-levels-outline/) + await waitForH1(p, /h1 - 1/) + await p.waitForFunction( + (expected) => Math.abs(window.scrollY - expected) < 50, + scrollBefore, + { timeout: 5_000 } + ) + const scrollAfter = await p.evaluate(() => window.scrollY) + expect(Math.abs(scrollAfter - scrollBefore)).toBeLessThan(50) + }) + + test('onBeforeRouteChange / onAfterRouteChange fire for full navigations', async () => { + await visit('/home') + await installHookSpy(p) + + await p.click( + '.VPSidebar a[href="/frontmatter/multiple-levels-outline.html"]' + ) + await p.waitForURL(/\/frontmatter\/multiple-levels-outline/) + await p.waitForFunction(() => (window.__hookLog?.length ?? 0) >= 2) + + const log = await p.evaluate(() => window.__hookLog ?? []) + expect(log[0][0]).toBe('before') + expect(log[log.length - 1][0]).toBe('after') + expect(log[0][1]).toMatch(/multiple-levels-outline/) + }) + + test('onBeforeRouteChange / onAfterRouteChange fire for hash-only navs', async () => { + await visit('/frontmatter/multiple-levels-outline') + await installHookSpy(p) + + await p.click('.VPDocAsideOutline a[href="#h2-1"]') + await p.waitForFunction(() => location.hash === '#h2-1') + await p.waitForFunction(() => (window.__hookLog?.length ?? 0) >= 2) + + const log = await p.evaluate(() => window.__hookLog ?? []) + expect(log[0][0]).toBe('before') + expect(log[log.length - 1][0]).toBe('after') + expect(log[0][1]).toMatch(/#h2-1$/) + }) + + test('onBeforeRouteChange returning false cancels the page load', async () => { + await visit('/home') + await p.evaluate(() => { + window.resolveRouterOrThrow().onBeforeRouteChange = () => false + }) + + await p.click( + '.VPSidebar a[href="/frontmatter/multiple-levels-outline.html"]' + ) + // Give the click a chance to process; content should NOT change. + await p.waitForTimeout(400) + + expect(await p.textContent('.VPContent h1')).toMatch('Lorem Ipsum') + }) + + test('programmatic router.go navigates and updates route state', async () => { + await visit('/home') + await callRouterGo(p, '/frontmatter/multiple-levels-outline') + + await p.waitForURL(/\/frontmatter\/multiple-levels-outline/) + await waitForH1(p, /h1 - 1/) + }) + + test('router.go with { replace: true } replaces the history entry', async () => { + await visit('/home') + await visit('/multi-sidebar/') + const lenBefore = await p.evaluate(() => history.length) + + await callRouterGo(p, '/frontmatter/multiple-levels-outline', { + replace: true + }) + await p.waitForURL(/\/frontmatter\/multiple-levels-outline/) + await waitForH1(p, /h1 - 1/) + expect(await p.evaluate(() => history.length)).toBe(lenBefore) + + // /multi-sidebar/ was replaced by /frontmatter/... so goBack lands + // on /home (the entry before /multi-sidebar/), not on /multi-sidebar/. + await p.goBack() + await p.waitForURL(/\/home(\.html)?$/) + await waitForH1(p, /Lorem Ipsum/) + }) + + test('same-URL click scrolls to hash without creating a new history entry', async () => { + await visit('/frontmatter/multiple-levels-outline') + // Click once to seed a hash in the URL. + await p.click('.VPDocAsideOutline a[href="#h2-1"]') + await p.waitForFunction(() => location.hash === '#h2-1') + const lenAfterFirstClick = await p.evaluate(() => history.length) + + // Click again on the same hash — URL already matches, no new entry. + await p.click('.VPDocAsideOutline a[href="#h2-1"]') + await p.waitForTimeout(300) + expect(await p.evaluate(() => history.length)).toBe(lenAfterFirstClick) + expect(await p.evaluate(() => location.hash)).toBe('#h2-1') + }) + + test('onBeforePageLoad / onAfterPageLoad fire during loadPage', async () => { + await visit('/home') + await p.evaluate(() => { + const router = window.resolveRouterOrThrow() + window.__hookLog = [] + router.onBeforePageLoad = (to: string) => { + window.__hookLog!.push(['before', to]) + } + router.onAfterPageLoad = (to: string) => { + window.__hookLog!.push(['after', to]) + } + }) + + await p.click( + '.VPSidebar a[href="/frontmatter/multiple-levels-outline.html"]' + ) + await p.waitForURL(/\/frontmatter\/multiple-levels-outline/) + await p.waitForFunction(() => (window.__hookLog?.length ?? 0) >= 2) + + const log = await p.evaluate(() => window.__hookLog ?? []) + expect(log[0][0]).toBe('before') + expect(log[log.length - 1][0]).toBe('after') + expect(log[0][1]).toMatch(/multiple-levels-outline/) + }) + + test('onBeforePageLoad returning false cancels page load (but still fires onAfterRouteChange)', async () => { + await visit('/home') + await p.evaluate(() => { + const router = window.resolveRouterOrThrow() + router.onBeforePageLoad = () => false + }) + + await p.click( + '.VPSidebar a[href="/frontmatter/multiple-levels-outline.html"]' + ) + await p.waitForTimeout(400) + // Content should stay on /home since loadPage bailed out. + expect(await p.textContent('.VPContent h1')).toMatch('Lorem Ipsum') + }) + + test('links inside .vp-raw are not intercepted (full page reload)', async () => { + await visit('/home') + // Inject a .vp-raw-wrapped link and trigger its click via the DOM + // API — sidebar overlays would otherwise block a real click. A full + // document reload will clear the window tag we set below. + await p.evaluate(() => { + window.__spaMarker = 99 + const wrapper = document.createElement('div') + wrapper.className = 'vp-raw' + wrapper.style.position = 'fixed' + wrapper.style.top = '0' + wrapper.style.left = '0' + wrapper.style.zIndex = '9999' + const link = document.createElement('a') + link.id = 'vp-raw-link' + link.href = '/frontmatter/multiple-levels-outline.html' + link.textContent = 'raw link' + wrapper.appendChild(link) + document.body.appendChild(wrapper) + ;(link as HTMLAnchorElement).click() + }) + await p.waitForURL(/\/frontmatter\/multiple-levels-outline/) + const marker = await p.evaluate(() => window.__spaMarker) + expect(marker).toBeUndefined() + }) + + test('external links are not intercepted', async () => { + await visit('/home') + // Don't actually follow the link cross-origin; just verify the + // router's capture-phase handler did NOT call preventDefault, which + // would leave the anchor's default navigation intact. + const intercepted = await p.evaluate(() => { + const link = document.createElement('a') + link.href = 'https://example.com/' + link.textContent = 'ext' + document.body.appendChild(link) + let defaultPrevented = false + link.addEventListener( + 'click', + (e) => { + defaultPrevented = e.defaultPrevented + e.preventDefault() // stop the actual navigation away + }, + // Runs after the router's capture-phase handler. + { capture: false } + ) + link.click() + link.remove() + return defaultPrevented + }) + expect(intercepted).toBe(false) + }) + + test('links with download attribute are not intercepted', async () => { + await visit('/home') + const intercepted = await p.evaluate(() => { + const link = document.createElement('a') + link.href = '/frontmatter/multiple-levels-outline.html' + link.setAttribute('download', 'x.html') + link.textContent = 'dl' + document.body.appendChild(link) + let defaultPrevented = false + link.addEventListener('click', (e) => { + defaultPrevented = e.defaultPrevented + e.preventDefault() + }) + link.click() + link.remove() + return defaultPrevented + }) + expect(intercepted).toBe(false) + }) + + test('links with target attribute are not intercepted', async () => { + await visit('/home') + const intercepted = await p.evaluate(() => { + const link = document.createElement('a') + link.href = '/frontmatter/multiple-levels-outline.html' + link.target = '_self' + link.textContent = 't' + document.body.appendChild(link) + let defaultPrevented = false + link.addEventListener('click', (e) => { + defaultPrevented = e.defaultPrevented + e.preventDefault() + }) + link.click() + link.remove() + return defaultPrevented + }) + expect(intercepted).toBe(false) + }) + + test('links to non-HTML paths are not intercepted', async () => { + await visit('/home') + const intercepted = await p.evaluate(() => { + const link = document.createElement('a') + link.href = '/some-file.zip' + link.textContent = 'zip' + document.body.appendChild(link) + let defaultPrevented = false + link.addEventListener('click', (e) => { + defaultPrevented = e.defaultPrevented + e.preventDefault() + }) + link.click() + link.remove() + return defaultPrevented + }) + expect(intercepted).toBe(false) + }) + + test('clicks on buttons inside links are not intercepted', async () => { + // docsearch-style layout: ``. Clicking + // the inner button should run the button's handler, not navigate. + await visit('/home') + const { defaultPrevented, buttonHandled } = await p.evaluate(() => { + const link = document.createElement('a') + link.href = '/frontmatter/multiple-levels-outline.html' + const button = document.createElement('button') + button.textContent = 'action' + link.appendChild(button) + document.body.appendChild(link) + + let buttonHandled = false + button.addEventListener('click', () => { + buttonHandled = true + }) + // Read defaultPrevented after the router's capture-phase click + // listener (legacy mode) would have run. The button's own bubble + // listener above doesn't call preventDefault. + let defaultPrevented = false + link.addEventListener('click', (e) => { + defaultPrevented = e.defaultPrevented + e.preventDefault() // stop the actual navigation away + }) + + button.click() + link.remove() + return { defaultPrevented, buttonHandled } + }) + expect(buttonHandled).toBe(true) + expect(defaultPrevented).toBe(false) + }) + + test('route state (path, hash, query) stays in sync', async () => { + await visit('/home') + await callRouterGo(p, '/frontmatter/multiple-levels-outline?x=1#h3-2') + await p.waitForURL(/\/frontmatter\/multiple-levels-outline/) + await p.waitForFunction(() => location.hash === '#h3-2') + + const state = await p.evaluate(() => { + const router = window.resolveRouterOrThrow() + return { + path: router.route.path, + hash: router.route.hash, + query: router.route.query + } + }) + expect(state.path).toMatch(/\/frontmatter\/multiple-levels-outline/) + expect(state.hash).toBe('#h3-2') + expect(state.query).toBe('?x=1') + }) + + test('unknown paths fall back to the 404 page', async () => { + await visit('/definitely-not-a-real-page') + // Default theme's NotFound renders this copy. + await p.waitForFunction(() => + /PAGE NOT FOUND/i.test(document.body.textContent ?? '') + ) + expect(await p.textContent('body')).toMatch(/PAGE NOT FOUND/i) + }) + + test('route.data.relativePath is set from the loaded page', async () => { + await visit('/frontmatter/multiple-levels-outline') + const relPath = await p.evaluate( + () => window.resolveRouterOrThrow().route.data.relativePath + ) + expect(relPath).toBe('frontmatter/multiple-levels-outline.md') + }) + }) +} + +// ---- Node-side helpers ---- + +async function waitForH1(p: Page, pattern: RegExp): Promise { + await p.waitForFunction( + (src: string) => + new RegExp(src).test( + document.querySelector('.VPContent h1')?.textContent ?? '' + ), + pattern.source, + { timeout: 5_000 } + ) +} + +async function callRouterGo( + p: Page, + href: string, + options?: { replace?: boolean } +): Promise { + await p.evaluate( + ({ to, opts }) => window.resolveRouterOrThrow().go(to, opts), + { to: href, opts: options } + ) +} + +async function installHookSpy(p: Page): Promise { + await p.evaluate(() => { + const router = window.resolveRouterOrThrow() + window.__hookLog = [] + router.onBeforeRouteChange = (to: string) => { + window.__hookLog!.push(['before', to]) + } + router.onAfterRouteChange = (to: string) => { + window.__hookLog!.push(['after', to]) + } + }) +} diff --git a/src/client/app/router/navigationApi.ts b/src/client/app/router/navigationApi.ts index d16d0ea42..15db6921a 100644 --- a/src/client/app/router/navigationApi.ts +++ b/src/client/app/router/navigationApi.ts @@ -14,10 +14,15 @@ import { * Feature-detect the Navigation API. We gate on `sourceElement` since that * property is newer than `intercept()` itself — its presence gives us a * sufficiently complete implementation to replace the legacy listeners. + * + * Setting `window.__VP_USE_LEGACY_ROUTER__ = true` before the app boots + * forces the legacy strategy; this is used by the e2e suite to run the + * same tests against both router implementations. */ export function hasNavigationApi(): boolean { + if (typeof window === 'undefined') return false + if ((window as any).__VP_USE_LEGACY_ROUTER__) return false return ( - typeof window !== 'undefined' && 'navigation' in window && typeof NavigateEvent !== 'undefined' && 'sourceElement' in NavigateEvent.prototype