feat: CMS head and one JSON-LD graph on every server-rendered page (tracer)
- Nitro middleware resolves page requests once and hands the answer to the render (event.context.seo); the route middleware reuses it - useCmsSeo: html lang, hreflang links, og:locale and exactly one JSON-LD script with one @graph (CMS nodes plus useSchemaNode nodes, merged by @id) - local module (configKey seo): cms (alias of public.seo.enabled), breadcrumbs, links.newTabHint, rootFiles, resolveFiles - GET /api/__seo__/resolve for client navigation through the Nuxt server - smoke harness: count=, exact header=, noheader=, reqheader=, repeated options, resolves/apicalls/noreport lines, --no-site; v0.1.1 suite runs unchanged against a backend without /site
This commit is contained in:
15
testing/expect-seo.txt
Normal file
15
testing/expect-seo.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
# SSR expectations of the SEO layer's v0.2.0 features against the fixture
|
||||
# SEO API (testing/fixtures, answers in testing/fixtures/seo). Grammar in
|
||||
# testing/smoke-ssr.mjs. testing/expect.txt keeps the v0.1.1 behaviour and
|
||||
# runs against a backend without /site (--no-site).
|
||||
|
||||
# Tracer: a page rendered by Nuxt carries the CMS title, canonical, hreflang
|
||||
# and one JSON-LD graph from the resolve answer.
|
||||
200 /blog/lwia-grzywa contains=<title>Lwia grzywa | Example</title> count=application/ld+json:1 count=rel="canonical":1 contains=hreflang="x-default" contains=lang="pl"><head>
|
||||
200 /blog/lwia-grzywa contains=<link rel="alternate" hreflang="en" href="https://example.test/en/blog/lions-mane" contains=hreflang="pl"
|
||||
200 /blog/lwia-grzywa contains=<meta property="og:locale" content="pl_PL"> contains=<meta property="og:locale:alternate" content="en_US">
|
||||
200 /blog/lwia-grzywa notcontains=Own catch-all title
|
||||
# One graph: the CMS nodes, the layer's node merged by @id (CMS values win),
|
||||
# a new layer node appended; "<" inside the JSON cannot end the script.
|
||||
200 /blog/lwia-grzywa count=data-hid="seo-jsonld":1 count="@type":"WebPage":1 contains="name":"Lwia grzywa" notcontains=Layer name loses contains="inLanguage":"pl" contains=#itemlist
|
||||
200 /blog/lwia-grzywa notcontains=</script><script>alert
|
||||
@@ -1,20 +1,31 @@
|
||||
// A small node:http stand-in for the SEO plugin's public API
|
||||
// (/_seo/api/v1):
|
||||
// (/_seo/api/v1) and the Go binary's root files:
|
||||
//
|
||||
// GET /resolve?url=… the answer for that exact url from
|
||||
// <dir>/resolve.json, else not_found
|
||||
// POST /notfound 204 with `Authorization: Bearer <token>`,
|
||||
// 401 with another token, 404 without one
|
||||
// GET /verification/<name> <dir>/verification/<name>, else 404
|
||||
// GET /_seo/api/v1/resolve?url=… the answer for that exact url from
|
||||
// <dir>/seo/resolve.json, then
|
||||
// <dir>/resolve.json, else not_found
|
||||
// GET /_seo/api/v1/site <dir>/seo/site.json (404 without the
|
||||
// file or with { site: false }: an
|
||||
// older backend)
|
||||
// POST /_seo/api/v1/notfound 204 with `Authorization: Bearer <token>`,
|
||||
// 401 with another token, 404 without one
|
||||
// GET /_seo/api/v1/verification/<name> <dir>/verification/<name> or
|
||||
// <dir>/seo/verification/<name>, else 404
|
||||
// GET /robots.txt, /llms.txt, <dir>/seo/root/<name>, else 404
|
||||
// /sitemap.xml
|
||||
// GET /_sitemap/files/<file> <dir>/seo/root/<file>, else 404;
|
||||
// sitemap-fail-*.xml answers 500
|
||||
// GET /_sitemap/indexnow/<key>.txt <dir>/seo/indexnow/<key>.txt, else 404
|
||||
//
|
||||
// It records every request (with the parsed JSON body of a POST) so a smoke
|
||||
// run can assert header forwarding and the 404 reports.
|
||||
// run can assert header forwarding, resolve counts and the 404 reports.
|
||||
// `reload()` reads the fixture files again (purge-check changes them).
|
||||
//
|
||||
// Plain .mjs with JSDoc types: Node does not strip TypeScript under
|
||||
// node_modules, and consuming apps may import this file from there.
|
||||
|
||||
import { createServer } from 'node:http'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const PREFIX = '/_seo/api/v1'
|
||||
@@ -24,26 +35,58 @@ export const FIXTURE_NOTFOUND_TOKEN = 'fixture-seo-notfound-token-0123456789'
|
||||
|
||||
/**
|
||||
* @typedef {{ method: string, url: string, xff: string | null, authorization: string | null, body: unknown }} FixtureRequest
|
||||
* @typedef {{ url: string, requests: FixtureRequest[], close: () => Promise<void> }} FixtureApi
|
||||
* @typedef {{ url: string, requests: FixtureRequest[], resolveCount: (target?: string) => number, reload: () => void, close: () => Promise<void> }} FixtureApi
|
||||
*/
|
||||
|
||||
/** @param {string} file @returns {any} */
|
||||
function readJson(file) {
|
||||
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null
|
||||
}
|
||||
|
||||
/** @param {string} name */
|
||||
function contentType(name) {
|
||||
if (name.endsWith('.xml')) return 'application/xml; charset=utf-8'
|
||||
if (name.endsWith('.html')) return 'text/html; charset=utf-8'
|
||||
return 'text/plain; charset=utf-8'
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the fixture API on 127.0.0.1.
|
||||
* @param {{ dir: string, port?: number, token?: string }} opts
|
||||
* @param {{ dir: string, port?: number, token?: string, site?: boolean }} opts
|
||||
* @returns {Promise<FixtureApi>}
|
||||
*/
|
||||
export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN }) {
|
||||
export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN, site = true }) {
|
||||
/** @type {FixtureRequest[]} */
|
||||
const requests = []
|
||||
/** @type {Record<string, unknown>} */
|
||||
const answers = JSON.parse(readFileSync(join(dir, 'resolve.json'), 'utf8'))
|
||||
let answers = {}
|
||||
/** @type {unknown} */
|
||||
let siteAnswer = null
|
||||
const load = () => {
|
||||
answers = { ...(readJson(join(dir, 'resolve.json')) ?? {}), ...(readJson(join(dir, 'seo', 'resolve.json')) ?? {}) }
|
||||
siteAnswer = site ? readJson(join(dir, 'seo', 'site.json')) : null
|
||||
}
|
||||
load()
|
||||
|
||||
/** @param {string} file @param {string} name */
|
||||
const fileAnswer = (file, name) => {
|
||||
if (/^sitemap-fail-/.test(name)) return { status: 500, text: 'backend failure', type: 'text/plain' }
|
||||
if (!existsSync(file)) return { status: 404, body: { error: 'not_found' } }
|
||||
return { status: 200, text: readFileSync(file, 'utf8'), type: contentType(name), robots: name.startsWith('sitemap-') || name === 'llms.txt' ? 'noindex' : undefined }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('node:http').IncomingMessage} req
|
||||
* @returns {{ status: number, body?: unknown, text?: string, type?: string }}
|
||||
* @returns {{ status: number, body?: unknown, text?: string, type?: string, robots?: string }}
|
||||
*/
|
||||
const route = (req) => {
|
||||
const url = new URL(req.url ?? '/', 'http://fixture.local')
|
||||
const root = /^\/(robots\.txt|llms\.txt|sitemap\.xml)$/.exec(url.pathname)
|
||||
if (root && req.method === 'GET') return fileAnswer(join(dir, 'seo', 'root', root[1]), root[1])
|
||||
const files = /^\/_sitemap\/files\/(sitemap-[a-z0-9-]+\.xml)$/.exec(url.pathname)
|
||||
if (files && req.method === 'GET') return fileAnswer(join(dir, 'seo', 'root', files[1]), files[1])
|
||||
const key = /^\/_sitemap\/indexnow\/([A-Za-z0-9-]{8,128}\.txt)$/.exec(url.pathname)
|
||||
if (key && req.method === 'GET') return fileAnswer(join(dir, 'seo', 'indexnow', key[1]), key[1])
|
||||
if (!url.pathname.startsWith(PREFIX + '/')) return { status: 404, body: { error: 'not_found' } }
|
||||
const path = url.pathname.slice(PREFIX.length)
|
||||
if (path === '/resolve' && req.method === 'GET') {
|
||||
@@ -53,17 +96,19 @@ export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN
|
||||
if (hit) return { status: 200, body: hit }
|
||||
return { status: 200, body: { action: 'not_found', status: 404, url: target, locale: '', staging: false, seo: null } }
|
||||
}
|
||||
if (path === '/site' && req.method === 'GET') {
|
||||
return siteAnswer ? { status: 200, body: siteAnswer } : { status: 404, body: { error: 'not_found' } }
|
||||
}
|
||||
if (path === '/notfound' && req.method === 'POST') {
|
||||
if (!token) return { status: 404, body: { error: 'not_found' } }
|
||||
return req.headers.authorization === `Bearer ${token}` ? { status: 204 } : { status: 401, body: { error: 'unauthorized' } }
|
||||
}
|
||||
const v = /^\/verification\/([A-Za-z0-9._-]+)$/.exec(path)
|
||||
if (v && req.method === 'GET') {
|
||||
try {
|
||||
return { status: 200, text: readFileSync(join(dir, 'verification', v[1]), 'utf8'), type: 'text/html; charset=utf-8' }
|
||||
} catch {
|
||||
return { status: 404, body: { error: 'not_found' } }
|
||||
for (const file of [join(dir, 'verification', v[1]), join(dir, 'seo', 'verification', v[1])]) {
|
||||
if (existsSync(file)) return { status: 200, text: readFileSync(file, 'utf8'), type: 'text/html; charset=utf-8' }
|
||||
}
|
||||
return { status: 404, body: { error: 'not_found' } }
|
||||
}
|
||||
return { status: 404, body: { error: 'not_found' } }
|
||||
}
|
||||
@@ -91,7 +136,7 @@ export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN
|
||||
out = { status: 500, body: { error: String(err) } }
|
||||
}
|
||||
if (out.text !== undefined) {
|
||||
res.writeHead(out.status, { 'content-type': out.type ?? 'text/plain' })
|
||||
res.writeHead(out.status, { 'content-type': out.type ?? 'text/plain', ...(out.robots ? { 'x-robots-tag': out.robots } : {}) })
|
||||
res.end(out.text)
|
||||
return
|
||||
}
|
||||
@@ -100,6 +145,14 @@ export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN
|
||||
})
|
||||
})
|
||||
|
||||
/** Resolve calls so far, for one target url or (without one) all of them. @param {string} [target] */
|
||||
const resolveCount = (target) =>
|
||||
requests.filter((r) => {
|
||||
if (r.method !== 'GET') return false
|
||||
const u = new URL(r.url, 'http://fixture.local')
|
||||
return u.pathname === `${PREFIX}/resolve` && (target === undefined || u.searchParams.get('url') === target)
|
||||
}).length
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
@@ -107,6 +160,8 @@ export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${addr.port}`,
|
||||
requests,
|
||||
resolveCount,
|
||||
reload: load,
|
||||
close: () => new Promise((done) => server.close(() => done())),
|
||||
})
|
||||
})
|
||||
|
||||
85
testing/fixtures/seo/resolve.json
Normal file
85
testing/fixtures/seo/resolve.json
Normal file
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"/blog/lwia-grzywa": {
|
||||
"action": "ok",
|
||||
"status": 200,
|
||||
"url": "https://example.test/blog/lwia-grzywa",
|
||||
"locale": "pl",
|
||||
"staging": false,
|
||||
"seo": {
|
||||
"title": "Lwia grzywa | Example",
|
||||
"description": "Lwia grzywa | Example opis",
|
||||
"canonical": "https://example.test/blog/lwia-grzywa",
|
||||
"robots": "index, follow",
|
||||
"x_robots_tag": "",
|
||||
"og": {
|
||||
"title": "Lwia grzywa | Example",
|
||||
"description": "Lwia grzywa | Example opis",
|
||||
"image": "https://example.test/img/og.png",
|
||||
"url": "https://example.test/blog/lwia-grzywa",
|
||||
"locale": "pl_PL",
|
||||
"locale_alternates": [
|
||||
"en_US"
|
||||
]
|
||||
},
|
||||
"twitter": {
|
||||
"card": "summary_large_image",
|
||||
"site": "@example"
|
||||
},
|
||||
"verification": []
|
||||
},
|
||||
"html_lang": "pl",
|
||||
"alternates": [
|
||||
{
|
||||
"hreflang": "pl",
|
||||
"url": "https://example.test/blog/lwia-grzywa"
|
||||
},
|
||||
{
|
||||
"hreflang": "en",
|
||||
"url": "https://example.test/en/blog/lions-mane"
|
||||
},
|
||||
{
|
||||
"hreflang": "x-default",
|
||||
"url": "https://example.test/blog/lwia-grzywa"
|
||||
}
|
||||
],
|
||||
"jsonld": {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "WebPage",
|
||||
"@id": "https://example.test/blog/lwia-grzywa#webpage",
|
||||
"url": "https://example.test/blog/lwia-grzywa",
|
||||
"name": "Lwia grzywa",
|
||||
"isPartOf": {
|
||||
"@id": "https://example.test/#website"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "BlogPosting",
|
||||
"@id": "https://example.test/blog/lwia-grzywa#article",
|
||||
"headline": "Lwia grzywa </script><script>alert(1)</script>",
|
||||
"mainEntityOfPage": {
|
||||
"@id": "https://example.test/blog/lwia-grzywa#webpage"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"name": "Start",
|
||||
"url": "https://example.test/",
|
||||
"path": "/"
|
||||
},
|
||||
{
|
||||
"name": "Blog",
|
||||
"url": "https://example.test/blog",
|
||||
"path": "/blog"
|
||||
},
|
||||
{
|
||||
"name": "Lwia grzywa",
|
||||
"url": "https://example.test/blog/lwia-grzywa",
|
||||
"path": "/blog/lwia-grzywa"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
77
testing/nuxt-server.mjs
Normal file
77
testing/nuxt-server.mjs
Normal file
@@ -0,0 +1,77 @@
|
||||
// Boots a built Nuxt server (`<outputDir>/server/index.mjs`) on 127.0.0.1
|
||||
// against a given SEO API base and waits until it answers HTTP. The same
|
||||
// helper as vue-journal-layer's testing/nuxt-server.mjs, with the SEO base.
|
||||
// Plain .mjs with JSDoc types so it runs from node_modules unchanged.
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createServer } from 'node:net'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
/** @returns {Promise<number>} */
|
||||
function freePort() {
|
||||
return new Promise((done, fail) => {
|
||||
const srv = createServer()
|
||||
srv.once('error', fail)
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const { port } = /** @type {import('node:net').AddressInfo} */ (srv.address())
|
||||
srv.close(() => done(port))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The returned `output()` gives the server's captured stdout and stderr so
|
||||
* far, for checks on what the server logged.
|
||||
*
|
||||
* @param {{ outputDir: string, apiBase: string, port?: number, env?: Record<string, string>, timeoutMs?: number }} opts
|
||||
* @returns {Promise<{ url: string, close: () => Promise<void>, output: () => string }>}
|
||||
*/
|
||||
export async function startNuxtServer({ outputDir, apiBase, port = 0, env = {}, timeoutMs = 30000 }) {
|
||||
const entry = join(resolve(outputDir), 'server', 'index.mjs')
|
||||
if (!existsSync(entry)) throw new Error(`nuxt-server: ${entry} does not exist; run pnpm build:playground first`)
|
||||
const listenPort = port || (await freePort())
|
||||
const url = `http://127.0.0.1:${listenPort}`
|
||||
|
||||
const child = spawn(process.execPath, [entry], {
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'production',
|
||||
NITRO_HOST: '127.0.0.1',
|
||||
NITRO_PORT: String(listenPort),
|
||||
NUXT_SEO_API_BASE: apiBase,
|
||||
...env,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
let log = ''
|
||||
child.stdout.on('data', (b) => (log += b))
|
||||
child.stderr.on('data', (b) => (log += b))
|
||||
let exited = false
|
||||
child.once('exit', () => (exited = true))
|
||||
|
||||
const close = () =>
|
||||
new Promise((/** @type {(v?: void) => void} */ done) => {
|
||||
if (exited) return done()
|
||||
child.once('exit', () => done())
|
||||
child.kill('SIGTERM')
|
||||
setTimeout(() => {
|
||||
if (!exited) child.kill('SIGKILL')
|
||||
}, 5000).unref()
|
||||
})
|
||||
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (exited) throw new Error(`nuxt-server: server exited before answering\n${log}`)
|
||||
try {
|
||||
// A Nuxt-internal path: it touches no page, so the boot probe never
|
||||
// resolves through the SEO API or fills a route cache.
|
||||
await fetch(url + '/_nuxt/', { redirect: 'manual', signal: AbortSignal.timeout(2000) })
|
||||
return { url, close, output: () => log }
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
}
|
||||
}
|
||||
await close()
|
||||
throw new Error(`nuxt-server: no answer on ${url} within ${timeoutMs} ms\n${log}`)
|
||||
}
|
||||
@@ -2,38 +2,51 @@
|
||||
// SSR smoke check: boots the fixture SEO API and the built playground
|
||||
// server, then checks each line of an expectations file.
|
||||
//
|
||||
// node testing/smoke-ssr.mjs --output <.output dir> --fixtures <dir> --expect <file> [--env KEY=VALUE]...
|
||||
// node testing/smoke-ssr.mjs --output <.output dir> --fixtures <dir> --expect <file> [--no-site] [--env KEY=VALUE]...
|
||||
//
|
||||
// Expectation grammar, one check per line ('#' starts a comment line):
|
||||
// STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX] [header=NAME: TEXT]
|
||||
// report PATH
|
||||
// A value runs until the next ` contains=`, ` notcontains=`, ` type=`,
|
||||
// ` header=` or the end of the line, so it may contain spaces. header=
|
||||
// checks that the response header NAME contains TEXT. A `report PATH` line
|
||||
// requires a POST /notfound for PATH to have reached the fixture API by the
|
||||
// end of the run (the server is started with the fixture's 404 token). Requests are plain GETs that do
|
||||
// not follow redirects and carry `X-Forwarded-For: 203.0.113.7` and
|
||||
// `Accept: text/html` (as a browser does, so errors render as pages); afterwards
|
||||
// at least one fixture API request must have carried that address (the SSR
|
||||
// fetches forward the visitor's x-forwarded-for).
|
||||
// STATUS PATH [contains=TEXT] [notcontains=TEXT] [count=TEXT:N] [type=PREFIX]
|
||||
// [header=NAME: VALUE] [noheader=NAME] [reqheader=NAME: VALUE]
|
||||
// report PATH a POST /notfound for PATH reached the API by the end of the run
|
||||
// noreport PATH no POST /notfound for PATH reached the API by the end of the run
|
||||
// resolves PATH N the API has seen exactly N resolve calls for PATH so far
|
||||
// (PATH * counts every resolve call)
|
||||
// apicalls N the API has seen exactly N requests so far
|
||||
// A value runs until the next ` contains=`, ` notcontains=`, ` count=`,
|
||||
// ` type=`, ` header=`, ` noheader=`, ` reqheader=` or the end of the line, so
|
||||
// it may contain spaces. Every option may repeat on a line and every
|
||||
// occurrence applies. count= wants exactly N occurrences of TEXT in the body
|
||||
// (N is the number after the last colon). header= wants the response header
|
||||
// NAME to equal VALUE (both trimmed); noheader= wants it absent; reqheader=
|
||||
// adds a request header (a crafted Host or X-Forwarded-Host). The resolves
|
||||
// and apicalls lines are checked in file order, between the requests; the
|
||||
// report lines after the last request.
|
||||
//
|
||||
// Requests are plain GETs that do not follow redirects and carry
|
||||
// `X-Forwarded-For: 203.0.113.7` and `Accept: text/html` (as a browser does,
|
||||
// so errors render as pages); when the API saw any request, at least one
|
||||
// must have carried that address (the SSR fetches forward the visitor's
|
||||
// x-forwarded-for). The server is started with the fixture's 404 token and
|
||||
// the fixture API as NUXT_SEO_API_BASE; --env overrides either. --no-site
|
||||
// makes the fixture API answer /site with 404, as a backend before SEO
|
||||
// v0.2.0 does.
|
||||
//
|
||||
// The same approach as vue-faq-layer's testing/smoke-ssr.mjs; plain .mjs
|
||||
// with JSDoc types so it runs from node_modules unchanged.
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { createServer } from 'node:net'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { FIXTURE_NOTFOUND_TOKEN, startFixtureApi } from './fixture-api.mjs'
|
||||
import { startNuxtServer } from './nuxt-server.mjs'
|
||||
|
||||
const VISITOR_IP = '203.0.113.7'
|
||||
const API_PREFIX = '/_seo/api/v1'
|
||||
const API_ENV = 'NUXT_SEO_API_BASE'
|
||||
const OPTION_KEYS = ['contains', 'notcontains', 'count', 'type', 'header', 'noheader', 'reqheader']
|
||||
|
||||
/** @param {string[]} argv */
|
||||
function parseArgs(argv) {
|
||||
/** @type {{ output?: string, fixtures?: string, expect?: string, env: Record<string, string> }} */
|
||||
const out = { env: {} }
|
||||
/** @type {{ output?: string, fixtures?: string, expect?: string, site: boolean, env: Record<string, string> }} */
|
||||
const out = { env: {}, site: true }
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i]
|
||||
const next = () => {
|
||||
@@ -44,6 +57,7 @@ function parseArgs(argv) {
|
||||
if (arg === '--output') out.output = next()
|
||||
else if (arg === '--fixtures') out.fixtures = next()
|
||||
else if (arg === '--expect') out.expect = next()
|
||||
else if (arg === '--no-site') out.site = false
|
||||
else if (arg === '--env') {
|
||||
const kv = next()
|
||||
const eq = kv.indexOf('=')
|
||||
@@ -52,20 +66,22 @@ function parseArgs(argv) {
|
||||
} else throw new Error(`unknown argument ${arg}`)
|
||||
}
|
||||
if (!out.output || !out.fixtures || !out.expect) throw new Error('--output, --fixtures and --expect are required')
|
||||
return /** @type {{ output: string, fixtures: string, expect: string, env: Record<string, string> }} */ (out)
|
||||
return /** @type {{ output: string, fixtures: string, expect: string, site: boolean, env: Record<string, string> }} */ (out)
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{ status: number, path: string, options: Array<{ key: string, value: string }> }} ExpectCheck
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} line
|
||||
* @returns {{ status: number, path: string, contains?: string, notcontains?: string, type?: string, header?: string }}
|
||||
* @returns {ExpectCheck}
|
||||
*/
|
||||
export function parseExpectLine(line) {
|
||||
const m = /^(\d{3})\s+(\S+)(.*)$/.exec(line.trim())
|
||||
if (!m) throw new Error('expected "STATUS PATH [options]"')
|
||||
/** @type {{ status: number, path: string, contains?: string, notcontains?: string, type?: string, header?: string }} */
|
||||
const check = { status: Number(m[1]), path: m[2] }
|
||||
const rest = m[3]
|
||||
const re = /\s(contains|notcontains|type|header)=/g
|
||||
const re = new RegExp(`\\s(${OPTION_KEYS.join('|')})=`, 'g')
|
||||
/** @type {Array<{ key: string, start: number, valueStart: number }>} */
|
||||
const marks = []
|
||||
let hit
|
||||
@@ -74,67 +90,61 @@ export function parseExpectLine(line) {
|
||||
if (marks.length > 0 && rest.slice(0, marks[0].start).trim() !== '') {
|
||||
throw new Error(`unknown option text "${rest.slice(0, marks[0].start).trim()}"`)
|
||||
}
|
||||
/** @type {Record<string, string>} */
|
||||
const options = {}
|
||||
marks.forEach((mark, i) => {
|
||||
const options = marks.map((mark, i) => {
|
||||
const end = i + 1 < marks.length ? marks[i + 1].start : rest.length
|
||||
options[mark.key] = rest.slice(mark.valueStart, end).trim()
|
||||
return { key: mark.key, value: rest.slice(mark.valueStart, end).trim() }
|
||||
})
|
||||
return { ...check, ...options }
|
||||
return { status: Number(m[1]), path: m[2], options }
|
||||
}
|
||||
|
||||
/** @returns {Promise<number>} */
|
||||
function freePort() {
|
||||
return new Promise((done, fail) => {
|
||||
const srv = createServer()
|
||||
srv.once('error', fail)
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const { port } = /** @type {import('node:net').AddressInfo} */ (srv.address())
|
||||
srv.close(() => done(port))
|
||||
})
|
||||
})
|
||||
/** NAME: VALUE split at the first colon. @param {string} value */
|
||||
function nameValue(value) {
|
||||
const colon = value.indexOf(':')
|
||||
return colon > 0 ? { name: value.slice(0, colon).trim(), value: value.slice(colon + 1).trim() } : { name: value.trim(), value: '' }
|
||||
}
|
||||
|
||||
/** @param {string} body @param {string} text */
|
||||
function occurrences(body, text) {
|
||||
if (text === '') return 0
|
||||
let n = 0
|
||||
for (let at = body.indexOf(text); at !== -1; at = body.indexOf(text, at + text.length)) n++
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* Boots `<outputDir>/server/index.mjs` on 127.0.0.1 and waits until it answers.
|
||||
* @param {{ outputDir: string, env: Record<string, string>, timeoutMs?: number }} opts
|
||||
* @returns {Promise<{ url: string, close: () => Promise<void> }>}
|
||||
* The failures of one response against its check.
|
||||
* @param {ExpectCheck} check @param {{ status: number, headers: Headers, body: string }} res
|
||||
* @returns {string[]}
|
||||
*/
|
||||
async function startNuxtServer({ outputDir, env, timeoutMs = 30000 }) {
|
||||
const entry = join(resolve(outputDir), 'server', 'index.mjs')
|
||||
if (!existsSync(entry)) throw new Error(`${entry} does not exist; run pnpm build:playground first`)
|
||||
const port = await freePort()
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
const child = spawn(process.execPath, [entry], {
|
||||
env: { ...process.env, NODE_ENV: 'production', NITRO_HOST: '127.0.0.1', NITRO_PORT: String(port), ...env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
let log = ''
|
||||
child.stdout.on('data', (b) => (log += b))
|
||||
child.stderr.on('data', (b) => (log += b))
|
||||
let exited = false
|
||||
child.once('exit', () => (exited = true))
|
||||
const close = () =>
|
||||
new Promise((/** @type {(v?: void) => void} */ done) => {
|
||||
if (exited) return done()
|
||||
child.once('exit', () => done())
|
||||
child.kill('SIGTERM')
|
||||
setTimeout(() => {
|
||||
if (!exited) child.kill('SIGKILL')
|
||||
}, 5000).unref()
|
||||
})
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (exited) throw new Error(`server exited before answering\n${log}`)
|
||||
try {
|
||||
await fetch(url + '/', { redirect: 'manual', signal: AbortSignal.timeout(2000) })
|
||||
return { url, close }
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
export function checkResponse(check, res) {
|
||||
if (res.status !== check.status) return [`status ${res.status}, want ${check.status}`]
|
||||
/** @type {string[]} */
|
||||
const out = []
|
||||
const type = res.headers.get('content-type') ?? ''
|
||||
for (const { key, value } of check.options) {
|
||||
if (key === 'contains' && !res.body.includes(value)) out.push(`body lacks "${value}"`)
|
||||
else if (key === 'notcontains' && res.body.includes(value)) out.push(`body contains "${value}"`)
|
||||
else if (key === 'count') {
|
||||
const colon = value.lastIndexOf(':')
|
||||
const want = colon > 0 ? Number(value.slice(colon + 1)) : NaN
|
||||
if (!Number.isInteger(want)) {
|
||||
out.push(`count=${value}: want TEXT:N`)
|
||||
continue
|
||||
}
|
||||
const text = value.slice(0, colon)
|
||||
const got = occurrences(res.body, text)
|
||||
if (got !== want) out.push(`body has ${got} of "${text}", want ${want}`)
|
||||
} else if (key === 'type' && !type.startsWith(value)) out.push(`content-type "${type}", want prefix "${value}"`)
|
||||
else if (key === 'header') {
|
||||
const h = nameValue(value)
|
||||
const got = res.headers.get(h.name)
|
||||
if (got === null || got.trim() !== h.value) out.push(`header ${h.name} is ${JSON.stringify(got)}, want "${h.value}"`)
|
||||
} else if (key === 'noheader') {
|
||||
const got = res.headers.get(value)
|
||||
if (got !== null) out.push(`header ${value} is ${JSON.stringify(got)}, want none`)
|
||||
}
|
||||
}
|
||||
await close()
|
||||
throw new Error(`no answer on ${url} within ${timeoutMs} ms\n${log}`)
|
||||
return out
|
||||
}
|
||||
|
||||
async function main() {
|
||||
@@ -145,17 +155,30 @@ async function main() {
|
||||
.filter((l) => l !== '' && !l.startsWith('#'))
|
||||
|
||||
const reports = lines.filter((l) => l.startsWith('report ')).map((l) => l.slice('report '.length).trim())
|
||||
const checks = lines.filter((l) => !l.startsWith('report '))
|
||||
const api = await startFixtureApi({ dir: resolve(args.fixtures) })
|
||||
const noreports = lines.filter((l) => l.startsWith('noreport ')).map((l) => l.slice('noreport '.length).trim())
|
||||
const steps = lines.filter((l) => !l.startsWith('report ') && !l.startsWith('noreport '))
|
||||
const api = await startFixtureApi({ dir: resolve(args.fixtures), site: args.site })
|
||||
/** @type {{ url: string, close: () => Promise<void> } | null} */
|
||||
let nuxt = null
|
||||
const failures = /** @type {string[]} */ ([])
|
||||
try {
|
||||
nuxt = await startNuxtServer({
|
||||
outputDir: args.output,
|
||||
env: { [API_ENV]: `${api.url}${API_PREFIX}`, NUXT_SEO_NOTFOUND_TOKEN: FIXTURE_NOTFOUND_TOKEN, ...args.env },
|
||||
apiBase: `${api.url}${API_PREFIX}`,
|
||||
env: { NUXT_SEO_NOTFOUND_TOKEN: FIXTURE_NOTFOUND_TOKEN, ...args.env },
|
||||
})
|
||||
for (const line of checks) {
|
||||
for (const line of steps) {
|
||||
const resolves = /^resolves\s+(\S+)\s+(\d+)$/.exec(line)
|
||||
if (resolves) {
|
||||
const got = api.resolveCount(resolves[1] === '*' ? undefined : resolves[1])
|
||||
if (got !== Number(resolves[2])) failures.push(`${line}: the API saw ${got} resolve calls`)
|
||||
continue
|
||||
}
|
||||
const calls = /^apicalls\s+(\d+)$/.exec(line)
|
||||
if (calls) {
|
||||
if (api.requests.length !== Number(calls[1])) failures.push(`${line}: the API saw ${api.requests.length} requests (${api.requests.map((r) => `${r.method} ${r.url}`).join(', ')})`)
|
||||
continue
|
||||
}
|
||||
let check
|
||||
try {
|
||||
check = parseExpectLine(line)
|
||||
@@ -163,41 +186,39 @@ async function main() {
|
||||
failures.push(`${line}: ${/** @type {Error} */ (err).message}`)
|
||||
continue
|
||||
}
|
||||
/** @type {Record<string, string>} */
|
||||
const headers = { 'X-Forwarded-For': VISITOR_IP, Accept: 'text/html' }
|
||||
for (const o of check.options) {
|
||||
if (o.key !== 'reqheader') continue
|
||||
const h = nameValue(o.value)
|
||||
headers[h.name] = h.value
|
||||
}
|
||||
let res
|
||||
try {
|
||||
res = await fetch(nuxt.url + check.path, {
|
||||
redirect: 'manual',
|
||||
headers: { 'X-Forwarded-For': VISITOR_IP, Accept: 'text/html' },
|
||||
signal: AbortSignal.timeout(20000),
|
||||
})
|
||||
res = await fetch(nuxt.url + check.path, { redirect: 'manual', headers, signal: AbortSignal.timeout(20000) })
|
||||
} catch (err) {
|
||||
failures.push(`${line}: request failed: ${/** @type {Error} */ (err).message}`)
|
||||
continue
|
||||
}
|
||||
const body = await res.text()
|
||||
const type = res.headers.get('content-type') ?? ''
|
||||
if (res.status !== check.status) failures.push(`${line}: status ${res.status}, want ${check.status}`)
|
||||
else if (check.contains !== undefined && !body.includes(check.contains)) failures.push(`${line}: body lacks "${check.contains}"`)
|
||||
else if (check.notcontains !== undefined && body.includes(check.notcontains)) failures.push(`${line}: body contains "${check.notcontains}"`)
|
||||
else if (check.type !== undefined && !type.startsWith(check.type)) failures.push(`${line}: content-type "${type}", want prefix "${check.type}"`)
|
||||
else if (check.header !== undefined) {
|
||||
const colon = check.header.indexOf(':')
|
||||
const name = colon > 0 ? check.header.slice(0, colon).trim() : check.header
|
||||
const want = colon > 0 ? check.header.slice(colon + 1).trim() : ''
|
||||
const got = res.headers.get(name)
|
||||
if (got === null || !got.includes(want)) failures.push(`${line}: header ${name} is ${JSON.stringify(got)}, want it to contain "${want}"`)
|
||||
}
|
||||
for (const f of checkResponse(check, { status: res.status, headers: res.headers, body })) failures.push(`${line}: ${f}`)
|
||||
}
|
||||
// The 404 reports go out after the response; give them a moment.
|
||||
const reported = () => api.requests.filter((r) => r.method === 'POST' && r.url.endsWith('/notfound')).map((r) => /** @type {any} */ (r.body)?.url)
|
||||
for (let i = 0; i < 20 && reports.some((p) => !reported().includes(p)); i++) await new Promise((r) => setTimeout(r, 100))
|
||||
if (noreports.length > 0) await new Promise((r) => setTimeout(r, 500))
|
||||
for (const p of reports) {
|
||||
if (!reported().includes(p)) failures.push(`report ${p}: no 404 report reached the API (got ${JSON.stringify(reported())})`)
|
||||
const n = reported().filter((u) => u === p).length
|
||||
if (n > 1) failures.push(`report ${p}: reported ${n} times, want once per request`)
|
||||
}
|
||||
for (const p of noreports) {
|
||||
if (reported().includes(p)) failures.push(`noreport ${p}: a 404 report reached the API`)
|
||||
}
|
||||
if (api.requests.some((r) => r.url.endsWith('/notfound') && r.authorization !== `Bearer ${FIXTURE_NOTFOUND_TOKEN}`)) {
|
||||
failures.push('a 404 report carried the wrong token')
|
||||
}
|
||||
if (!api.requests.some((r) => (r.xff ?? '').includes(VISITOR_IP))) {
|
||||
if (api.requests.length > 0 && !api.requests.some((r) => (r.xff ?? '').includes(VISITOR_IP))) {
|
||||
failures.push('x-forwarded-for: the SSR fetches did not forward the visitor address')
|
||||
}
|
||||
} finally {
|
||||
@@ -209,7 +230,7 @@ async function main() {
|
||||
for (const f of failures) console.error(`smoke-ssr: FAIL ${f}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`smoke-ssr: ok (${lines.length} checks)`)
|
||||
console.log(`smoke-ssr: ok (${lines.length} checks, ${args.expect})`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
Reference in New Issue
Block a user