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:
12
testing/expect-seo-drift.txt
Normal file
12
testing/expect-seo-drift.txt
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])]) {
|
||||
|
||||
16
testing/fixtures/seo/site-drift.json
Normal file
16
testing/fixtures/seo/site-drift.json
Normal 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/" }
|
||||
}
|
||||
@@ -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": {} },
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user