feat!: render the post page on the shared SEO layer with one H1 in both Journal H1 modes (tracer)

- extend vue-seo-layer (link dependency during development) and drop
  @nuxtjs/seo: the page head, canonical and the one JSON-LD graph come
  from the CMS resolve answer; the post's own title, excerpt and image
  apply only while there is no CMS answer
- remove the journal sitemap source and the copied shared purge route,
  library and plugin (the SEO layer serves POST /api/__site__/cache/purge);
  the deprecated POST /api/__journal__/cache/purge keeps working
- post title is h1 while the Journal API reports content_h1_to_h2 on
  (or absent) and p.journal-post__title while off; journal.postTitleTag
  ('auto' | 'h1' | 'p') overrides it; body h1 headings are demoted to h2
  while the setting is on (gateJournalHtml/sanitizeJournalHtml demoteH1)
- journal.breadcrumbs option and post-title CSS tokens
- smoke harness: count=, header=, noheader= and resolves lines; the
  fixture API also answers the SEO resolve and site endpoints

BREAKING CHANGE: the layer no longer installs @nuxtjs/seo (no
nuxt-site-config, nuxt-schema-org, robots or sitemap module, no
/api/__sitemap__/journal source, no useSiteConfig()); apps take their
head, sitemaps and robots from the SEO plugin through vue-seo-layer.
This commit is contained in:
Jakub Zych
2026-11-04 13:37:11 +01:00
parent 1dd3176060
commit 289d031201
31 changed files with 1034 additions and 2846 deletions

View File

@@ -1,7 +1,19 @@
# SSR expectations against the acme fixtures (testing/fixtures/acme).
# Grammar: STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]
# The consumer enables en (default, no prefix) and pl (/pl prefix), and sets
# site.url to https://acme.test.
# Grammar: STATUS PATH [contains=TEXT] [notcontains=TEXT] [count=TEXT:N]
# [type=PREFIX] [header=NAME: VALUE] [noheader=NAME]; resolves PATH N
# The consumer enables en (default, no prefix) and pl (/pl prefix). The
# fixture SEO API (fixtures/acme/seo) answers the site URL https://acme.test.
# The post page on the shared SEO layer: exactly one H1 in both Journal H1
# modes, one canonical and one JSON-LD script (UI-SPEC L6, D-97).
200 /blog/heading-on count=<h1:1 contains=<h1 class="journal-post__title" count=application/ld+json:1 count=rel="canonical":1
200 /blog/heading-on contains=<h2>Body heading one</h2> notcontains=<p class="journal-post__title"
200 /blog/heading-off count=<h1:1 contains=<p class="journal-post__title" count=application/ld+json:1 count=rel="canonical":1
200 /blog/heading-off contains=<h1>Body heading one</h1>
# The CMS head wins: its title, no second title.
200 /blog/heading-on contains=<title>Heading On Sample | Acme Blog</title> count=<title>:1
# Without a CMS answer the post's own title and description are the fallback.
200 /blog/uncategorised-note contains=<title>Uncategorised Note</title> contains=Uncategorised Note: a short summary.
# Single post
200 /blog/hello-acme contains=Hello Acme
@@ -34,23 +46,20 @@
200 /blog/tag/release contains=Hello Acme
404 /blog/tag/nope
# RSS, sitemap, canonical and meta
# RSS, canonical and meta (sitemaps come from the CMS, not the layer)
200 /rss.xml type=application/rss+xml
200 /blog contains=href="/rss.xml"
200 /sitemap.xml contains=/blog/hello-acme
200 /sitemap.xml contains=/blog/category/quiet
200 /sitemap.xml notcontains=/blog/draft-note
200 /blog?page=2 contains=?page=2
200 /blog?page=2 contains=<link rel="canonical" href="https://acme.test/blog?page=2">
200 /blog/hello-acme contains=og:title
200 /blog/hello-acme contains=Acme says hello and introduces its blog.
# Search (the playground sets journal.routes.search: '/search'). 11 posts
# match "acme": 2 pages of 9. Not route-cached (the playground caches /**),
# noindex, follow, and left out of the sitemap.
# match "acme": 2 pages of 9. Not route-cached (the playground caches /**)
# and noindex, follow.
200 /search?q=acme contains=Hello Acme
200 /search?q=acme contains=11 results
200 /search?q=acme contains=noindex, follow
200 /search?q=acme contains=noindex, follow header=x-robots-tag: noindex, follow
200 /search?q=acme&page=2 contains=Acme Note 12
404 /search?q=acme&page=3
404 /search?q=acme&page=abc
@@ -63,7 +72,6 @@
200 /search?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E contains=Search results: &lt;script&gt;alert(1) notcontains=>Search results: <script>
200 /pl/search?q=acme contains=11 wyników
200 /pl/search?q=unsafe contains=1 wynik
200 /sitemap.xml notcontains=/search
# The header box renders its no-JS form to the search route on every page.
200 /blog contains=action="/search"
200 /pl/blog contains=action="/pl/search"

View File

@@ -4,6 +4,17 @@
// nested children, tag, search, fields=suggest, include_empty) and the author routes, and it
// records every request so a smoke run can assert header forwarding.
//
// It also stands in for the SEO plugin's public API (/_seo/api/v1), which
// vue-seo-layer calls for every page:
//
// GET /_seo/api/v1/resolve?url=… the answer for that exact url from
// <dir>/seo/resolve.json, else not_found
// GET /_seo/api/v1/site <dir>/seo/site.json, else 404 (an older
// backend)
//
// Anything else under /_seo answers 404 (no 404 reports or route reports:
// the smoke sets no token).
//
// Plain .mjs with JSDoc types: Node does not strip TypeScript under
// node_modules, and consuming apps import this file from there.
@@ -12,13 +23,14 @@ import { readFileSync } from 'node:fs'
import { join } from 'node:path'
const PREFIX = '/_journal/api/v1'
const SEO_PREFIX = '/_seo/api/v1'
const DEFAULT_PER_PAGE = 9
const MAX_PER_PAGE = 30
const SORT_COLUMNS = new Set(['published_at', 'created_at', 'updated_at', 'title', 'id'])
/**
* @typedef {{ method: string, url: string, xff: string | null }} FixtureRequest
* @typedef {{ url: string, requests: FixtureRequest[], reload: () => void, close: () => Promise<void> }} FixtureApi
* @typedef {{ url: string, requests: FixtureRequest[], resolveCount: (target?: string) => number, reload: () => void, close: () => Promise<void> }} FixtureApi
*/
/** @param {string} dir @param {string} name @param {any} fallback */
@@ -139,6 +151,10 @@ export function startFixtureApi({ dir, port = 0 }) {
/** @type {any[]} */
let authors = []
let rss = ''
/** @type {Record<string, unknown>} */
let seoAnswers = {}
/** @type {unknown} */
let seoSite = null
// Reads (or re-reads) the fixtures, so a check can change a fixture file
// while the API runs.
const load = () => {
@@ -146,6 +162,8 @@ export function startFixtureApi({ dir, port = 0 }) {
categories = readJson(dir, 'categories.json', [])
tags = readJson(dir, 'tags.json', [])
authors = readJson(dir, 'authors.json', [])
seoAnswers = readJson(join(dir, 'seo'), 'resolve.json', {})
seoSite = readJson(join(dir, 'seo'), 'site.json', null)
try {
rss = readFileSync(join(dir, 'rss.xml'), 'utf8')
} catch {
@@ -201,6 +219,17 @@ export function startFixtureApi({ dir, port = 0 }) {
body: JSON.stringify(value),
type: 'application/json',
})
if (url.pathname.startsWith(SEO_PREFIX + '/')) {
const seoPath = url.pathname.slice(SEO_PREFIX.length)
if (req.method === 'GET' && seoPath === '/resolve') {
const target = url.searchParams.get('url') ?? ''
if (!target.startsWith('/')) return json(422, { error: 'bad_url' })
const hit = seoAnswers[target]
return json(200, hit ?? { action: 'not_found', status: 404, url: target, locale: '', staging: false, seo: null })
}
if (req.method === 'GET' && seoPath === '/site' && seoSite) return json(200, seoSite)
return json(404, { error: 'not_found' })
}
if (!url.pathname.startsWith(PREFIX + '/')) return json(404, { error: 'Not found' })
if (req.method !== 'GET' && req.method !== 'HEAD') return json(405, { error: 'Method not allowed' })
const path = decodeURIComponent(url.pathname.slice(PREFIX.length))
@@ -257,6 +286,14 @@ export function startFixtureApi({ dir, port = 0 }) {
res.end(req.method === 'HEAD' ? undefined : out.body)
})
/** 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 === `${SEO_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', () => {
@@ -264,6 +301,7 @@ export function startFixtureApi({ dir, port = 0 }) {
resolve({
url: `http://127.0.0.1:${addr.port}`,
requests,
resolveCount,
reload: load,
close: () => new Promise((done) => server.close(() => done())),
})

View File

@@ -475,5 +475,51 @@
"translations": {},
"created_at": "2026-01-20T09:00:00+00:00",
"updated_at": "2026-01-20T09:00:00+00:00"
},
{
"id": 15,
"title": "Heading On Sample",
"slug": "heading-on",
"excerpt": "Heading On Sample shows the Journal H1 setting on.",
"content": null,
"content_html": "<h1>Body heading one</h1>\n<p>The body opens with its own top heading.</p>\n<h2>Body heading two</h2>\n<p>And continues below it.</p>\n",
"reading_time": 1,
"published": true,
"published_at": "2025-12-20T09:00:00+00:00",
"is_pinned": false,
"metadata": null,
"sources": null,
"author": null,
"categories": [],
"tags": [],
"featured_images": [],
"featured_image_url": null,
"translations": {},
"created_at": "2025-12-20T09:00:00+00:00",
"updated_at": "2025-12-20T09:00:00+00:00",
"content_h1_to_h2": true
},
{
"id": 16,
"title": "Heading Off Sample",
"slug": "heading-off",
"excerpt": "Heading Off Sample shows the Journal H1 setting off.",
"content": null,
"content_html": "<h1>Body heading one</h1>\n<p>The body opens with its own top heading.</p>\n<h2>Body heading two</h2>\n<p>And continues below it.</p>\n",
"reading_time": 1,
"published": true,
"published_at": "2025-12-19T09:00:00+00:00",
"is_pinned": false,
"metadata": null,
"sources": null,
"author": null,
"categories": [],
"tags": [],
"featured_images": [],
"featured_image_url": null,
"translations": {},
"created_at": "2025-12-19T09:00:00+00:00",
"updated_at": "2025-12-19T09:00:00+00:00",
"content_h1_to_h2": false
}
]

View File

@@ -0,0 +1,412 @@
{
"/blog/hello-acme": {
"action": "ok",
"status": 200,
"url": "https://acme.test/blog/hello-acme",
"locale": "en",
"staging": false,
"seo": {
"title": "Hello Acme | Acme Blog",
"description": "Acme says hello and introduces its blog.",
"canonical": "https://acme.test/blog/hello-acme",
"robots": "index, follow",
"x_robots_tag": "",
"og": {
"title": "Hello Acme",
"description": "Acme says hello and introduces its blog.",
"image": "",
"url": "https://acme.test/blog/hello-acme",
"locale": "en_US",
"locale_alternates": []
},
"twitter": {
"card": "summary_large_image",
"site": ""
},
"verification": []
},
"html_lang": "en",
"alternates": [],
"jsonld": {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": "https://acme.test/#website",
"url": "https://acme.test/",
"name": "Acme Blog"
},
{
"@type": "WebPage",
"@id": "https://acme.test/blog/hello-acme#webpage",
"url": "https://acme.test/blog/hello-acme",
"name": "Hello Acme",
"isPartOf": {
"@id": "https://acme.test/#website"
}
},
{
"@type": "BlogPosting",
"@id": "https://acme.test/blog/hello-acme#article",
"headline": "Hello Acme",
"mainEntityOfPage": {
"@id": "https://acme.test/blog/hello-acme#webpage"
}
}
]
},
"breadcrumbs": [
{
"name": "Home",
"url": "https://acme.test/",
"path": "/"
},
{
"name": "Blog",
"url": "https://acme.test/blog",
"path": "/blog"
},
{
"name": "Hello Acme",
"url": "https://acme.test/blog/hello-acme",
"path": "/blog/hello-acme"
}
]
},
"/blog/heading-on": {
"action": "ok",
"status": 200,
"url": "https://acme.test/blog/heading-on",
"locale": "en",
"staging": false,
"seo": {
"title": "Heading On Sample | Acme Blog",
"description": "Heading On Sample shows the Journal H1 setting on.",
"canonical": "https://acme.test/blog/heading-on",
"robots": "index, follow",
"x_robots_tag": "",
"og": {
"title": "Heading On Sample",
"description": "Heading On Sample shows the Journal H1 setting on.",
"image": "",
"url": "https://acme.test/blog/heading-on",
"locale": "en_US",
"locale_alternates": []
},
"twitter": {
"card": "summary_large_image",
"site": ""
},
"verification": []
},
"html_lang": "en",
"alternates": [],
"jsonld": {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": "https://acme.test/#website",
"url": "https://acme.test/",
"name": "Acme Blog"
},
{
"@type": "WebPage",
"@id": "https://acme.test/blog/heading-on#webpage",
"url": "https://acme.test/blog/heading-on",
"name": "Heading On Sample",
"isPartOf": {
"@id": "https://acme.test/#website"
}
},
{
"@type": "BlogPosting",
"@id": "https://acme.test/blog/heading-on#article",
"headline": "Heading On Sample",
"mainEntityOfPage": {
"@id": "https://acme.test/blog/heading-on#webpage"
}
}
]
},
"breadcrumbs": [
{
"name": "Home",
"url": "https://acme.test/",
"path": "/"
},
{
"name": "Blog",
"url": "https://acme.test/blog",
"path": "/blog"
},
{
"name": "Heading On Sample",
"url": "https://acme.test/blog/heading-on",
"path": "/blog/heading-on"
}
]
},
"/blog/heading-off": {
"action": "ok",
"status": 200,
"url": "https://acme.test/blog/heading-off",
"locale": "en",
"staging": false,
"seo": {
"title": "Heading Off Sample | Acme Blog",
"description": "Heading Off Sample shows the Journal H1 setting off.",
"canonical": "https://acme.test/blog/heading-off",
"robots": "index, follow",
"x_robots_tag": "",
"og": {
"title": "Heading Off Sample",
"description": "Heading Off Sample shows the Journal H1 setting off.",
"image": "",
"url": "https://acme.test/blog/heading-off",
"locale": "en_US",
"locale_alternates": []
},
"twitter": {
"card": "summary_large_image",
"site": ""
},
"verification": []
},
"html_lang": "en",
"alternates": [],
"jsonld": {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": "https://acme.test/#website",
"url": "https://acme.test/",
"name": "Acme Blog"
},
{
"@type": "WebPage",
"@id": "https://acme.test/blog/heading-off#webpage",
"url": "https://acme.test/blog/heading-off",
"name": "Heading Off Sample",
"isPartOf": {
"@id": "https://acme.test/#website"
}
},
{
"@type": "BlogPosting",
"@id": "https://acme.test/blog/heading-off#article",
"headline": "Heading Off Sample",
"mainEntityOfPage": {
"@id": "https://acme.test/blog/heading-off#webpage"
}
}
]
},
"breadcrumbs": [
{
"name": "Home",
"url": "https://acme.test/",
"path": "/"
},
{
"name": "Blog",
"url": "https://acme.test/blog",
"path": "/blog"
},
{
"name": "Heading Off Sample",
"url": "https://acme.test/blog/heading-off",
"path": "/blog/heading-off"
}
]
},
"/blog": {
"action": "ok",
"status": 200,
"url": "https://acme.test/blog",
"locale": "en",
"staging": false,
"seo": {
"title": "Latest posts | Acme Blog",
"description": "Every post of the Acme blog.",
"canonical": "https://acme.test/blog",
"robots": "index, follow",
"x_robots_tag": "",
"og": {
"title": "Latest posts",
"description": "Every post of the Acme blog.",
"image": "",
"url": "https://acme.test/blog",
"locale": "en_US",
"locale_alternates": []
},
"twitter": {
"card": "summary_large_image",
"site": ""
},
"verification": []
},
"html_lang": "en",
"alternates": [],
"jsonld": {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": "https://acme.test/#website",
"url": "https://acme.test/",
"name": "Acme Blog"
},
{
"@type": "WebPage",
"@id": "https://acme.test/blog#webpage",
"url": "https://acme.test/blog",
"name": "Latest posts",
"isPartOf": {
"@id": "https://acme.test/#website"
}
}
]
},
"breadcrumbs": [
{
"name": "Home",
"url": "https://acme.test/",
"path": "/"
},
{
"name": "Blog",
"url": "https://acme.test/blog",
"path": "/blog"
}
]
},
"/blog?page=2": {
"action": "ok",
"status": 200,
"url": "https://acme.test/blog?page=2",
"locale": "en",
"staging": false,
"seo": {
"title": "Latest posts | Acme Blog",
"description": "Every post of the Acme blog, page 2.",
"canonical": "https://acme.test/blog?page=2",
"robots": "index, follow",
"x_robots_tag": "",
"og": {
"title": "Latest posts",
"description": "Every post of the Acme blog, page 2.",
"image": "",
"url": "https://acme.test/blog?page=2",
"locale": "en_US",
"locale_alternates": []
},
"twitter": {
"card": "summary_large_image",
"site": ""
},
"verification": []
},
"html_lang": "en",
"alternates": [],
"jsonld": {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": "https://acme.test/#website",
"url": "https://acme.test/",
"name": "Acme Blog"
},
{
"@type": "WebPage",
"@id": "https://acme.test/blog?page=2#webpage",
"url": "https://acme.test/blog?page=2",
"name": "Latest posts",
"isPartOf": {
"@id": "https://acme.test/#website"
}
}
]
},
"breadcrumbs": [
{
"name": "Home",
"url": "https://acme.test/",
"path": "/"
},
{
"name": "Blog",
"url": "https://acme.test/blog",
"path": "/blog"
}
],
"pagination": {
"form": "query",
"param": "page",
"page": 2
}
},
"/blog/author/jdoe": {
"action": "ok",
"status": 200,
"url": "https://acme.test/blog/author/jdoe",
"locale": "en",
"staging": false,
"seo": {
"title": "Jane Doe | Acme Blog",
"description": "Posts by Jane Doe.",
"canonical": "",
"robots": "index, follow",
"x_robots_tag": "",
"og": {
"title": "Jane Doe",
"description": "Posts by Jane Doe.",
"image": "",
"url": "https://acme.test/blog/author/jdoe",
"locale": "en_US",
"locale_alternates": []
},
"twitter": {
"card": "summary_large_image",
"site": ""
},
"verification": []
},
"html_lang": "en",
"alternates": [],
"jsonld": {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": "https://acme.test/#website",
"url": "https://acme.test/",
"name": "Acme Blog"
},
{
"@type": "WebPage",
"@id": "https://acme.test/blog/author/jdoe#webpage",
"url": "https://acme.test/blog/author/jdoe",
"name": "Jane Doe",
"isPartOf": {
"@id": "https://acme.test/#website"
}
}
]
},
"breadcrumbs": [
{
"name": "Home",
"url": "https://acme.test/",
"path": "/"
},
{
"name": "Blog",
"url": "https://acme.test/blog",
"path": "/blog"
}
]
}
}

View File

@@ -0,0 +1,38 @@
{
"site_url": "https://acme.test",
"default_locale": "en",
"locales": [
"en",
"pl"
],
"unprefixed_locale": "en",
"links": {
"new_tab": true
},
"html_sitemap": {
"enabled": false,
"paths": {},
"page_size": 100
},
"routes": [
{
"type": "post",
"pattern": "/blog/:slug"
},
{
"type": "category",
"pattern": "/blog/category/:category"
}
],
"root_files": [],
"llms": false,
"pagination": {
"form": "query",
"param": "page",
"segments": {}
},
"home_crumb": {
"name": "Home",
"url": "https://acme.test/"
}
}

View File

@@ -63,7 +63,9 @@ export async function startNuxtServer({ outputDir, apiBase, port = 0, env = {},
while (Date.now() < deadline) {
if (exited) throw new Error(`nuxt-server: server exited before answering\n${log}`)
try {
await fetch(url + '/', { redirect: 'manual', signal: AbortSignal.timeout(2000) })
// 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))

View File

@@ -1,17 +1,29 @@
#!/usr/bin/env node
// SSR smoke check: boots the fixture Journal API and a built Nuxt server,
// then checks each line of an expectations file.
// 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] [type=PREFIX]
// A value runs until the next ` contains=`, ` notcontains=`, ` type=` or the
// end of the line, so it may contain spaces. Requests are plain GETs that do
// STATUS PATH [contains=TEXT] [notcontains=TEXT] [count=TEXT:N] [type=PREFIX]
// [header=NAME: VALUE] [noheader=NAME]
// 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. 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'
@@ -45,17 +57,21 @@ function parseArgs(argv) {
return /** @type {{ output: string, fixtures: string, expect: string, env: Record<string, string> }} */ (out)
}
const OPTION_KEYS = ['contains', 'notcontains', 'count', 'type', 'header', 'noheader']
/**
* @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 }}
* @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 }} */
const check = { status: Number(m[1]), path: m[2] }
const rest = m[3]
const re = /\s(contains|notcontains|type)=/g
const re = new RegExp(`\\s(${OPTION_KEYS.join('|')})=`, 'g')
/** @type {Array<{ key: string, start: number, valueStart: number }>} */
const marks = []
let hit
@@ -64,13 +80,57 @@ 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 }
}
/** @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`)
}
}
return out
}
async function main() {
@@ -85,8 +145,18 @@ async function main() {
let nuxt = null
const failures = /** @type {string[]} */ ([])
try {
nuxt = await startNuxtServer({ outputDir: args.output, apiBase: `${api.url}/_journal/api/v1`, env: args.env })
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)
@@ -106,11 +176,7 @@ async function main() {
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}"`)
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')