feat: route report and drift check at server start

- the module writes #seo-routes (static paths and route patterns) from the
  resolved pages; options seo.routeReport, seo.pagination and
  seo.htmlSitemap reach the public runtime config
- seo-route-report: POST /registry/routes once per process with the
  route-report token, one retry after 60 s on failure, nothing without a
  token, with the layer off or with routeReport false
- seo-drift: one "[vue-seo-layer] drift:" warning per mismatch between
  the CMS's site answer (URL patterns, pagination, HTML sitemap paths)
  and the build
- GET /api/__seo__/site and useSeoSite()
- smoke grammar routereport and log, fixture registry route, drift run
This commit is contained in:
Jakub Zych
2026-11-04 11:49:21 +01:00
parent 9cc2cda924
commit 3abc986f42
21 changed files with 599 additions and 20 deletions

View File

@@ -0,0 +1,12 @@
# The startup drift check against a site answer that differs from the
# build (testing/fixtures/seo/site-drift.json): a content type whose URL
# pattern no Nuxt route serves, the path pagination form against the
# layer's query form and another pl HTML sitemap path. One warning each;
# the server still starts and serves pages. Run with
# NUXT_PUBLIC_SEO_ROUTE_REPORT=false, so no route report is sent.
200 /plain contains=<title>Plain page | Playground</title>
log [vue-seo-layer] drift::3
log [vue-seo-layer] drift: the CMS's journal-post URLs (/wpisy/:slug) match no Nuxt route:1
log [vue-seo-layer] drift: the CMS paginates in the path form, the layer was built with query (seo.pagination.form):1
log [vue-seo-layer] drift: the CMS serves the pl HTML sitemap at /mapa, the layer at /mapa-strony (seo.htmlSitemap.paths):1
routereport 0

View File

@@ -1,3 +1,5 @@
# No 404 token (NUXT_SEO_NOTFOUND_TOKEN empty): a 404 page sends no report.
404 /kontakt
noreport /kontakt
# Without a token the route report is not sent either.
routereport 0

View File

@@ -75,3 +75,12 @@ noreport /stary
200 /prywatne contains=<meta name="robots" content="noindex, nofollow"> header=X-Robots-Tag: noindex, nofollow
200 /plain noheader=X-Robots-Tag count=name="robots":0
200 /blog/lwia-grzywa contains=<meta name="robots" content="index, follow">
# The site answer through the layer's server, for useSeoSite().
200 /api/__seo__/site contains="site_url":"https://example.test" header=X-Robots-Tag: noindex
# The route report at server start: the static paths with the token, not
# the signed-in page, the page with a noindex seo.robots or the declared
# noindex route. The build matches the site answer: no drift warning.
routereport 1 has=/ has=/plain has=/about lacks=/konto lacks=/szkic lacks=/prywatne
log [vue-seo-layer] drift::0

View File

