From 850821cf509b064864601e65c4efdfcc6ce68808 Mon Sep 17 00:00:00 2001 From: Baptiste Leproux Date: Wed, 21 Oct 2026 10:46:24 +0200 Subject: [PATCH] ci(test): check the Vercel build output --- .github/workflows/ci.yml | 18 ++++ package.json | 1 + test/vercel/output.test.ts | 163 +++++++++++++++++++++++++++++++++++++ vitest.config.ts | 10 ++- 4 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 test/vercel/output.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97052e6..32a4aab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,3 +44,21 @@ jobs: # tarball (https://pkg.pr.new/comarkdown/comark-docs@), so downstream sites # can test the layer before a release. Requires the pkg.pr.new GitHub App. - run: pnpx pkg-pr-new publish --compact --no-template --pnpm + + # The Vercel output as a deploy would get it: ISR functions and their TTLs, the bypass token, the server function. + vercel: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + package-manager-cache: false + node-version: '24' + - run: npm i -g --force corepack && corepack enable + - run: pnpm install --frozen-lockfile + - run: pnpm build + env: + NITRO_PRESET: vercel + VERCEL_BYPASS_TOKEN: ci-placeholder-bypass-token-0000000000 + - run: pnpm test:vercel diff --git a/package.json b/package.json index f49e2b5..2b9b41f 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "lint:fix": "eslint . --fix", "test": "vitest run --project unit", "test:e2e": "vitest run --project e2e", + "test:vercel": "vitest run --project vercel", "test:watch": "vitest --project unit", "dev:prepare": "nuxt prepare playground" }, diff --git a/test/vercel/output.test.ts b/test/vercel/output.test.ts new file mode 100644 index 0000000..0009b49 --- /dev/null +++ b/test/vercel/output.test.ts @@ -0,0 +1,163 @@ +import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs' +import { join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const outputDir = fileURLToPath(new URL('../../playground/.vercel/output/', import.meta.url)) +const functionsDir = join(outputDir, 'functions') +const serverDir = join(functionsDir, '__fallback.func') + +if (!existsSync(join(outputDir, 'config.json'))) { + throw new Error('No Vercel build found: run `NITRO_PRESET=vercel pnpm build` before `pnpm test:vercel`.') +} + +/** `comarkDocs.isr` default in `modules/config/`, in seconds. */ +const TTL = 300 + +/** Vercel's hard limit is 250 MB, the budget catches a dependency bundling far more than it needs. */ +const MAX_FUNCTION_MB = 100 + +const SHA = 'a'.repeat(40) + +interface Route { + src?: string + dest?: string + handle?: string + status?: number + headers?: Record + continue?: boolean +} + +interface PrerenderConfig { + expiration: number | false + bypassToken?: string +} + +const readJSON = (path: string): T => JSON.parse(readFileSync(path, 'utf8')) as T + +const { version, routes } = readJSON<{ version: number; routes: Route[] }>(join(outputDir, 'config.json')) + +/** The routes Vercel matches once no static file answers, up to the next `handle` phase. */ +function functionRoutes(): Route[] { + const start = routes.findIndex((route) => route.handle === 'filesystem') + 1 + const end = routes.findIndex((route, index) => index >= start && route.handle) + return routes.slice(start, end === -1 ? undefined : end) +} + +/** The function Vercel serves a path from, and its ISR expiration when it is cached. */ +function resolveFunction(path: string) { + // Vercel matches `src` against the whole path. + const route = functionRoutes().find((route) => route.dest && new RegExp(`^(?:${route.src})$`).test(path)) + const dest = route?.dest?.split('?')[0] + if (!dest) return undefined + + const prerenderConfig = join(functionsDir, `${dest}.prerender-config.json`) + const expiration = existsSync(prerenderConfig) ? readJSON(prerenderConfig).expiration : undefined + return { dest, expiration } +} + +function walk(dir: string, files: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name) + if (entry.isDirectory()) walk(path, files) + else files.push(path) + } + return files +} + +/** Nitro inlines each server asset as a raw chunk named after the asset, exporting its contents as a string. */ +async function readRawAsset(name: string): Promise { + const chunk = (await import(join(serverDir, 'chunks/raw', `${name}.mjs`))) as { default: string } + return JSON.parse(chunk.default) as T +} + +describe('routing', () => { + it('uses the Build Output API v3', () => { + expect(version).toBe(3) + }) + + it('falls back to the server function after the static files', () => { + expect(routes.some((route) => route.handle === 'filesystem')).toBe(true) + expect(functionRoutes().at(-1)).toEqual({ src: '/(.*)', dest: '/__fallback' }) + }) + + // A header route without `continue` ends the routing before any function (nitrojs/nitro#4651). + it('lets every header-only route continue', () => { + const stopping = routes.filter((route) => route.headers && !route.dest && !route.status && route.continue !== true) + expect(stopping.map((route) => route.src)).toEqual([]) + }) +}) + +describe('ISR', () => { + // `false` never expires: the response is pinned to a commit SHA. + it.each([ + ['/', TTL], + ['/logos', TTL], + ['/getting-started', TTL], + ['/getting-started/introduction', TTL], + ['/getting-started/introduction/_payload.json', TTL], + ['/tree/main/getting-started/introduction', TTL], + ['/pr/1/getting-started/introduction', TTL], + [`/blob/${SHA}/getting-started/introduction`, false], + [`/blob/${SHA}/getting-started/introduction/_payload.json`, false], + ['/raw/getting-started/introduction.md', TTL], + ['/llms.txt', TTL], + ['/llms-full.txt', TTL], + ['/sitemap.md', TTL], + ['/rss.xml', TTL], + ['/openapi.json', TTL], + ['/.well-known/skills/index.json', false], + [`/api/content/blob/${SHA}/manifest.json`, false], + [`/api/content/blob/${SHA}/snapshot/default.json`, false], + ['/api/code-explorer/comarkdown/comark-docs/README.md.json', TTL], + ])('%s is cached with an expiration of %s', (path, expiration) => { + expect(resolveFunction(path)?.expiration).toBe(expiration) + }) + + // Answers that must reflect the request: the webhook, the assistant stream, MCP and the pinned head commit. + it.each(['/api/revalidate', '/api/assistant', '/api/content/head', '/mcp'])('%s is not cached', (path) => { + const resolved = resolveFunction(path) + expect(resolved?.dest).toBeDefined() + expect(resolved?.expiration).toBeUndefined() + }) + + // The push webhook purges pages with it, without one content updates wait for the TTL. + it('gives every ISR function the same bypass token', () => { + const configs = walk(functionsDir) + .filter((file) => file.endsWith('.prerender-config.json')) + .map((file) => ({ file: relative(functionsDir, file), ...readJSON(file) })) + + expect(configs.length).toBeGreaterThan(0) + // File names only: the token itself must not reach the logs. + expect(configs.filter((config) => !config.bypassToken).map((config) => config.file), 'build with `VERCEL_BYPASS_TOKEN` set').toEqual([]) + expect(new Set(configs.map((config) => config.bypassToken)).size).toBe(1) + }) +}) + +describe('server function', () => { + const { runtime, supportsResponseStreaming } = readJSON<{ runtime: string; supportsResponseStreaming?: boolean }>(join(serverDir, '.vc-config.json')) + + it('runs on Node.js', () => { + expect(runtime).toMatch(/^nodejs\d+\.x$/) + }) + + // The assistant streams its answer, a buffered response would only show up at the end. + it('streams responses', () => { + expect(supportsResponseStreaming).toBe(true) + }) + + it(`stays under ${MAX_FUNCTION_MB} MB`, () => { + const bytes = walk(serverDir).reduce((total, file) => total + lstatSync(file).size, 0) + expect(bytes / 1024 / 1024).toBeLessThan(MAX_FUNCTION_MB) + }) + + // Without it a cold start walks the content repository on GitHub. + it('bundles the content snapshot and its manifest, stamped with one commit', async () => { + const snapshot = await readRawAsset<{ ref: string; items: Array<{ path: string }> }>('snapshot') + const manifest = await readRawAsset<{ ref: string }>('manifest') + + expect(snapshot.ref).toMatch(/^[0-9a-f]{40}$/) + expect(manifest.ref).toBe(snapshot.ref) + expect(snapshot.items.map((item) => item.path)).toContain('/getting-started/introduction') + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 0497dce..b0eadf7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ test: { name: 'unit', include: ['test/**/*.test.ts', 'modules/**/*.test.ts'], - exclude: ['test/e2e/**'], + exclude: ['test/e2e/**', 'test/vercel/**'], setupFiles: ['test/setup.ts'], environment: 'node', }, @@ -29,6 +29,14 @@ export default defineConfig({ testTimeout: 60_000, }, }, + { + // Reads the Vercel build output (`NITRO_PRESET=vercel pnpm build`), checking what a deploy would get. + test: { + name: 'vercel', + include: ['test/vercel/**/*.test.ts'], + environment: 'node', + }, + }, ], }, })