diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d38b323..060ef87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,43 @@ jobs: - name: Build run: pnpm run build + e2e: + if: ${{ github.event.pull_request.draft == false && !startsWith(github.head_ref, 'dependabot') }} + runs-on: [arc-runner-set] + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@v5 + + - name: Install Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install + + # Not `pnpm exec playwright`: vocs ships its own playwright, and which + # copy pnpm links into node_modules/.bin varies with install order. + - name: Install Playwright browser + run: node node_modules/@playwright/test/cli.js install --with-deps chromium + + # Builds the site and serves it -- see playwright.config.ts. + - name: Run browser tests + run: pnpm run test:e2e + + - name: Upload Playwright report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + typo-check: if: ${{ github.event.pull_request.draft == false && !startsWith(github.head_ref, 'dependabot') }} runs-on: [arc-runner-set] diff --git a/.gitignore b/.gitignore index 6e10c13..d55c73c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ pnpm-debug.log* .DS_Store .vercel .gstack/ + +# playwright +test-results/ +playwright-report/ diff --git a/README.md b/README.md index f084179..8d4b7cf 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,17 @@ pnpm build pnpm preview ``` +## Tests + +Browser tests cover the SEO metadata that has to survive client-side +navigation. They build the site and serve it themselves, so no dev server needs +to be running: + +```bash +pnpm test:e2e:browsers # once, to download Chromium +pnpm test:e2e +``` + ## AI-readable formats The site auto-generates machine-readable docs: diff --git a/docs/layout.tsx b/docs/layout.tsx new file mode 100644 index 0000000..01e3641 --- /dev/null +++ b/docs/layout.tsx @@ -0,0 +1,43 @@ +import type { ReactNode } from 'react' + +// Injected by `vite.define` in vocs.config.ts so the resolved site URL is a +// compile-time constant in both the prerender and the browser bundle. +declare const __DOCS_SITE_URL__: string + +/** + * Route-dependent `` tags. + * + * Vocs evaluates `config.head` once per page while prerendering and never + * again, so anything derived from the route there freezes at whichever page + * the visitor landed on and goes stale on client-side navigation. Tags + * that depend on the current route therefore live here instead: Vocs mounts + * this component inside the router (see `virtual:consumer-components`), so it + * re-renders on every navigation, and React 19 hoists ``/`` into + * `` when prerendering *and* in the browser. The prerendered HTML keeps + * the same tags it had before -- React emits them now instead of the config + * hook. + * + * Path-independent tags (og:site_name, og:locale, twitter:site, robots) stay + * in `config.head`; re-rendering constants buys nothing. + */ +export default function Layout({ children, path }: { children: ReactNode; path: string }) { + const url = canonicalUrl(path) + return ( + <> + {url && } + {url && } + {children} + + ) +} + +/** + * `path` is the matched route, so it is already free of trailing slashes, + * query strings, and hashes. Vocs registers a `.html` alias for every page -- + * point those at the extensionless URL that actually gets prerendered. The + * catch-all 404 route (`*`) has no canonical URL, so it gets no tags. + */ +function canonicalUrl(path: string) { + if (path === '*') return undefined + return `${__DOCS_SITE_URL__}${path.replace(/\.html$/, '')}` +} diff --git a/package.json b/package.json index 52f4a05..2cb1ae1 100644 --- a/package.json +++ b/package.json @@ -6,14 +6,18 @@ "scripts": { "dev": "vocs dev", "build": "vocs build && node scripts/substitute-site-url.mjs", - "preview": "vocs preview" + "preview": "vocs preview", + "test:e2e": "node node_modules/@playwright/test/cli.js test", + "test:e2e:browsers": "node node_modules/@playwright/test/cli.js install chromium" }, "dependencies": { - "vocs": "^1.4.1", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "vocs": "^1.4.1" }, "devDependencies": { + "@playwright/test": "^1.62.1", + "@types/react": "^19.0.0", "typescript": "^5.4.5" }, "packageManager": "pnpm@10.32.1" diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..4ec851c --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from '@playwright/test' +import { previewUrl, siteUrl } from './playwright.constants' + +export default defineConfig({ + testDir: './tests', + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [['github'], ['list'], ['html', { open: 'never' }]] : 'list', + use: { + baseURL: previewUrl, + trace: 'on-first-retry', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + + // Always rebuild rather than reusing a running server: the specs assert on + // absolute URLs baked in at build time, so they are only meaningful against + // a build made with this SITE_URL. + webServer: { + command: 'pnpm run build && pnpm run preview', + url: previewUrl, + env: { SITE_URL: siteUrl }, + reuseExistingServer: false, + stdout: 'pipe', + stderr: 'pipe', + timeout: 5 * 60 * 1000, + }, +}) diff --git a/playwright.constants.ts b/playwright.constants.ts new file mode 100644 index 0000000..e2ded77 --- /dev/null +++ b/playwright.constants.ts @@ -0,0 +1,9 @@ +// Shared between playwright.config.ts (which builds and serves the site) and +// the specs (which assert against the URLs that build produced). +export const previewPort = 4173 +export const previewUrl = `http://localhost:${previewPort}` + +// Pinned so the assertions do not depend on the SITE_URL fallback chain in +// scripts/site-url.mjs, and so a wrong value cannot coincidentally match the +// origin the site is served from. +export const siteUrl = 'https://docs.example.test' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0db71af..004e52f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,12 @@ importers: specifier: ^1.4.1 version: 1.4.1(@types/node@25.5.0)(@types/react@19.2.14)(esbuild@0.27.4)(jiti@2.6.1)(lightningcss@1.32.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(typescript@5.9.3) devDependencies: + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 typescript: specifier: ^5.4.5 version: 5.9.3 @@ -394,6 +400,11 @@ packages: '@oxc-project/types@0.120.0': resolution: {integrity: sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@radix-ui/colors@3.0.0': resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==} @@ -2926,11 +2937,21 @@ packages: engines: {node: '>=18'} hasBin: true + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + playwright@1.58.2: resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} engines: {node: '>=18'} hasBin: true + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -3887,6 +3908,10 @@ snapshots: '@oxc-project/types@0.120.0': {} + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + '@radix-ui/colors@3.0.0': {} '@radix-ui/number@1.1.1': {} @@ -6743,12 +6768,20 @@ snapshots: playwright-core@1.58.2: {} + playwright-core@1.62.1: {} + playwright@1.58.2: dependencies: playwright-core: 1.58.2 optionalDependencies: fsevents: 2.3.2 + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + points-on-curve@0.2.0: {} points-on-path@0.2.1: diff --git a/tests/route-metadata.spec.ts b/tests/route-metadata.spec.ts new file mode 100644 index 0000000..6084f6e --- /dev/null +++ b/tests/route-metadata.spec.ts @@ -0,0 +1,67 @@ +import { expect, type Page, test } from '@playwright/test' +import { siteUrl } from '../playwright.constants' + +// Two pages in the same sidebar section, so one is always linked from the +// other and moving between them is a single click. +const overview = '/protocol/overview' +const basedRollups = '/protocol/based-rollups' + +test('prerendered HTML carries exactly one canonical URL, matching its own route', async ({ + request, +}) => { + for (const path of ['/', overview, basedRollups]) { + const html = await (await request.get(path)).text() + const expected = `${siteUrl}${path}` + + expect(tags(html, 'link'), `canonical on ${path}`).toEqual([ + expect.stringContaining(`href="${expected}"`), + ]) + expect(tags(html, 'meta'), `og:url on ${path}`).toEqual([ + expect.stringContaining(`content="${expected}"`), + ]) + } +}) + +test('canonical and og:url follow client-side navigation', async ({ page }) => { + await page.goto(overview) + await expectRouteMetadata(page, overview) + + await softNavigate(page, basedRollups) + await expectRouteMetadata(page, basedRollups) + + // Navigating back must not leave a stale tag behind either. + await softNavigate(page, overview) + await expectRouteMetadata(page, overview) +}) + +/** Click through to `path` and assert the router handled it without a page load. */ +async function softNavigate(page: Page, path: string) { + await page.evaluate(() => { + ;(window as Window & { didNotReload?: boolean }).didNotReload = true + }) + + await page.locator(`a[href="${path}"]:visible`).first().click() + await expect(page).toHaveURL(path) + + expect( + await page.evaluate(() => (window as Window & { didNotReload?: boolean }).didNotReload), + 'expected a soft navigation, but the document reloaded', + ).toBe(true) +} + +async function expectRouteMetadata(page: Page, path: string) { + const expected = `${siteUrl}${path}` + const canonical = page.locator('link[rel="canonical"]') + const ogUrl = page.locator('meta[property="og:url"]') + + await expect(canonical).toHaveCount(1) + await expect(canonical).toHaveAttribute('href', expected) + await expect(ogUrl).toHaveCount(1) + await expect(ogUrl).toHaveAttribute('content', expected) +} + +/** Every canonical `` / og:url `` in a raw HTML document. */ +function tags(html: string, name: 'link' | 'meta') { + const attribute = name === 'link' ? 'rel="canonical"' : 'property="og:url"' + return [...html.matchAll(new RegExp(`<${name}\\b[^>]*${attribute}[^>]*>`, 'g'))].map(([tag]) => tag) +} diff --git a/tsconfig.json b/tsconfig.json index 9482947..4d7768e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,5 +8,12 @@ "esModuleInterop": true, "skipLibCheck": true }, - "include": ["docs/**/*.ts", "docs/**/*.tsx", "vocs.config.ts"] + "include": [ + "docs/**/*.ts", + "docs/**/*.tsx", + "tests/**/*.ts", + "vocs.config.ts", + "playwright.config.ts", + "playwright.constants.ts" + ] } diff --git a/vocs.config.ts b/vocs.config.ts index 367afa6..c6f7c07 100644 --- a/vocs.config.ts +++ b/vocs.config.ts @@ -38,15 +38,19 @@ export default defineConfig({ '/': `https://vocs.dev/api/og?logo=${encodeURIComponent(ogLogoUrl)}&title=%title&description=%description`, }, - // Per-page : canonical + og:url + twitter completion. Previews get - // noindex so they never outrank production. - head({ path }) { - const url = `${siteUrl}${path}` + // Site-wide tags. Vocs renders this once per page at prerender time + // and never re-runs it in the browser, so only route-independent tags belong + // here -- canonical and og:url are emitted from docs/layout.tsx so they track + // client-side navigation. Previews get noindex so they never outrank + // production. + // + // Stays a function: Vocs treats an object-valued `head` as a path -> element + // map, and a bare ReactElement is an object, so the element form silently + // emits nothing. + head() { return React.createElement( React.Fragment, null, - React.createElement('link', { rel: 'canonical', href: url }), - React.createElement('meta', { property: 'og:url', content: url }), React.createElement('meta', { property: 'og:site_name', content: 'Taiko Docs' }), React.createElement('meta', { property: 'og:locale', content: 'en_US' }), React.createElement('meta', { name: 'twitter:site', content: '@taikoxyz' }), @@ -124,6 +128,12 @@ export default defineConfig({ ], vite: { + // docs/layout.tsx runs in the browser, where neither process.env nor the + // build-time siteUrl chain exists -- inline the resolved value instead. + define: { + __DOCS_SITE_URL__: JSON.stringify(siteUrl), + }, + plugins: [ { // Dev-server substitution: rewrites SITEURLPLACEHOLDER in .mdx files at