test: cover the not-found page

Unit tests for the resolver and the plugin's page discovery, a locale with
its own `404.md` in the base build fixture with hosts that serve the
nearest or only the root `404.html`, and a root `404.md` with an inheriting
locale in the e2e site.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
feat/not-found
Divyansh Singh 1 week ago
parent 19062c3df0
commit f35d81ac22

@ -91,6 +91,14 @@ describe('relative base emit', () => {
expect(html).toMatch(/href="\.\/assets\/style\.[\w-]+\.css"/)
})
test('locale 404 renders at its own depth', () => {
const html = read('relative', 'zh/404.html')
expect(html).toContain(
'window.__VP_SITE_ROOT__=new URL("../",location).href'
)
expect(html).toMatch(/href="\.\.\/assets\/style\.[\w-]+\.css"/)
})
test('no sentinel leaks into emitted html or css', () => {
for (const file of walk(dist('relative'))) {
if (!/\.(html|css)$/.test(file)) continue
@ -208,3 +216,44 @@ describe('plain base emit is unchanged', () => {
expect(html).not.toContain('crossorigin>')
})
})
describe('not-found emit', () => {
for (const mode of ['plain', 'relative', 'cdn', 'mpa']) {
test(`${mode}: the theme page stands in for a missing root 404.md`, () => {
const html = read(mode, '404.html')
expect(html).toContain('<div id="app" data-vp-not-found>')
expect(html).toContain('class="NotFound"')
expect(html).toContain('<title>404 | Base Fixture</title>')
expect(html).toContain('<meta name="robots" content="noindex">')
expect(html).toContain('<html lang="en"')
})
test(`${mode}: a locale 404.md is emitted for its locale`, () => {
const html = read(mode, 'zh/404.html')
expect(html).toContain('<div id="app" data-vp-not-found>')
expect(html).toContain('页面未找到')
expect(html).toContain('<title>页面未找到 | Base Fixture</title>')
expect(html).toContain('<meta name="robots" content="noindex">')
expect(html).toContain('<html lang="zh-CN"')
expect(html).not.toContain('class="NotFound"')
})
test(`${mode}: not-found pages stay out of the sitemap`, () => {
const sitemap = read(mode, 'sitemap.xml')
expect(sitemap).toContain('<loc>https://example.com/zh/</loc>')
expect(sitemap).toContain('<loc>https://example.com/sub/page.html</loc>')
expect(sitemap).not.toContain('404.html')
})
}
test('mpa: the not-found page needs no script', () => {
const html = read('mpa', '404.html')
expect(html).not.toContain('<script type="module"')
})
test('the not-found page is pre-rendered with the site chrome', () => {
const html = read('plain', '404.html')
expect(html).toContain('class="VPNav"')
expect(html).not.toContain('class="VPSidebar"')
})
})

@ -12,6 +12,15 @@ export default defineConfig({
outDir: `.vitepress/dist-${mode}`,
cleanUrls: false,
rewrites: { 'src-moved.md': 'moved/target.md' },
sitemap: { hostname: 'https://example.com' },
locales: {
root: { label: 'English', lang: 'en' },
zh: {
label: '中文',
lang: 'zh-CN',
themeConfig: { notFound: { title: '页面未找到' } }
}
},
vite: {
logLevel: 'error',
// keep the tiny fixture images as real emitted assets

@ -0,0 +1,7 @@
---
title: 页面未找到
---
# 页面未找到
这个页面不存在。[回到首页](/zh/)

@ -0,0 +1,3 @@
# 首页
中文首页。

@ -11,14 +11,20 @@ export async function newPage(): Promise<TestPage> {
const page = await browser.newPage()
const errors: string[] = []
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text())
if (msg.type() !== 'error') return
// a failed resource is only identified by where it came from
const url = msg.location()?.url
errors.push(url ? `${msg.text()} <${url}>` : msg.text())
})
page.on('pageerror', (err) => errors.push(String(err)))
return { browser, page, errors }
}
export function realErrors(errors: string[]): string[] {
return errors.filter((e) => !e.includes('favicon'))
export function realErrors(errors: string[], ignore: string[] = []): string[] {
return errors.filter(
(e) =>
!e.includes('favicon') && !ignore.some((url) => e.includes(`<${url}>`))
)
}
export async function waitForHydration(page: Page): Promise<void> {

@ -0,0 +1,152 @@
import { SUB_PREFIX } from './constants'
import { newPage, realErrors, waitForHydration, type TestPage } from './helpers'
// the cdn build, served at the root by a host that picks the nearest 404.html
const nearest = () => `http://localhost:${process.env['PAGES_PORT']}`
// the plain build, served by a host that only knows the root 404.html
const rootOnly = () => `http://localhost:${process.env['PLAIN_PORT']}`
// the relative build, mounted under a prefix
const sub = () => `http://localhost:${process.env['SUB_PORT']}${SUB_PREFIX}`
let t: TestPage
beforeAll(async () => {
t = await newPage()
})
afterAll(async () => {
await t.page.close()
await t.browser.close()
})
beforeEach(() => {
t.errors.length = 0
})
const h1 = () => t.page.textContent('h1')
// the document of a miss is a 404 by design; anything else is a real error
const errors = () => realErrors(t.errors, [t.page.url()])
const lang = () => t.page.evaluate(() => document.documentElement.lang)
const mark = () => t.page.evaluate(() => ((window as any).__spa_marker = 1))
const marked = () => t.page.evaluate(() => (window as any).__spa_marker === 1)
describe('not-found page on a host serving the nearest 404.html', () => {
test('a miss shows the theme page when the site has no 404.md', async () => {
const res = await t.page.goto(`${nearest()}/nowhere.html`)
expect(res?.status()).toBe(404)
await waitForHydration(t.page)
expect(await t.page.textContent('.NotFound .title')).toBe('PAGE NOT FOUND')
expect(await t.page.title()).toBe('404 | Base Fixture')
expect(await t.page.getAttribute('.NotFound .link', 'href')).toBe('/')
expect(new URL(t.page.url()).pathname).toBe('/nowhere.html')
expect(errors()).toEqual([])
})
test("a miss under a locale shows that locale's 404.md", async () => {
const res = await t.page.goto(`${nearest()}/zh/guide/nowhere.html`)
expect(res?.status()).toBe(404)
await waitForHydration(t.page)
expect(await h1()).toContain('页面未找到')
expect(await lang()).toBe('zh-CN')
expect(await t.page.title()).toBe('页面未找到 | Base Fixture')
expect(errors()).toEqual([])
})
test('the theme page uses the locale text of the miss', async () => {
// the root build has no zh text of its own: only the client knows the
// locale, from the url
await t.page.goto(`${nearest()}/nowhere.html`)
await waitForHydration(t.page)
expect(await t.page.textContent('.NotFound .title')).toBe('PAGE NOT FOUND')
expect(await lang()).toBe('en')
})
test('a 404 served for a url that has a page renders that page', async () => {
// the static host has no `index` -> `index.html` rule, so this is a miss
// for the server and a real page for the client
const res = await t.page.goto(`${nearest()}/sub/index`)
expect(res?.status()).toBe(404)
await waitForHydration(t.page)
expect(await h1()).toContain('Sub index')
expect(errors()).toEqual([])
})
test('the not-found page renders at its own url', async () => {
const res = await t.page.goto(`${nearest()}/zh/404.html`)
expect(res?.status()).toBe(200)
await waitForHydration(t.page)
expect(await h1()).toContain('页面未找到')
expect(errors()).toEqual([])
})
test('client-side navigation to a miss keeps the url and recovers', async () => {
await t.page.goto(`${nearest()}/`)
await waitForHydration(t.page)
await mark()
await t.page.evaluate(() => {
const a = document.createElement('a')
a.href = '/zh/nowhere.html'
a.id = 'to-nowhere'
a.textContent = 'nowhere'
// above the fixed nav and sidebar, so the click reaches it
a.style.cssText = 'position:fixed;right:0;bottom:0;z-index:1000'
document.body.appendChild(a)
})
await t.page.click('#to-nowhere')
await t.page.waitForFunction(() =>
document.querySelector('h1')?.textContent?.includes('页面未找到')
)
expect(new URL(t.page.url()).pathname).toBe('/zh/nowhere.html')
expect(await lang()).toBe('zh-CN')
expect(await marked()).toBe(true)
await t.page.click('.vp-doc a[href="/zh/"]')
await t.page.waitForFunction(() =>
document.querySelector('h1')?.textContent?.includes('首页')
)
expect(await marked()).toBe(true)
expect(errors()).toEqual([])
})
})
describe('not-found page on a host serving only the root 404.html', () => {
test("a miss under a locale still shows that locale's page", async () => {
const res = await t.page.goto(`${rootOnly()}/zh/nowhere.html`)
expect(res?.status()).toBe(404)
await waitForHydration(t.page)
expect(await h1()).toContain('页面未找到')
expect(await lang()).toBe('zh-CN')
expect(await t.page.title()).toBe('页面未找到 | Base Fixture')
expect(errors()).toEqual([])
})
test('a miss outside any locale shows the theme page', async () => {
await t.page.goto(`${rootOnly()}/deep/nowhere.html`)
await waitForHydration(t.page)
expect(await t.page.textContent('.NotFound .title')).toBe('PAGE NOT FOUND')
expect(errors()).toEqual([])
})
})
describe('not-found page with a relative base', () => {
test('a miss under a locale is styled and localized', async () => {
const res = await t.page.goto(`${sub()}zh/nowhere.html`)
expect(res?.status()).toBe(404)
await waitForHydration(t.page)
expect(await h1()).toContain('页面未找到')
expect(await t.page.evaluate(() => (window as any).__VP_SITE_ROOT__)).toBe(
sub()
)
expect(errors()).toEqual([])
})
test('a root-level miss shows the theme page', async () => {
await t.page.goto(`${sub()}nowhere.html`)
await waitForHydration(t.page)
expect(await t.page.textContent('.NotFound .title')).toBe('PAGE NOT FOUND')
expect(await t.page.getAttribute('.NotFound .link', 'href')).toBe(
SUB_PREFIX
)
expect(errors()).toEqual([])
})
})

@ -23,11 +23,17 @@ const types: Record<string, string> = {
'.zip': 'application/zip'
}
// how a miss is answered: with the nearest 404.html up the directory tree
// (cloudflare pages, gitlab pages), with the root one only (github pages,
// netlify, vercel), or with nothing (an asset cdn)
type NotFoundMode = 'nearest' | 'root' | 'none'
// listens on an os-assigned port (the other suites run in parallel on CI,
// so a pre-picked "free" port can be taken before we bind it)
function serveStatic(
mounts: [prefix: string, root: string][],
cors: boolean
cors: boolean,
notFound: NotFoundMode = 'none'
): Promise<Server> {
const server = createServer(async (req, res) => {
const url = decodeURIComponent(new URL(req.url!, 'http://x').pathname)
@ -45,6 +51,18 @@ function serveStatic(
res.end(data)
return
} catch {}
if (notFound === 'none') break
const dirs = notFound === 'nearest' ? file.split('/').slice(0, -1) : []
for (let depth = dirs.length; depth >= 0; depth--) {
try {
const data = await readFile(
join(root, ...dirs.slice(0, depth), '404.html')
)
res.writeHead(404, { 'content-type': 'text/html' })
res.end(data)
return
} catch {}
}
}
res.writeHead(404)
res.end('not found')
@ -88,10 +106,13 @@ export async function setup() {
[SUB_PREFIX, dist('relative')],
[ALT_PREFIX, dist('relative')]
],
false
false,
'nearest'
),
await serveStatic([['/', dist('cdn')]], false),
cdnServer
await serveStatic([['/', dist('cdn')]], false, 'nearest'),
cdnServer,
// a host that only knows the root 404.html
await serveStatic([['/', dist('plain')]], false, 'root')
]
browserServer = await chromium.launchServer({
@ -105,6 +126,7 @@ export async function setup() {
process.env['SUB_PORT'] = String(portOf(servers[0]!))
process.env['PAGES_PORT'] = String(portOf(servers[1]!))
process.env['VP_CDN_PORT'] = String(cdnPort)
process.env['PLAIN_PORT'] = String(portOf(servers[3]!))
}
export async function teardown() {

@ -197,6 +197,11 @@ const sidebar: DefaultTheme.Config['sidebar'] = {
export default defineConfig({
title: 'Example',
description: 'An example app using VitePress.',
// a locale without a 404.md of its own inherits the root one
locales: {
root: { label: 'English', lang: 'en' },
fr: { label: 'Français', lang: 'fr' }
},
srcExclude: ['**/parts/**'],
markdown: {
image: { lazyLoad: true }

@ -0,0 +1,7 @@
---
title: Not found
---
# Custom not found
This page does not exist. [Go home](/)

@ -0,0 +1,3 @@
# Accueil
Page française.

@ -0,0 +1,118 @@
const origin = () => `http://localhost:${process.env['PORT']}`
const status = (path: string) =>
page.evaluate(
async (path) =>
(await fetch(path, { headers: { accept: 'text/html' } })).status,
path
)
describe('not found page', () => {
test('a missing url renders the site 404.md with a 404 status', async () => {
const res = await page.goto(`${origin()}/missing`)
expect(res?.status()).toBe(404)
await page.waitForSelector('#app .Layout')
expect(await page.textContent('h1')).toContain('Custom not found')
expect(await page.title()).toBe('Not found | Example')
// the address bar keeps what the visitor typed
expect(new URL(page.url()).pathname).toBe('/missing')
// a doc page without the doc chrome
expect(
await page.locator('.VPContent').getAttribute('class')
).not.toContain('has-sidebar')
expect(await page.locator('.VPDocFooter .pager-link').count()).toBe(0)
expect(await page.locator('.VPDocFooter .edit-link').count()).toBe(0)
})
test('a locale without its own 404.md inherits the root one', async () => {
const res = await page.goto(`${origin()}/fr/guide/missing`)
expect(res?.status()).toBe(404)
await page.waitForSelector('#app .Layout')
expect(await page.textContent('h1')).toContain('Custom not found')
expect(await page.title()).toBe('Not found | Example')
expect(await page.evaluate(() => document.documentElement.lang)).toBe('fr')
// the locale menu marks the locale of the miss
expect(
await page.locator('.VPNavBarTranslations .items a').first().textContent()
).toContain('English')
})
test('the not-found page also renders at its own url', async () => {
await goto('/404')
expect(await page.textContent('h1')).toContain('Custom not found')
})
test('client-side navigation to a missing url keeps the url', async () => {
await goto('/')
await page.evaluate(() => {
;(window as any).__spa_marker = 1
const a = document.createElement('a')
a.href = '/nested/nowhere'
a.id = 'to-nowhere'
a.textContent = 'nowhere'
// above the fixed nav and sidebar, so the click reaches it
a.style.cssText = 'position:fixed;right:0;bottom:0;z-index:1000'
document.body.appendChild(a)
})
await page.click('#to-nowhere')
await page.waitForFunction(() =>
document.querySelector('h1')?.textContent?.includes('Custom not found')
)
expect(new URL(page.url()).pathname).toBe('/nested/nowhere.html')
expect(await page.evaluate(() => (window as any).__spa_marker)).toBe(1)
// and back to a real page
await page.click('.vp-doc a[href="/"]')
await page.waitForSelector('.VPHome')
expect(new URL(page.url()).pathname).toBe('/')
expect(await page.evaluate(() => (window as any).__spa_marker)).toBe(1)
})
test('the server answers with the right status codes', async () => {
await goto('/')
expect(await status('/missing')).toBe(404)
expect(await status('/missing.html')).toBe(404)
expect(await status('/nested/nowhere/')).toBe(404)
expect(await status('/')).toBe(200)
expect(await status('/home.html')).toBe(200)
expect(await status('/404.html')).toBe(200)
})
test.runIf(process.env['VITE_TEST_BUILD'])(
'the emitted page is pre-rendered, marked and not indexed',
async () => {
await goto('/')
const html = await page.evaluate(async () =>
(await fetch('/missing', { headers: { accept: 'text/html' } })).text()
)
expect(html).toContain('<div id="app" data-vp-not-found>')
expect(html).toContain('Custom not found')
expect(html).toContain('<meta name="robots" content="noindex">')
const frHtml = await page.evaluate(async () =>
(await fetch('/fr/404.html')).text()
)
expect(frHtml).toContain('<html lang="fr"')
expect(frHtml).toContain('Custom not found')
expect(frHtml).toContain('<title>Not found | Example</title>')
}
)
test.runIf(process.env['VITE_TEST_BUILD'])(
'a pre-rendered miss loads the page chunk once',
async () => {
await page.goto(`${origin()}/missing`)
await page.waitForSelector('#app .Layout')
const chunks = await page.evaluate(() =>
performance
.getEntriesByType('resource')
.map((e) => e.name)
.filter((name) => /\/404\.md\./.test(name))
)
expect(chunks).toHaveLength(1)
expect(chunks[0]).not.toContain('.lean.js')
}
)
})

@ -0,0 +1,129 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { resolveConfig } from 'node/config'
import { notFoundPlugin } from 'node/plugins/notFoundPlugin'
import { normalizePath } from 'vite'
const locales = `locales: { root: { label: 'English', lang: 'en' }, zh: { label: '中文', lang: 'zh-CN' } }`
async function site(files: Record<string, string>, config = '') {
const root = mkdtempSync(join(tmpdir(), 'vp-not-found-'))
mkdirSync(join(root, '.vitepress'), { recursive: true })
writeFileSync(
join(root, '.vitepress/config.ts'),
`export default { ${locales}, ${config} }`
)
for (const [file, content] of Object.entries(files)) {
mkdirSync(join(root, file, '..'), { recursive: true })
writeFileSync(join(root, file), content)
}
const siteConfig = await resolveConfig(root, 'build', 'production')
const plugin = notFoundPlugin(siteConfig)
const hooks = {
resolveId: (id: string) =>
(plugin.resolveId as any).handler.call(undefined, id, undefined, {}),
load: (id: string) => (plugin.load as any).handler.call(undefined, id)
}
const file = (page: string) => normalizePath(join(siteConfig.srcDir, page))
return {
siteConfig,
...hooks,
file,
cleanup: () => rmSync(root, { recursive: true })
}
}
describe('node/plugins/notFoundPlugin', () => {
test('synthesizes the pages nobody wrote', async () => {
const s = await site({ 'index.md': '# Home' })
try {
expect(s.siteConfig.notFoundPages).toEqual([
{ path: '404.md', source: null },
{ path: 'zh/404.md', source: null }
])
expect(s.resolveId('/zh/404.md')).toBe(s.file('zh/404.md'))
expect(s.resolveId('/zh/404.md?t=123')).toBe(s.file('zh/404.md'))
expect(s.load(s.file('404.md'))).toContain('<NotFound />')
expect(s.load(s.file('zh/404.md'))).toContain('<NotFound />')
} finally {
s.cleanup()
}
})
test('a locale without its own page re-exports the root one', async () => {
const s = await site({ 'index.md': '# Home', '404.md': '# Lost' })
try {
expect(s.siteConfig.pages).not.toContain('404.md')
expect(s.siteConfig.notFoundPages).toEqual([
{ path: '404.md', source: '404.md' },
{ path: 'zh/404.md', source: null }
])
// the authored page loads as a file
expect(s.resolveId(s.file('404.md'))).toBeUndefined()
expect(s.load(s.file('404.md'))).toBeUndefined()
// the inherited one is a virtual js module around it
const id = s.resolveId('/zh/404.md')
expect(id).toBe('\0' + s.file('zh/404.md'))
const code = s.load(id)
expect(code).toContain(`from ${JSON.stringify(s.file('404.md'))}`)
expect(code).toContain(`relativePath: "zh/404.md"`)
expect(code).not.toContain('<NotFound />')
} finally {
s.cleanup()
}
})
test('a rewrite can move a page onto the not-found path', async () => {
const s = await site(
{ 'index.md': '# Home', 'errors/lost.md': '# Lost' },
`rewrites: { 'errors/lost.md': '404.md' }`
)
try {
expect(s.siteConfig.pages).not.toContain('errors/lost.md')
expect(s.siteConfig.notFoundPages[0]).toEqual({
path: '404.md',
source: 'errors/lost.md'
})
expect(s.load(s.file('zh/404.md'))).toContain(
`from ${JSON.stringify(s.file('errors/lost.md'))}`
)
} finally {
s.cleanup()
}
})
test('a rewrite moving 404.md away leaves the file a regular page', async () => {
const s = await site(
{ 'index.md': '# Home', '404.md': '# Moved' },
`rewrites: { '404.md': 'moved/404.md' }`
)
try {
expect(s.siteConfig.pages).toContain('404.md')
expect(s.siteConfig.notFoundPages).toEqual([
{ path: '404.md', source: null },
{ path: 'zh/404.md', source: null }
])
// the file keeps its own content; the not-found page is synthesized
// from the theme instead
expect(s.resolveId(s.file('404.md'))).toBeUndefined()
expect(s.load(s.file('404.md'))).toBeUndefined()
expect(s.load(s.file('zh/404.md'))).toContain('<NotFound />')
} finally {
s.cleanup()
}
})
test('leaves sub-requests to the module that owns them', async () => {
const s = await site({ 'index.md': '# Home', '404.md': '# Lost' })
try {
expect(
s.resolveId('/zh/404.md?vue&type=style&index=0&lang.css')
).toBeUndefined()
expect(s.load(s.file('zh/404.md') + '?vue&type=style')).toBeUndefined()
} finally {
s.cleanup()
}
})
})

@ -1,12 +1,61 @@
import {
createNotFoundPageData,
isRelativeBase,
joinPath,
mergeHead,
relativePathToRoot,
type HeadConfig
resolveNotFoundPage,
type HeadConfig,
type SiteData
} from 'shared/shared'
describe('shared/shared', () => {
describe('resolveNotFoundPage', () => {
const site = {
locales: {
root: { label: 'English', lang: 'en' },
zh: { label: '中文', lang: 'zh-CN' },
'fr-FR': { label: 'Français', lang: 'fr-FR' },
'https://example.com/': { label: 'External' }
}
} as unknown as SiteData
test('picks the not-found page of the path locale', () => {
expect(resolveNotFoundPage(site, 'zh/guide/missing.md')).toBe('zh/404.md')
expect(resolveNotFoundPage(site, 'zh/guide/missing')).toBe('zh/404.md')
expect(resolveNotFoundPage(site, 'zh/')).toBe('zh/404.md')
expect(resolveNotFoundPage(site, 'fr-FR/missing')).toBe('fr-FR/404.md')
})
test('falls back to the root page', () => {
expect(resolveNotFoundPage(site, 'guide/missing.md')).toBe('404.md')
expect(resolveNotFoundPage(site, '')).toBe('404.md')
// a page named after the locale is not inside the locale directory
expect(resolveNotFoundPage(site, 'zh')).toBe('404.md')
// locale keys that are links to other sites never match
expect(resolveNotFoundPage(site, 'https://example.com/x')).toBe('404.md')
})
test('has only the root page without locales', () => {
expect(resolveNotFoundPage({} as SiteData, 'zh/missing')).toBe('404.md')
expect(resolveNotFoundPage(undefined, 'zh/missing')).toBe('404.md')
})
})
describe('createNotFoundPageData', () => {
test('is a virtual page of the given path', () => {
expect(createNotFoundPageData('zh/404.md')).toEqual({
relativePath: 'zh/404.md',
filePath: '',
title: '404',
description: 'Not Found',
headers: [],
frontmatter: {},
isNotFound: true
})
})
})
describe('mergeHead', () => {
test('replaces meta tags with the same key in place', () => {
expect(

Loading…
Cancel
Save