From e66cfa9066b813499e17431afba2f1870661c89f Mon Sep 17 00:00:00 2001 From: Baptiste Leproux Date: Tue, 20 Oct 2026 18:43:05 +0200 Subject: [PATCH] test(e2e): cover MCP, versioned previews, 404s and sitemap URLs --- README.md | 1 + test/e2e/mcp.test.ts | 82 +++++++++++++++++++++++++++++++++++++++ test/e2e/previews.test.ts | 34 ++++++++++++++++ test/e2e/routes.test.ts | 53 +++++++++++++------------ test/e2e/utils.ts | 33 ++++++++++++++++ 5 files changed, 179 insertions(+), 24 deletions(-) create mode 100644 test/e2e/mcp.test.ts create mode 100644 test/e2e/previews.test.ts create mode 100644 test/e2e/utils.ts diff --git a/README.md b/README.md index c4729eb..85c6318 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ pnpm install pnpm dev # runs the playground pnpm lint # correctness-only ESLint (no stylistic rules — see eslint.config.mjs) pnpm test # Vitest over the pure utils +pnpm test:e2e # Vitest against the built playground (`pnpm build` first) pnpm typecheck # vue-tsc across the layer + playground ``` diff --git a/test/e2e/mcp.test.ts b/test/e2e/mcp.test.ts new file mode 100644 index 0000000..1e6dc0e --- /dev/null +++ b/test/e2e/mcp.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { baseURL, fetchWithFailure, INTRO_PAGE, NOT_FOUND_SLUG, SITE_URL } from './utils' + +let requestId = 0 + +/** Calls the MCP server over streamable HTTP, without a session. */ +async function mcp(method: string, params?: Record): Promise { + const response = await fetch(new URL('/mcp', baseURL), { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: ++requestId, method, params }), + }) + if (!response.ok) { + const error = (await response.json().catch(() => undefined)) as { message?: string } | undefined + throw new Error(`MCP ${method} returned ${response.status}${error?.message ? `: ${error.message}` : ''}`) + } + + const text = await response.text() + // Streamable HTTP may answer with SSE instead of JSON. + const json = text.startsWith('{') ? text : text.split('\n').find((line) => line.startsWith('data: '))?.slice(6) + const payload = JSON.parse(json || '{}') as { result?: T; error?: { message: string } } + if (payload.error) throw new Error(`MCP ${method} failed: ${payload.error.message}`) + return payload.result as T +} + +async function listTools(): Promise { + const { tools } = await mcp<{ tools: Array<{ name: string }> }>('tools/list') + return tools.map((tool) => tool.name) +} + +/** Joins the text parts, `isError` defaults to `false`. */ +async function callTool(name: string, args: Record) { + const result = await mcp<{ content: Array<{ text: string }>; isError?: boolean }>('tools/call', { name, arguments: args }) + return { text: result.content.map((part) => part.text).join('\n'), isError: result.isError ?? false } +} + +describe('mcp', () => { + it('lists the layer tools', async () => { + expect(await listTools()).toEqual(expect.arrayContaining(['list-pages', 'get-page', 'search-docs'])) + }) + + // Clients discover the server through its card, which is written separately from the toolkit's config. + it('advertises the endpoint and the same tools in its server card', async () => { + const { response, failure } = await fetchWithFailure('/.well-known/mcp/server-card.json') + expect(response.status, failure).toBe(200) + + const card = (await response.json()) as { endpoints: Array<{ url: string }>; tools: Array<{ name: string }> } + expect(card.endpoints.map((endpoint) => endpoint.url)).toEqual([`${SITE_URL}/mcp`]) + expect(card.tools.map((tool) => tool.name).sort()).toEqual((await listTools()).sort()) + }) + + it('list-pages lists the docs pages', async () => { + const { text, isError } = await callTool('list-pages', {}) + expect(isError).toBe(false) + expect(text).toContain(`${INTRO_PAGE.path} — ${INTRO_PAGE.title}`) + }) + + it('get-page reads a page as markdown', async () => { + const { text, isError } = await callTool('get-page', { path: INTRO_PAGE.path }) + expect(isError).toBe(false) + expect(text).toMatch(new RegExp(`^# ${INTRO_PAGE.title}$`, 'm')) + }) + + it('get-page keeps only the requested sections', async () => { + const { text } = await callTool('get-page', { path: INTRO_PAGE.path, sections: ['How it works'] }) + expect(text.split('\n').filter((line) => line.startsWith('## '))).toEqual(['## How it works']) + }) + + it.each([ + [`/${NOT_FOUND_SLUG}`, 'Page not found'], + ['/getting-started', 'is a section, not a page'], + ])('get-page reports %s as an error', async (path, message) => { + const { text, isError } = await callTool('get-page', { path }) + expect(isError).toBe(true) + expect(text).toContain(message) + }) + + it('search-docs returns matching sections with their anchor', async () => { + const { text } = await callTool('search-docs', { query: 'versioned previews' }) + expect(text).toContain('/concepts/versioned-previews#') + }) +}) diff --git a/test/e2e/previews.test.ts b/test/e2e/previews.test.ts new file mode 100644 index 0000000..150a258 --- /dev/null +++ b/test/e2e/previews.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { fetchPath, fetchWithFailure, INTRO_PAGE, NOT_FOUND_SLUG } from './utils' + +// The content commit production serves, resolved from the commit under test in CI (`NUXT_DOCS_GITHUB_BRANCH`). +const head = await fetchWithFailure('/api/content/head') +const { sha } = head.response.ok ? ((await head.response.json()) as { sha: string }) : { sha: undefined } + +describe('versioned previews', () => { + it('resolves the production content commit', () => { + expect(head.response.status, head.failure).toBe(200) + expect(sha).toMatch(/^[0-9a-f]{40}$/) + }) + + it.each([ + `/blob/${sha}${INTRO_PAGE.path}`, + `/tree/main${INTRO_PAGE.path}`, + ])('%s renders, kept out of search engines', async (path) => { + const { response, failure } = await fetchWithFailure(path) + expect(response.status, failure).toBe(200) + expect(response.headers.get('x-robots-tag')).toBe('noindex, nofollow') + expect(await response.text()).toContain(`${INTRO_PAGE.title} |`) + }) + + // The page sets the same header, so only a response it does not render pins the route rules. + // An unknown commit must 404, not 500: GitHub answers its lookup with a 422. + it.each([ + `/blob/${'0'.repeat(40)}${INTRO_PAGE.path}`, + `/tree/main/${NOT_FOUND_SLUG}`, + ])('%s returns a 404, kept out of search engines', async (path) => { + const response = await fetchPath(path, { accept: 'text/html' }) + expect(response.status).toBe(404) + expect(response.headers.get('x-robots-tag')).toBe('noindex, nofollow') + }) +}) diff --git a/test/e2e/routes.test.ts b/test/e2e/routes.test.ts index ea933d2..0099608 100644 --- a/test/e2e/routes.test.ts +++ b/test/e2e/routes.test.ts @@ -1,29 +1,17 @@ -import { describe, expect, inject, it } from 'vitest' - -const baseURL = inject('baseURL') - -/** Fetches a site path, explaining a failure with the message Nitro attaches to its JSON errors. */ -async function get(path: string, headers?: HeadersInit) { - const response = await fetch(new URL(path, baseURL), { headers }) - let failure = `${path} returned ${response.status}` - if (response.status >= 400) { - const error = await fetch(new URL(path, baseURL), { headers: { accept: 'application/json' } }) - .then((res) => res.json() as Promise<{ message?: string }>) - .catch(() => undefined) - if (error?.message) failure += `: ${error.message}` - } - return { response, failure } -} +import { describe, expect, it } from 'vitest' +import { baseURL, fetchPath, fetchWithFailure, INTRO_PAGE, NOT_FOUND_SLUG, SITE_URL } from './utils' /** Absolute URLs point at the production site: keep the path and query only. */ function toPath(url: string): string { - const { pathname, search } = new URL(url.replaceAll('&', '&')) + // Resolved against `baseURL` so a relative `<loc>` fails its own test, not the file import. + const { pathname, search } = new URL(url.replaceAll('&', '&'), baseURL) return pathname + search } // The sitemap lists every content page plus the Vue pages, so a new page is covered without touching this file. -const sitemap = await fetch(new URL('/sitemap.xml', baseURL)).then((res) => res.text()) -const pages = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => toPath(match[1]!)) +const sitemap = await fetchPath('/sitemap.xml').then((res) => res.text()) +const locs = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => match[1]!) +const pages = locs.map(toPath) describe('pages', () => { it('lists the landing page and the docs', () => { @@ -31,14 +19,31 @@ describe('pages', () => { expect(pages.length).toBeGreaterThan(5) }) + // The sitemap protocol requires absolute URLs, on the canonical origin rather than the serving host. + it('lists absolute URLs on the site origin', () => { + const offsite = locs.filter((loc) => !loc.startsWith(`${SITE_URL}/`)) + expect(offsite).toEqual([]) + }) + + // `accept` is explicit: a bare `*/*` gets the JSON error, or the markdown 404 meant for agents. + it.each([ + [`/${NOT_FOUND_SLUG}`, 'text/html'], + [`/getting-started/${NOT_FOUND_SLUG}`, 'text/html'], + [`/raw/${NOT_FOUND_SLUG}.md`, 'text/markdown'], + ])('%s returns a %s 404', async (path, type) => { + const response = await fetchPath(path, { accept: type }) + expect(response.status).toBe(404) + expect(response.headers.get('content-type')).toContain(type) + }) + it.each(pages)('%s renders, OG image included', async (path) => { - const { response, failure } = await get(path) + const { response, failure } = await fetchWithFailure(path) expect(response.status, failure).toBe(200) expect(response.headers.get('content-type')).toContain('text/html') const ogImage = (await response.text()).match(/<meta property="og:image" content="([^"]+)"/)?.[1] if (ogImage) { - const og = await get(toPath(ogImage)) + const og = await fetchWithFailure(toPath(ogImage)) expect(og.response.status, og.failure).toBe(200) expect(og.response.headers.get('content-type')).toBe('image/png') } @@ -48,7 +53,7 @@ describe('pages', () => { describe('agent routes', () => { it.each([ '/raw/index.md', - '/raw/getting-started/introduction.md', + `/raw${INTRO_PAGE.path}.md`, '/llms.txt', '/llms-full.txt', '/sitemap.md', @@ -56,13 +61,13 @@ describe('agent routes', () => { '/openapi.json', '/robots.txt', ])('%s responds', async (path) => { - const { response, failure } = await get(path) + const { response, failure } = await fetchWithFailure(path) expect(response.status, failure).toBe(200) }) // Cached routes answer with a redirect to the `/raw` twin rather than a rewrite. it('negotiates markdown on a page URL', async () => { - const { response, failure } = await get('/getting-started/introduction', { accept: 'text/markdown' }) + const { response, failure } = await fetchWithFailure(INTRO_PAGE.path, { accept: 'text/markdown' }) expect(response.status, failure).toBe(200) expect(response.headers.get('content-type')).toContain('text/markdown') }) diff --git a/test/e2e/utils.ts b/test/e2e/utils.ts new file mode 100644 index 0000000..e047a39 --- /dev/null +++ b/test/e2e/utils.ts @@ -0,0 +1,33 @@ +import { inject } from 'vitest' + +export const baseURL = inject('baseURL') + +/** + * The playground's `site.url`, which absolute URLs (sitemap, canonical, OG) point at. + * Keep in sync with `playground/nuxt.config.ts`. + */ +export const SITE_URL = 'https://docs-template.comark.dev' + +/** A playground page the tests read, renamed together with its content file. */ +export const INTRO_PAGE = { path: '/getting-started/introduction', title: 'Introduction' } as const + +/** A slug no page uses. */ +export const NOT_FOUND_SLUG = '__e2e-not-found__' + +/** Fetches a site path once: use it for expected errors, which the server logs on every request. */ +export function fetchPath(path: string, headers?: HeadersInit): Promise<Response> { + return fetch(new URL(path, baseURL), { headers }) +} + +/** Fetches a site path, explaining a failure with the message Nitro attaches to its JSON errors. */ +export async function fetchWithFailure(path: string, headers?: HeadersInit) { + const response = await fetchPath(path, headers) + let failure = `${path} returned ${response.status}` + if (response.status >= 400) { + const error = await fetchPath(path, { accept: 'application/json' }) + .then((res) => res.json() as Promise<{ message?: string }>) + .catch(() => undefined) + if (error?.message) failure += `: ${error.message}` + } + return { response, failure } +}