Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
82 changes: 82 additions & 0 deletions test/e2e/mcp.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(method: string, params?: Record<string, unknown>): Promise<T> {
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<string[]> {
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<string, unknown>) {
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#')
})
})
34 changes: 34 additions & 0 deletions test/e2e/previews.test.ts
Original file line number Diff line number Diff line change
@@ -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(`<title>${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')
})
})
53 changes: 29 additions & 24 deletions test/e2e/routes.test.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,49 @@
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('&amp;', '&'))
// Resolved against `baseURL` so a relative `<loc>` fails its own test, not the file import.
const { pathname, search } = new URL(url.replaceAll('&amp;', '&'), 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', () => {
expect(pages).toContain('/')
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')
}
Expand All @@ -48,21 +53,21 @@ 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',
'/rss.xml',
'/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')
})
Expand Down
33 changes: 33 additions & 0 deletions test/e2e/utils.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading