- noindexStaticRoutes: requiresAuth, noindex/none seo.robots and static
pages under seo.noindexRoutes, i18n copies included, never patterns or
the layer's HTML sitemap pages (G1, gap-2 contract)
- #seo-routes exports noindexPaths; routeReportBody(paths, noindex) answers
{routes, noindex} with the same limits and keeps the JSON body within the
older plugin's 4,113,024 bytes, dropping noindex entries first
- smoke grammar noindex= and lacksnoindex=; README carries the contract
284 lines
14 KiB
JavaScript
284 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
// 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] [--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]
|
|
// [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
|
|
// routereport N [has=PATH]... [lacks=PATH]... [noindex=PATH]... [lacksnoindex=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's routes, no lacks=
|
|
// path is; every noindex= path is in its noindex list, no
|
|
// lacksnoindex= 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
|
|
// 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 (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.
|
|
|
|
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 OPTION_KEYS = ['contains', 'notcontains', 'count', 'type', 'header', 'noheader', 'reqheader']
|
|
|
|
/** @param {string[]} argv */
|
|
function parseArgs(argv) {
|
|
/** @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]
|
|
const next = () => {
|
|
const v = argv[++i]
|
|
if (v === undefined) throw new Error(`${arg} needs a value`)
|
|
return v
|
|
}
|
|
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 === '--site-file') out.siteFile = next()
|
|
else if (arg === '--env') {
|
|
const kv = next()
|
|
const eq = kv.indexOf('=')
|
|
if (eq < 1) throw new Error(`--env expects KEY=VALUE, got ${kv}`)
|
|
out.env[kv.slice(0, eq)] = kv.slice(eq + 1)
|
|
} 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, siteFile?: string, env: Record<string, string> }} */ (out)
|
|
}
|
|
|
|
/**
|
|
* @typedef {{ status: number, path: string, options: Array<{ key: string, value: string }> }} ExpectCheck
|
|
*/
|
|
|
|
/**
|
|
* @param {string} line
|
|
* @returns {ExpectCheck}
|
|
*/
|
|
export function parseExpectLine(line) {
|
|
const m = /^(\d{3})\s+(\S+)(.*)$/.exec(line.trim())
|
|
if (!m) throw new Error('expected "STATUS PATH [options]"')
|
|
const rest = m[3]
|
|
const re = new RegExp(`\\s(${OPTION_KEYS.join('|')})=`, 'g')
|
|
/** @type {Array<{ key: string, start: number, valueStart: number }>} */
|
|
const marks = []
|
|
let hit
|
|
while ((hit = re.exec(rest)) !== null) marks.push({ key: hit[1], start: hit.index, valueStart: re.lastIndex })
|
|
if (marks.length === 0 && rest.trim() !== '') throw new Error(`unknown option text "${rest.trim()}"`)
|
|
if (marks.length > 0 && rest.slice(0, marks[0].start).trim() !== '') {
|
|
throw new Error(`unknown option text "${rest.slice(0, marks[0].start).trim()}"`)
|
|
}
|
|
const options = marks.map((mark, i) => {
|
|
const end = i + 1 < marks.length ? marks[i + 1].start : rest.length
|
|
return { key: mark.key, value: rest.slice(mark.valueStart, end).trim() }
|
|
})
|
|
return { status: Number(m[1]), path: m[2], options }
|
|
}
|
|
|
|
/** 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
|
|
}
|
|
|
|
/**
|
|
* The failures of one response against its check.
|
|
* @param {ExpectCheck} check @param {{ status: number, headers: Headers, body: string }} res
|
|
* @returns {string[]}
|
|
*/
|
|
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`)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2))
|
|
const lines = readFileSync(resolve(args.expect), 'utf8')
|
|
.split('\n')
|
|
.map((l) => l.trim())
|
|
.filter((l) => l !== '' && !l.startsWith('#'))
|
|
|
|
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 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 {
|
|
nuxt = await startNuxtServer({
|
|
outputDir: args.output,
|
|
apiBase: `${api.url}${API_PREFIX}`,
|
|
env: { NUXT_SEO_NOTFOUND_TOKEN: FIXTURE_NOTFOUND_TOKEN, ...args.env },
|
|
})
|
|
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 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(', ')})`)
|
|
continue
|
|
}
|
|
let check
|
|
try {
|
|
check = parseExpectLine(line)
|
|
} catch (err) {
|
|
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, signal: AbortSignal.timeout(20000) })
|
|
} catch (err) {
|
|
failures.push(`${line}: request failed: ${/** @type {Error} */ (err).message}`)
|
|
continue
|
|
}
|
|
const body = await res.text()
|
|
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`)
|
|
}
|
|
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|noindex|lacksnoindex)=\S+)*)$/.exec(line)
|
|
if (!m) {
|
|
failures.push(`${line}: expected "routereport N [has=PATH]... [lacks=PATH]... [noindex=PATH]... [lacksnoindex=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 lastBody = /** @type {any} */ (posts[posts.length - 1]?.body)
|
|
const last = lastBody?.routes ?? []
|
|
const lastNoindex = lastBody?.noindex ?? []
|
|
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 (key === 'noindex' && !lastNoindex.includes(path)) failures.push(`${line}: ${path} is not in the report's noindex list ${JSON.stringify(lastNoindex)}`)
|
|
if (key === 'lacksnoindex' && lastNoindex.includes(path)) failures.push(`${line}: ${path} is in the report's noindex list ${JSON.stringify(lastNoindex)}`)
|
|
}
|
|
}
|
|
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.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 {
|
|
if (nuxt) await nuxt.close()
|
|
await api.close()
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
for (const f of failures) console.error(`smoke-ssr: FAIL ${f}`)
|
|
process.exit(1)
|
|
}
|
|
console.log(`smoke-ssr: ok (${lines.length} checks, ${args.expect})`)
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(`smoke-ssr: FAIL setup: ${err instanceof Error ? err.message : String(err)}`)
|
|
process.exit(1)
|
|
})
|