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
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,12 @@ jobs:
- run: pnpm test
- run: pnpm dev:prepare
- run: pnpm typecheck
# Builds the playground, which exercises the layer end to end: content
# pages, landing, OG images, and the generated ISR route rules.
# Builds the playground: module setup, bundling, and the generated ISR route rules.
- run: pnpm build
- run: pnpm test:e2e
env:
GITHUB_TOKEN: ${{ github.token }}
NUXT_DOCS_GITHUB_BRANCH: ${{ github.sha }}
# Continuous preview releases via pkg.pr.new: every commit gets an installable
# tarball (https://pkg.pr.new/comarkdown/comark-docs@<sha>), so downstream sites
# can test the layer before a release. Requires the pkg.pr.new GitHub App.
Comment thread
larbish marked this conversation as resolved.
Expand Down
1 change: 1 addition & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export default defineNuxtConfig({
},
nitro: {
alias: layerIconAliases(),
externals: { inline: ['comark/'] },
// MCP tool handlers reach the request through `useEvent()`.
experimental: { asyncContext: true },
vercel: {
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@
"typecheck": "nuxt typecheck playground",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "vitest run",
"test:watch": "vitest",
"test": "vitest run --project unit",
"test:e2e": "vitest run --project e2e",
"test:watch": "vitest --project unit",
"dev:prepare": "nuxt prepare playground"
},
"dependencies": {
Expand Down Expand Up @@ -95,6 +96,7 @@
"@opentelemetry/resources": "^2.12.0",
"@opentelemetry/sdk-trace-base": "^2.12.0",
"@opentelemetry/sdk-trace-node": "^2.12.0",
"es-module-lexer": "^3.0.2",
"eslint": "^10.13.0",
"nuxt": "^4.6.2",
"typescript": "^6.0.3",
Expand Down
778 changes: 358 additions & 420 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 0 additions & 2 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,3 @@ minimumReleaseAgeExclude:
overrides:
# Keep the workspace on a single h3 major (v1), matching comark-content.
h3: ^1.15.11
# Until nitrojs/nitro#4651 is released: header-only Vercel routes need `continue: true`.
nitropack: https://pkg.pr.new/nitrojs/nitro/nitropack@4651
50 changes: 50 additions & 0 deletions test/e2e/bundle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { readdirSync, readFileSync } from 'node:fs'
import { isBuiltin } from 'node:module'
import { join, relative } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { init, parse } from 'es-module-lexer'
import { resolveModuleURL } from 'exsolve'
import { expect, it } from 'vitest'

const serverDir = fileURLToPath(new URL('../../playground/.output/server/', import.meta.url))

// Optional peers imported behind a try/catch, absent unless a site installs them.
const OPTIONAL_IMPORTS = new Set([
// `@nuxtjs/mcp-toolkit` Code Mode.
'secure-exec',
])

/** The bundled chunks: the traced `node_modules/` are third-party files Nitro copied as they are. */
function bundledFiles(): string[] {
return readdirSync(serverDir, { withFileTypes: true, recursive: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.mjs'))
.map((entry) => join(entry.parentPath, entry.name))
.filter((file) => !relative(serverDir, file).startsWith('node_modules'))
}

// A lazy chunk (a component, a route) only fails once requested, so the route crawl alone can miss it.
it('every import in the server bundle resolves inside the build output', async () => {
await init()
const files = bundledFiles()
const broken: string[] = []
let checked = 0

for (const file of files) {
const [imports] = parse(readFileSync(file, 'utf8'))
for (const entry of imports) {
// `import.meta`, computed dynamic imports and template globs name no single module.
const specifier = entry.type === 'dynamic' && entry.glob ? undefined : entry.specifier
if (!specifier || isBuiltin(specifier) || OPTIONAL_IMPORTS.has(specifier)) continue
checked++
const resolved = resolveModuleURL(specifier, { from: pathToFileURL(file), try: true })
// Resolving into the repo's own `node_modules` works locally but not once deployed.
if (!resolved?.startsWith(pathToFileURL(serverDir).href)) {
broken.push(`${relative(serverDir, file)} imports ${specifier}`)
}
}
}

// Guards against a vacuous pass, e.g. the lexer reporting no specifiers after an API change.
expect(checked).toBeGreaterThan(files.length)
expect(broken).toEqual([])
})
69 changes: 69 additions & 0 deletions test/e2e/routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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 }
}

/** 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;', '&'))
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]!))

describe('pages', () => {
it('lists the landing page and the docs', () => {
expect(pages).toContain('/')
expect(pages.length).toBeGreaterThan(5)
})

it.each(pages)('%s renders, OG image included', async (path) => {
const { response, failure } = await get(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))
expect(og.response.status, og.failure).toBe(200)
expect(og.response.headers.get('content-type')).toBe('image/png')
}
})
})

describe('agent routes', () => {
it.each([
'/raw/index.md',
'/raw/getting-started/introduction.md',
'/llms.txt',
'/llms-full.txt',
'/sitemap.md',
'/rss.xml',
'/openapi.json',
'/robots.txt',
])('%s responds', async (path) => {
const { response, failure } = await get(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' })
expect(response.status, failure).toBe(200)
expect(response.headers.get('content-type')).toContain('text/markdown')
})
})
67 changes: 67 additions & 0 deletions test/e2e/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { type ChildProcess, spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { type AddressInfo, createServer } from 'node:net'
import { fileURLToPath } from 'node:url'
import type { TestProject } from 'vitest/node'

declare module 'vitest' {
export interface ProvidedContext {
baseURL: string
}
}

const entry = fileURLToPath(new URL('../../playground/.output/server/index.mjs', import.meta.url))

function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const probe = createServer()
probe.on('error', reject)
probe.listen(0, '127.0.0.1', () => {
const { port } = probe.address() as AddressInfo
probe.close(() => resolve(port))
})
})
}

async function waitForServer(url: string, server: ChildProcess) {
const deadline = Date.now() + 30_000
while (Date.now() < deadline) {
if (server.exitCode !== null) {
throw new Error(`The playground server exited with code ${server.exitCode}`)
}
try {
await fetch(url)
return
} catch {
await new Promise((resolve) => setTimeout(resolve, 200))
}
}
throw new Error(`The playground server did not answer on ${url} within 30s`)
}

/** Boots the built playground once for the whole e2e project. */
export default async function setup(project: TestProject) {
if (!existsSync(entry)) {
throw new Error('No playground build found: run `pnpm build` before `pnpm test:e2e`.')
}

const port = await freePort()
const server = spawn(process.execPath, [entry], {
env: { ...process.env, HOST: '127.0.0.1', PORT: String(port) },
// Server logs land in the test output: the `[request error]` line is what explains a failing route.
stdio: ['ignore', 'inherit', 'inherit'],
})

const baseURL = `http://127.0.0.1:${port}`
try {
await waitForServer(`${baseURL}/robots.txt`, server)
} catch (error) {
server.kill()
throw error
}
project.provide('baseURL', baseURL)

return () => {
server.kill()
}
}
25 changes: 22 additions & 3 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,27 @@ export default defineConfig({
// `transformSync`, hence the cast.
oxc: { tsconfig: false } as never,
test: {
include: ['test/**/*.test.ts', 'modules/**/*.test.ts'],
setupFiles: ['test/setup.ts'],
environment: 'node',
projects: [
{
test: {
name: 'unit',
include: ['test/**/*.test.ts', 'modules/**/*.test.ts'],
exclude: ['test/e2e/**'],
setupFiles: ['test/setup.ts'],
environment: 'node',
},
},
{
// Runs against the built playground (`pnpm build`), so it catches what only breaks once bundled.
test: {
name: 'e2e',
include: ['test/e2e/**/*.test.ts'],
globalSetup: ['test/e2e/setup.ts'],
environment: 'node',
// A cold page pulls its content from GitHub and OG images render through satori.
testTimeout: 60_000,
},
},
],
},
})
Loading