feat: FAQPage node and widget headings
- FaqBlock prop schema (default true) adds a FAQPage node (@id <page URL>#faq, one Question with its acceptedAnswer as plain text per item) to the page's one JSON-LD graph through vue-seo-layer's useSchemaNode (faqSchema, faqAnswerText) - headingLevel 0 renders the title as <p class="faq-block__title"> with the same id, still labelling the section, for sidebar and footer widgets - smoke: count= and repeatable options; expectations for the widget title, one FAQPage node and the answers in the HTML inside details
This commit is contained in:
@@ -12,3 +12,11 @@
|
||||
200 / contains=<p class="playground-fallback">No FAQ</p>
|
||||
200 / notcontains=>Empty</h
|
||||
200 / notcontains=FaqSection
|
||||
# A widget block (headingLevel 0) renders its title as a styled p that still
|
||||
# labels the section (UI-SPEC L6); the page keeps one heading per content block.
|
||||
200 / contains=<p id= contains=class="faq-block__title">Acme questions</p>
|
||||
200 / count=<h2:1 count=<h3:1
|
||||
# One JSON-LD script with one FAQPage node (two blocks with schema share its
|
||||
# @id; the widget has schema off); the answers are in the HTML inside details.
|
||||
200 / count=application/ld+json:1 count="@type":"FAQPage":1 contains="acceptedAnswer":{"@type":"Answer","text":"Two days. Poland Europe"}
|
||||
200 / contains=<details class="faq-item" name= contains=<p>Two days.</p><ul><li>Poland</li><li>Europe</li></ul>
|
||||
|
||||
@@ -5,9 +5,12 @@
|
||||
// node testing/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]
|
||||
// A value runs until the next ` contains=`, ` notcontains=`, ` count=`,
|
||||
// ` type=` 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 after the last colon), as in
|
||||
// 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`; afterwards
|
||||
// at least one fixture API request must have carried that address (the SSR
|
||||
// fetches forward the visitor's x-forwarded-for).
|
||||
@@ -50,17 +53,19 @@ function parseArgs(argv) {
|
||||
return /** @type {{ output: string, fixtures: string, expect: string, env: Record<string, string> }} */ (out)
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 = /\s(contains|notcontains|count|type)=/g
|
||||
/** @type {Array<{ key: string, start: number, valueStart: number }>} */
|
||||
const marks = []
|
||||
let hit
|
||||
@@ -69,13 +74,42 @@ 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, type: string, 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 = []
|
||||
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 === 'type' && !res.type.startsWith(value)) out.push(`content-type "${res.type}", want prefix "${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`)
|
||||
else if (occurrences(res.body, value.slice(0, colon)) !== want) out.push(`body has ${occurrences(res.body, value.slice(0, colon))} of "${value.slice(0, colon)}", want ${want}`)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** @returns {Promise<number>} */
|
||||
@@ -172,10 +206,7 @@ async function main() {
|
||||
}
|
||||
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, type, body })) failures.push(`${line}: ${f}`)
|
||||
}
|
||||
if (!api.requests.some((r) => (r.xff ?? '').includes(VISITOR_IP))) {
|
||||
failures.push('x-forwarded-for: the SSR fetches did not forward the visitor address')
|
||||
|
||||
Reference in New Issue
Block a user