@@ -4,11 +4,14 @@
// 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)
// GET /_seo/api/v1/site <dir>/seo/site.json, or <dir>/seo/<siteFile>
// (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
// POST /_seo/api/v1/registry/routes the route report: the same token rules;
// 404 with { site: false } (an older
// backend has no page registry)
// 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
@@ -52,10 +55,10 @@ function contentType(name) {
/**
* Starts the fixture API on 127.0.0.1.
* @param {{ dir: string, port?: number, token?: string, site?: boolean }} opts
* @param {{ dir: string, port?: number, token?: string, site?: boolean, siteFile?: string }} opts
* @returns {Promise<FixtureApi>}
*/
export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN, site = true }) {
export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN, site = true, siteFile = 'site.json' }) {
/** @type {FixtureRequest[]} */
const requests = []
/** @type {Record<string, unknown>} */
@@ -64,7 +67,7 @@ export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN,
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
siteAnswer = site ? readJson(join(dir, 'seo', siteFile)) : null
}
load()
@@ -103,6 +106,10 @@ export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN,
if (!token) return { status: 404, body: { error: 'not_found' } }
return req.headers.authorization === `Bearer ${token}` ? { status: 204 } : { status: 401, body: { error: 'unauthorized' } }
}
if (path === '/registry/routes' && req.method === 'POST') {
if (!site || !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') {
for (const file of [join(dir, 'verification', v[1]), join(dir, 'seo', 'verification', v[1])]) {

View File

@@ -0,0 +1,16 @@
{
"site_url": "https://example.test",
"default_locale": "pl",
"locales": ["pl", "en"],
"unprefixed_locale": "pl",
"links": { "new_tab": true },
"html_sitemap": { "enabled": true, "paths": { "pl": "/mapa", "en": "/en/sitemap" }, "page_size": 100 },
"routes": [
{ "type": "post", "pattern": "/posts/:slug" },
{ "type": "journal-post", "pattern": "/wpisy/:slug" }
],
"root_files": [],
"llms": false,
"pagination": { "form": "path", "param": "page", "segments": { "pl": "strona", "en": "page" } },
"home_crumb": { "name": "Start", "url": "https://example.test/" }
}

View File

@@ -5,7 +5,7 @@
"unprefixed_locale": "pl",
"links": { "new_tab": true },
"html_sitemap": { "enabled": true, "paths": { "pl": "/mapa-strony", "en": "/en/sitemap" }, "page_size": 100 },
"routes": [{ "type": "post", "pattern": "/blog/:slug" }],
"routes": [{ "type": "post", "pattern": "/posts/:slug" }],
"root_files": ["google1234.html", "a1b2c3d4e5f60718293a4b5c6d7e8f90.txt"],
"llms": true,
"pagination": { "form": "query", "param": "page", "segments": {} },

View File

@@ -2,7 +2,7 @@
// 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> [--no-site] [--env KEY=VALUE]...
// node testing/smoke-ssr.mjs --output <.output dir> --fixtures <dir> --expect <file> [--no-site] [--site-file NAME] [--env KEY=VALUE]...
//
// Expectation grammar, one check per line ('#' starts a comment line):
// STATUS PATH [contains=TEXT] [notcontains=TEXT] [count=TEXT:N] [type=PREFIX]
@@ -12,6 +12,12 @@
// 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
// routereport N [has=PATH]... [lacks=PATH]...
// exactly N route reports (POST /registry/routes) reached
// the API by the end of the run, each with the token;
// every has= path is in the last one, no lacks= path is
// log TEXT:N the server's output holds exactly N occurrences of TEXT
// (waits up to 3 s for them; N after the last colon)
// 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
@@ -28,8 +34,9 @@
// 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.
// makes the fixture API answer /site (and the route report) with 404, as a
// backend before SEO v0.2.0 does; --site-file answers /site with another
// file of <fixtures>/seo.
//
// The same approach as vue-faq-layer's testing/smoke-ssr.mjs; plain .mjs
// with JSDoc types so it runs from node_modules unchanged.
@@ -45,7 +52,7 @@ const OPTION_KEYS = ['contains', 'notcontains', 'count', 'type', 'header', 'nohe
/** @param {string[]} argv */
function parseArgs(argv) {
/** @type {{ output?: string, fixtures?: string, expect?: string, site: boolean, env: Record<string, string> }} */
/** @type {{ output?: string, fixtures?: string, expect?: string, site: boolean, siteFile?: string, env: Record<string, string> }} */
const out = { env: {}, site: true }
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
@@ -58,6 +65,7 @@ function parseArgs(argv) {
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 === '--site-file') out.siteFile = next()
else if (arg === '--env') {
const kv = next()
const eq = kv.indexOf('=')
@@ -66,7 +74,7 @@ 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, site: boolean, env: Record<string, string> }} */ (out)
return /** @type {{ output: string, fixtures: string, expect: string, site: boolean, siteFile?: string, env: Record<string, string> }} */ (out)
}
/**
@@ -156,9 +164,10 @@ async function main() {
const reports = lines.filter((l) => l.startsWith('report ')).map((l) => l.slice('report '.length).trim())
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} */
const routeReports = lines.filter((l) => l.startsWith('routereport '))
const steps = lines.filter((l) => !l.startsWith('report ') && !l.startsWith('noreport ') && !l.startsWith('routereport '))
const api = await startFixtureApi({ dir: resolve(args.fixtures), site: args.site, ...(args.siteFile ? { siteFile: args.siteFile } : {}) })
/** @type {{ url: string, close: () => Promise<void>, output: () => string } | null} */
let nuxt = null
const failures = /** @type {string[]} */ ([])
try {
@@ -174,6 +183,15 @@ async function main() {
if (got !== Number(resolves[2])) failures.push(`${line}: the API saw ${got} resolve calls`)
continue
}
const log = /^log\s+(.+):(\d+)$/.exec(line)
if (log) {
const want = Number(log[2])
const seen = () => occurrences(nuxt?.output() ?? '', log[1])
for (let i = 0; i < 30 && seen() < want; i++) await new Promise((r) => setTimeout(r, 100))
if (want === 0) await new Promise((r) => setTimeout(r, 500))
if (seen() !== want) failures.push(`${line}: the server output has ${seen()}\n${nuxt?.output() ?? ''}`)
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(', ')})`)
@@ -215,6 +233,26 @@ async function main() {
for (const p of noreports) {
if (reported().includes(p)) failures.push(`noreport ${p}: a 404 report reached the API`)
}
const routePosts = () => api.requests.filter((r) => r.method === 'POST' && r.url.endsWith('/registry/routes'))
for (const line of routeReports) {
const m = /^routereport\s+(\d+)((?:\s+(?:has|lacks)=\S+)*)$/.exec(line)
if (!m) {
failures.push(`${line}: expected "routereport N [has=PATH]... [lacks=PATH]..."`)
continue
}
const want = Number(m[1])
for (let i = 0; i < 20 && routePosts().length < want; i++) await new Promise((r) => setTimeout(r, 100))
if (want === 0) await new Promise((r) => setTimeout(r, 500))
const posts = routePosts()
if (posts.length !== want) failures.push(`${line}: the API saw ${posts.length} route reports`)
if (posts.some((r) => r.authorization !== `Bearer ${FIXTURE_NOTFOUND_TOKEN}`)) failures.push(`${line}: a route report carried the wrong token`)
const last = /** @type {any} */ (posts[posts.length - 1]?.body)?.routes ?? []
for (const opt of (m[2] ?? '').trim().split(/\s+/).filter(Boolean)) {
const [key, path] = [opt.slice(0, opt.indexOf('=')), opt.slice(opt.indexOf('=') + 1)]
if (key === 'has' && !last.includes(path)) failures.push(`${line}: ${path} is not in the report ${JSON.stringify(last)}`)
if (key === 'lacks' && last.includes(path)) failures.push(`${line}: ${path} is in the report ${JSON.stringify(last)}`)
}
}
if (api.requests.some((r) => r.url.endsWith('/notfound') && r.authorization !== `Bearer ${FIXTURE_NOTFOUND_TOKEN}`)) {
failures.push('a 404 report carried the wrong token')
}