- JournalPostCard: the title link is the card's only anchor; the image is no longer a link and the separate read-more link is gone; the whole card stays clickable through a CSS stretched link with the focus ring on the card; prop headingLevel (2 | 3, default 2) - related posts keep their h2 and render their cards at h3; JournalPostList passes headingLevel through - smoke checks headings=strict (one h1, no skipped level) and cardlinks=N on every fixture page; expectation files for postTitleTag forced to h1 and p (the documented two-h1 and no-h1 consequences) BREAKING CHANGE: .journal-post-card__more and the image link (.journal-post-card__image is now a div) are removed from post cards.
248 lines
10 KiB
JavaScript
Executable File
248 lines
10 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
// SSR smoke check: boots the fixture Journal and SEO API and a built Nuxt
|
|
// server, then checks each line of an expectations file.
|
|
//
|
|
// node smoke-ssr.mjs --output <.output dir> --fixtures <dir> --expect <file> [--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] [headings=strict]
|
|
// [cardlinks=N]
|
|
// resolves PATH N the fixture SEO API has seen exactly N resolve calls for
|
|
// PATH so far (PATH * counts every resolve call)
|
|
// A value runs until the next ` contains=`, ` notcontains=`, ` count=`,
|
|
// ` type=`, ` header=`, ` noheader=` 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. The same grammar
|
|
// as vue-seo-layer's testing/smoke-ssr.mjs, plus two checks of the
|
|
// heading and card rules (UI-SPEC L5, L6), both on the markup outside
|
|
// <script> elements: headings=strict wants exactly one <h1 and no skipped
|
|
// level in document order (h2 then h4 fails; going back up is fine);
|
|
// cardlinks=N wants at least one .journal-post-card and exactly N <a in
|
|
// each. Requests are plain GETs that do
|
|
// not follow redirects and carry `X-Forwarded-For: 203.0.113.7`. After the
|
|
// lines, at least one fixture API request must have carried that address
|
|
// (the SSR fetches forward the visitor's x-forwarded-for), and every
|
|
// categories request must have carried include_empty=1.
|
|
//
|
|
// The server gets the fixture API as NUXT_JOURNAL_API_BASE and, for the
|
|
// vue-seo-layer resolve calls, NUXT_SEO_API_BASE (<fixtures>/seo/resolve.json
|
|
// and site.json); --env overrides either.
|
|
|
|
import { readFileSync } from 'node:fs'
|
|
import { resolve } from 'node:path'
|
|
import { startFixtureApi } from './fixture-api.mjs'
|
|
import { startNuxtServer } from './nuxt-server.mjs'
|
|
|
|
const VISITOR_IP = '203.0.113.7'
|
|
|
|
/** @param {string[]} argv */
|
|
function parseArgs(argv) {
|
|
/** @type {{ output?: string, fixtures?: string, expect?: string, env: Record<string, string> }} */
|
|
const out = { env: {} }
|
|
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 === '--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, env: Record<string, string> }} */ (out)
|
|
}
|
|
|
|
const OPTION_KEYS = ['contains', 'notcontains', 'count', 'type', 'header', 'noheader', 'headings', 'cardlinks']
|
|
|
|
/** The page markup without <script> elements (the payload, JSON-LD). @param {string} body */
|
|
function markup(body) {
|
|
return body.replace(/<script\b[\s\S]*?<\/script>/gi, '')
|
|
}
|
|
|
|
/** The heading rule's failures (UI-SPEC L6). @param {string} body @returns {string[]} */
|
|
export function headingFailures(body) {
|
|
const levels = [...markup(body).matchAll(/<h([1-6])(?=[\s>])/gi)].map((m) => Number(m[1]))
|
|
/** @type {string[]} */
|
|
const out = []
|
|
const h1 = levels.filter((l) => l === 1).length
|
|
if (h1 !== 1) out.push(`${h1} <h1 elements, want 1`)
|
|
let previous = 0
|
|
for (const level of levels) {
|
|
if (level > previous + 1) out.push(`heading h${level} after ${previous === 0 ? 'no heading' : `h${previous}`} skips a level (order ${levels.join(',')})`)
|
|
previous = level
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** The anchors per post card. @param {string} body @returns {number[]} */
|
|
export function cardLinkCounts(body) {
|
|
const cards = markup(body).split(/<article class="journal-post-card"/).slice(1)
|
|
return cards.map((card) => (card.slice(0, card.indexOf('</article>')).match(/<a[\s>]/g) ?? []).length)
|
|
}
|
|
|
|
/**
|
|
* @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 }
|
|
}
|
|
|
|
/** @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 colon = value.indexOf(':')
|
|
const name = colon > 0 ? value.slice(0, colon).trim() : value.trim()
|
|
const want = colon > 0 ? value.slice(colon + 1).trim() : ''
|
|
const got = res.headers.get(name)
|
|
if (got === null || got.trim() !== want) out.push(`header ${name} is ${JSON.stringify(got)}, want "${want}"`)
|
|
} else if (key === 'noheader') {
|
|
const got = res.headers.get(value)
|
|
if (got !== null) out.push(`header ${value} is ${JSON.stringify(got)}, want none`)
|
|
} else if (key === 'headings') {
|
|
if (value !== 'strict') out.push(`headings=${value}: want headings=strict`)
|
|
else out.push(...headingFailures(res.body))
|
|
} else if (key === 'cardlinks') {
|
|
const want = Number(value)
|
|
const counts = cardLinkCounts(res.body)
|
|
if (!Number.isInteger(want)) out.push(`cardlinks=${value}: want a number`)
|
|
else if (counts.length === 0) out.push('no .journal-post-card on the page')
|
|
else if (counts.some((n) => n !== want)) out.push(`post cards hold ${counts.join(',')} links, want ${want} each`)
|
|
}
|
|
}
|
|
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 api = await startFixtureApi({ dir: resolve(args.fixtures) })
|
|
/** @type {{ url: string, close: () => Promise<void> } | null} */
|
|
let nuxt = null
|
|
const failures = /** @type {string[]} */ ([])
|
|
try {
|
|
nuxt = await startNuxtServer({
|
|
outputDir: args.output,
|
|
apiBase: `${api.url}/_journal/api/v1`,
|
|
env: { NUXT_SEO_API_BASE: `${api.url}/_seo/api/v1`, ...args.env },
|
|
})
|
|
for (const line of lines) {
|
|
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
|
|
}
|
|
let check
|
|
try {
|
|
check = parseExpectLine(line)
|
|
} catch (err) {
|
|
failures.push(`${line}: ${/** @type {Error} */ (err).message}`)
|
|
continue
|
|
}
|
|
let res
|
|
try {
|
|
res = await fetch(nuxt.url + check.path, {
|
|
redirect: 'manual',
|
|
headers: { 'X-Forwarded-For': VISITOR_IP },
|
|
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}`)
|
|
}
|
|
if (!api.requests.some((r) => (r.xff ?? '').includes(VISITOR_IP))) {
|
|
failures.push('x-forwarded-for: x-forwarded-for not forwarded')
|
|
}
|
|
// Categories are always requested with include_empty=1, so empty ones resolve.
|
|
for (const r of api.requests) {
|
|
const u = new URL(r.url, 'http://fixture.local')
|
|
if (u.pathname.endsWith('/categories') && u.searchParams.get('include_empty') !== '1') {
|
|
failures.push(`include_empty: ${r.url} requested without include_empty=1`)
|
|
}
|
|
}
|
|
} 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)`)
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(`smoke-ssr: FAIL setup: ${err instanceof Error ? err.message : String(err)}`)
|
|
process.exit(1)
|
|
})
|