From 06d43e04e7dfccb073e6bb9437404ea743f7ff4c Mon Sep 17 00:00:00 2001 From: Aileen Booker Date: Wed, 19 Aug 2026 10:12:46 +0400 Subject: [PATCH 1/3] Added manual live Ghost content smoke Added a privacy-bounded structural census runner and manual-only upstream workflow for `main.ghost.is`. The runner validates the target, transport, Content API pagination, extractor invariants, and canonical aggregate signatures while sanitizing every report; deterministic tests cover the workflow trust boundary and offline failure paths. --- .../workflows/live-ghost-content-smoke.yml | 29 + packages/algolia-html-extractor/package.json | 1 + packages/algolia-html-extractor/smoke/cli.mts | 67 ++ .../smoke/live-ghost-content-smoke.mts | 632 +++++++++++++++++ .../test/live-ghost-content-smoke.test.ts | 640 ++++++++++++++++++ .../live-ghost-content-smoke.workflow.test.ts | 84 +++ packages/algolia-html-extractor/tsconfig.json | 2 +- vitest.config.mjs | 1 + 8 files changed, 1455 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/live-ghost-content-smoke.yml create mode 100644 packages/algolia-html-extractor/smoke/cli.mts create mode 100644 packages/algolia-html-extractor/smoke/live-ghost-content-smoke.mts create mode 100644 packages/algolia-html-extractor/test/live-ghost-content-smoke.test.ts create mode 100644 packages/algolia-html-extractor/test/live-ghost-content-smoke.workflow.test.ts diff --git a/.github/workflows/live-ghost-content-smoke.yml b/.github/workflows/live-ghost-content-smoke.yml new file mode 100644 index 00000000..b96aad87 --- /dev/null +++ b/.github/workflows/live-ghost-content-smoke.yml @@ -0,0 +1,29 @@ +name: Live Ghost content smoke + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + smoke: + name: Observe live Ghost content + if: github.repository == 'TryGhost/algolia' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Run live content smoke + env: + GHOST_URL: https://main.ghost.is + GHOST_API_VERSION: v6.0 + MAIN_GHOST_CONTENT_API_KEY: ${{ secrets.MAIN_GHOST_CONTENT_API_KEY }} + run: pnpm --filter @tryghost/algolia-html-extractor smoke:live diff --git a/packages/algolia-html-extractor/package.json b/packages/algolia-html-extractor/package.json index b9a2a66a..3b15384b 100644 --- a/packages/algolia-html-extractor/package.json +++ b/packages/algolia-html-extractor/package.json @@ -26,6 +26,7 @@ "lint": "oxlint --quiet . && oxfmt --check .", "posttest": "pnpm typecheck && pnpm lint", "build": "tsc --project tsconfig.build.json", + "smoke:live": "node smoke/cli.mts", "prepack": "pnpm build" }, "files": [ diff --git a/packages/algolia-html-extractor/smoke/cli.mts b/packages/algolia-html-extractor/smoke/cli.mts new file mode 100644 index 00000000..70847238 --- /dev/null +++ b/packages/algolia-html-extractor/smoke/cli.mts @@ -0,0 +1,67 @@ +import {appendFile} from 'node:fs/promises'; + +import { + SmokeError, + runLiveGhostContentSmoke, + type SmokeTransport, + type SmokeTransportRequest +} from './live-ghost-content-smoke.mts'; + +const createRequestUrl = (request: SmokeTransportRequest): URL => { + const requestUrl = new URL(`/ghost/api/content/${request.contentType}/`, request.target); + requestUrl.searchParams.set('key', request.contentApiKey); + requestUrl.searchParams.set('fields', request.fields); + requestUrl.searchParams.set('formats', request.formats); + requestUrl.searchParams.set('limit', String(request.limit)); + requestUrl.searchParams.set('page', String(request.page)); + return requestUrl; +}; + +const transport: SmokeTransport = async request => { + const response = await fetch(createRequestUrl(request), { + method: 'GET', + headers: {'Accept-Version': request.apiVersion}, + redirect: request.redirect + }); + + let body: unknown = null; + if (response.status >= 200 && response.status < 300) { + try { + body = await response.json(); + } catch { + body = null; + } + } + + return { + status: response.status, + redirected: response.redirected, + body + }; +}; + +const summaryPath = process.env.GITHUB_STEP_SUMMARY; +if (summaryPath === undefined || summaryPath === '') { + process.stderr.write('Live Ghost content smoke failed: operational-failure\n'); + process.exitCode = 1; +} else { + try { + const report = await runLiveGhostContentSmoke({ + target: process.env.GHOST_URL ?? '', + apiVersion: process.env.GHOST_API_VERSION ?? '', + contentApiKey: process.env.MAIN_GHOST_CONTENT_API_KEY ?? '', + transport, + clock: () => new Date(), + summarySink: summary => appendFile(summaryPath, summary, 'utf8') + }); + + process.stdout.write(`Live Ghost content smoke: ${report.category}\n`); + if (report.category !== 'ok') { + process.exitCode = 1; + } + } catch (error) { + const category = error instanceof SmokeError ? error.category : 'operational-failure'; + process.stderr.write(`Live Ghost content smoke failed: ${category}\n`); + process.exitCode = 1; + } +} diff --git a/packages/algolia-html-extractor/smoke/live-ghost-content-smoke.mts b/packages/algolia-html-extractor/smoke/live-ghost-content-smoke.mts new file mode 100644 index 00000000..960cc1f0 --- /dev/null +++ b/packages/algolia-html-extractor/smoke/live-ghost-content-smoke.mts @@ -0,0 +1,632 @@ +import {createHash} from 'node:crypto'; + +import {parse, type DefaultTreeAdapterTypes} from 'parse5'; + +import {extract} from '../index.mts'; + +const EXPECTED_TARGET = 'https://main.ghost.is' as const; +const EXPECTED_API_VERSION = 'v6.0' as const; +const PAGE_LIMIT = 100 as const; + +export type GhostContentType = 'posts' | 'pages'; +export type SignatureId = `sha256:${string}`; +export type SmokeResultCategory = + | 'ok' + | 'operational-failure' + | 'schema-drift' + | 'structural-drift' + | 'extractor-failure'; + +export type SmokeTransportRequest = Readonly<{ + target: typeof EXPECTED_TARGET; + apiVersion: typeof EXPECTED_API_VERSION; + contentApiKey: string; + contentType: GhostContentType; + page: number; + limit: typeof PAGE_LIMIT; + fields: 'html'; + formats: 'html'; + redirect: 'error'; +}>; + +export type SmokeTransportResponse = Readonly<{ + status: number; + redirected: boolean; + body: unknown; +}>; + +export type SmokeTransport = (request: SmokeTransportRequest) => Promise; + +export type ResourceTotals = Readonly<{ + pages: number; + items: number; +}>; + +export type SmokeReport = Readonly<{ + category: SmokeResultCategory; + observedAt: string; + target: typeof EXPECTED_TARGET; + apiVersion: typeof EXPECTED_API_VERSION; + totals: Readonly>; + signatures: readonly Readonly<{id: SignatureId; count: number}>[]; + drift: Readonly<{ + added: readonly SignatureId[]; + missing: readonly SignatureId[]; + countChanged: readonly SignatureId[]; + }>; +}>; + +export type LiveGhostContentSmokeOptions = Readonly<{ + target: string; + apiVersion: string; + contentApiKey: string; + transport: SmokeTransport; + clock: () => Date; + summarySink: (summary: string) => void | Promise; + baseline?: Readonly>; +}>; + +export type SmokeErrorCode = + | 'invalid-credentials' + | 'invalid-target' + | 'invalid-api-version' + | 'clock-failure' + | 'transport-failure' + | 'summary-failure' + | 'redirect-rejected' + | 'http-failure' + | 'invalid-schema' + | 'invalid-pagination' + | 'empty-census' + | 'normalization-failure' + | 'extractor-invariant'; + +type FailureCategory = Exclude; + +type Element = DefaultTreeAdapterTypes.Element; +type Node = DefaultTreeAdapterTypes.Node; +type ParentNode = DefaultTreeAdapterTypes.ParentNode; + +type SmokeState = { + totals: Record; + signatureCounts: Map; +}; + +type Pagination = Readonly<{ + page: number; + limit: number; + pages: number; + total: number; + next: number | null; + prev: number | null; +}>; + +class SmokeAbort extends Error { + readonly category: FailureCategory; + readonly code: SmokeErrorCode; + + constructor(category: FailureCategory, code: SmokeErrorCode) { + super(category); + this.category = category; + this.code = code; + } +} + +export class SmokeError extends Error { + readonly category: FailureCategory; + readonly code: SmokeErrorCode; + readonly report: SmokeReport; + + constructor(category: FailureCategory, code: SmokeErrorCode, report: SmokeReport) { + super(`Live Ghost content smoke failed: ${category}`); + this.name = 'SmokeError'; + this.category = category; + this.code = code; + this.report = report; + } +} + +const createState = (): SmokeState => ({ + totals: { + posts: {pages: 0, items: 0}, + pages: {pages: 0, items: 0} + }, + signatureCounts: new Map() +}); + +const sortSignatureIds = (identifiers: Iterable): readonly SignatureId[] => { + return [...identifiers].sort(); +}; + +const classifyDrift = ( + signatureCounts: ReadonlyMap, + baseline: Readonly> | undefined +): SmokeReport['drift'] => { + if (baseline === undefined) { + return {added: [], missing: [], countChanged: []}; + } + + const baselineIds = Object.keys(baseline) as SignatureId[]; + const added = sortSignatureIds( + [...signatureCounts.keys()].filter(identifier => baseline[identifier] === undefined) + ); + const missing = sortSignatureIds( + baselineIds.filter(identifier => !signatureCounts.has(identifier)) + ); + const countChanged = sortSignatureIds( + baselineIds.filter(identifier => { + const observedCount = signatureCounts.get(identifier); + return observedCount !== undefined && observedCount !== baseline[identifier]; + }) + ); + + return {added, missing, countChanged}; +}; + +const createReport = ( + category: SmokeResultCategory, + observedAt: string, + state: SmokeState, + baseline: Readonly> | undefined +): SmokeReport => ({ + category, + observedAt, + target: EXPECTED_TARGET, + apiVersion: EXPECTED_API_VERSION, + totals: { + posts: {...state.totals.posts}, + pages: {...state.totals.pages} + }, + signatures: sortSignatureIds(state.signatureCounts.keys()).map(id => ({ + id, + count: state.signatureCounts.get(id) ?? 0 + })), + drift: classifyDrift(state.signatureCounts, baseline) +}); + +const formatIdentifiers = (identifiers: readonly SignatureId[]): string => { + return identifiers.length === 0 ? 'none' : identifiers.join(', '); +}; + +export function formatSmokeSummary(report: SmokeReport): string { + const signatureLines = report.signatures.map(({id, count}) => `| ${id} | ${count} |`); + const signatureTable = signatureLines.length === 0 ? '| none | 0 |' : signatureLines.join('\n'); + + return [ + '# Live Ghost content smoke', + '', + `Result: ${report.category}`, + `Observed: ${report.observedAt}`, + `Target: ${report.target}`, + `API version: ${report.apiVersion}`, + '', + '| Resource | Pages | Items |', + '| --- | ---: | ---: |', + `| posts | ${report.totals.posts.pages} | ${report.totals.posts.items} |`, + `| pages | ${report.totals.pages.pages} | ${report.totals.pages.items} |`, + '', + `Distinct signatures: ${report.signatures.length}`, + '', + '| Signature | Count |', + '| --- | ---: |', + signatureTable, + '', + `Added: ${formatIdentifiers(report.drift.added)}`, + `Missing: ${formatIdentifiers(report.drift.missing)}`, + `Count changed: ${formatIdentifiers(report.drift.countChanged)}`, + '' + ].join('\n'); +} + +const readObservedAt = (clock: () => Date): string => { + let observedAt: Date; + try { + observedAt = clock(); + } catch { + throw new SmokeAbort('operational-failure', 'clock-failure'); + } + + if (!(observedAt instanceof Date) || !Number.isFinite(observedAt.valueOf())) { + throw new SmokeAbort('operational-failure', 'clock-failure'); + } + + return new Date(observedAt.valueOf()).toISOString(); +}; + +const validateOptions = (options: LiveGhostContentSmokeOptions): void => { + if (typeof options.contentApiKey !== 'string' || options.contentApiKey.trim().length === 0) { + throw new SmokeAbort('operational-failure', 'invalid-credentials'); + } + + if (options.target !== EXPECTED_TARGET) { + throw new SmokeAbort('operational-failure', 'invalid-target'); + } + + if (options.apiVersion !== EXPECTED_API_VERSION) { + throw new SmokeAbort('operational-failure', 'invalid-api-version'); + } +}; + +const SIGNATURE_ID_PATTERN = /^sha256:[0-9a-f]{64}$/u; + +const validateBaseline = ( + baseline: Readonly> | undefined +): Readonly> | undefined => { + if (baseline === undefined) { + return undefined; + } + + if (!isObject(baseline)) { + throw new SmokeAbort('schema-drift', 'invalid-schema'); + } + + for (const [identifier, count] of Object.entries(baseline)) { + if (!SIGNATURE_ID_PATTERN.test(identifier) || !isPositiveInteger(count)) { + throw new SmokeAbort('schema-drift', 'invalid-schema'); + } + } + + return baseline; +}; + +const isObject = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null && !Array.isArray(value); +}; + +const isNonNegativeInteger = (value: unknown): value is number => { + return typeof value === 'number' && Number.isInteger(value) && value >= 0; +}; + +const isPositiveInteger = (value: unknown): value is number => { + return typeof value === 'number' && Number.isInteger(value) && value > 0; +}; + +const readPagination = (body: Record, expectedPage: number): Pagination => { + const meta = body.meta; + if (!isObject(meta) || !isObject(meta.pagination)) { + throw new SmokeAbort('schema-drift', 'invalid-schema'); + } + + const {page, limit, pages, total, next, prev} = meta.pagination; + const hasValidScalars = + page === expectedPage && + limit === PAGE_LIMIT && + isPositiveInteger(pages) && + pages >= page && + isNonNegativeInteger(total); + if (!hasValidScalars) { + throw new SmokeAbort('schema-drift', 'invalid-pagination'); + } + + const expectedNext = page < pages ? page + 1 : null; + const expectedPrevious = page === 1 ? null : page - 1; + const hasValidNext = next === null || isPositiveInteger(next); + const hasValidPrevious = prev === null || isPositiveInteger(prev); + if (!hasValidNext || !hasValidPrevious || next !== expectedNext || prev !== expectedPrevious) { + throw new SmokeAbort('schema-drift', 'invalid-pagination'); + } + + return {page, limit, pages, total, next, prev}; +}; + +const requestPage = async ( + options: LiveGhostContentSmokeOptions, + contentType: GhostContentType, + page: number +): Promise> => { + let response: SmokeTransportResponse; + try { + response = await options.transport({ + target: EXPECTED_TARGET, + apiVersion: EXPECTED_API_VERSION, + contentApiKey: options.contentApiKey, + contentType, + page, + limit: PAGE_LIMIT, + fields: 'html', + formats: 'html', + redirect: 'error' + }); + } catch { + throw new SmokeAbort('operational-failure', 'transport-failure'); + } + + if (!isObject(response) || typeof response.redirected !== 'boolean') { + throw new SmokeAbort('operational-failure', 'transport-failure'); + } + + if (response.redirected) { + throw new SmokeAbort('operational-failure', 'redirect-rejected'); + } + + if (!isNonNegativeInteger(response.status) || response.status < 200 || response.status >= 300) { + throw new SmokeAbort('operational-failure', 'http-failure'); + } + + if (!isObject(response.body)) { + throw new SmokeAbort('schema-drift', 'invalid-schema'); + } + + return response.body; +}; + +const STRUCTURAL_ATTRIBUTE_NAMES = [ + 'id', + 'name', + 'href', + 'src', + 'alt', + 'data-kg-toggle-state', + 'data-kg-background-image', + 'data-kg-thumbnail', + 'data-kg-custom-thumbnail', + 'data-kg-transistor-embed' +] as const; +const SELECTED_TAGS = ['p', 'pre', 'td', 'li'] as const; +const HEADING_TAGS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const; +const SELECTED_TAG_SET: ReadonlySet = new Set(SELECTED_TAGS); +const HEADING_TAG_SET: ReadonlySet = new Set(HEADING_TAGS); + +const isElement = (node: Node): node is Element => 'tagName' in node; + +const getPresentAttributeNames = (element: Element): readonly string[] => { + const attributeNames = new Set(element.attrs.map(attribute => attribute.name)); + return STRUCTURAL_ATTRIBUTE_NAMES.filter(name => attributeNames.has(name)); +}; + +const getGhostClassTokens = (element: Element): readonly string[] => { + const classValue = element.attrs.find(attribute => attribute.name === 'class')?.value ?? ''; + return [...new Set(classValue.split(/\s+/u).filter(token => token.startsWith('kg-')))].sort(); +}; + +const hasNonEmptyAnchor = (element: Element): boolean => { + return element.attrs.some( + attribute => + (attribute.name === 'id' || attribute.name === 'name') && attribute.value !== '' + ); +}; + +const hasDescendantAnchor = (parent: ParentNode): boolean => { + for (const child of parent.childNodes) { + if (!isElement(child)) { + continue; + } + if (hasNonEmptyAnchor(child) || hasDescendantAnchor(child)) { + return true; + } + } + return false; +}; + +const getHeadingAnchorShape = (element: Element): 'direct' | 'descendant' | 'none' => { + if (hasNonEmptyAnchor(element)) { + return 'direct'; + } + return hasDescendantAnchor(element) ? 'descendant' : 'none'; +}; + +const normalizeStructure = (renderedHtml: string): string => { + const document = parse(renderedHtml); + const nodes: Array<{ + tag: string; + parent: number | null; + kgClasses: readonly string[]; + attributes: readonly string[]; + }> = []; + const headings: Array<{level: string; anchor: 'direct' | 'descendant' | 'none'}> = []; + const selectedCounts: Record<(typeof SELECTED_TAGS)[number], number> = { + p: 0, + pre: 0, + td: 0, + li: 0 + }; + const semanticGaps = { + caption: false, + tableHeader: false, + blockquote: false, + figure: false, + cardWrapper: false + }; + + const visit = (parent: ParentNode, parentIndex: number | null): void => { + for (const child of parent.childNodes) { + if (!isElement(child)) { + continue; + } + + const kgClasses = getGhostClassTokens(child); + const nodeIndex = nodes.length; + nodes.push({ + tag: child.tagName, + parent: parentIndex, + kgClasses, + attributes: getPresentAttributeNames(child) + }); + + if (SELECTED_TAG_SET.has(child.tagName)) { + selectedCounts[child.tagName as keyof typeof selectedCounts] += 1; + } + if (HEADING_TAG_SET.has(child.tagName)) { + headings.push({level: child.tagName, anchor: getHeadingAnchorShape(child)}); + } + + semanticGaps.caption ||= child.tagName === 'figcaption' || child.tagName === 'caption'; + semanticGaps.tableHeader ||= child.tagName === 'th'; + semanticGaps.blockquote ||= child.tagName === 'blockquote'; + semanticGaps.figure ||= child.tagName === 'figure'; + semanticGaps.cardWrapper ||= kgClasses.some( + className => className === 'kg-card' || className.endsWith('-card') + ); + + visit(child, nodeIndex); + } + }; + + visit(document, null); + return JSON.stringify({version: 1, nodes, headings, selectedCounts, semanticGaps}); +}; + +const createSignature = (canonicalStructure: string): SignatureId => { + return `sha256:${createHash('sha256').update(canonicalStructure, 'utf8').digest('hex')}`; +}; + +const validateExtractionFragments = (renderedHtml: string): void => { + let fragments: unknown; + try { + fragments = extract(renderedHtml); + } catch { + throw new SmokeAbort('extractor-failure', 'extractor-invariant'); + } + + if (!Array.isArray(fragments)) { + throw new SmokeAbort('extractor-failure', 'extractor-invariant'); + } + + const allowedSourceTags: ReadonlySet = new Set(SELECTED_TAGS); + const allowedHeadingRanks: ReadonlySet = new Set([40, 50, 60, 70, 80, 90, 100]); + for (const [position, fragment] of fragments.entries()) { + if (!isObject(fragment)) { + throw new SmokeAbort('extractor-failure', 'extractor-invariant'); + } + + const hasValidAnchor = fragment.anchor === null || typeof fragment.anchor === 'string'; + const hasValidHeadings = + Array.isArray(fragment.headingPath) && + fragment.headingPath.every(heading => typeof heading === 'string'); + const isValid = + typeof fragment.html === 'string' && + typeof fragment.text === 'string' && + hasValidHeadings && + hasValidAnchor && + fragment.position === position && + typeof fragment.sourceTag === 'string' && + allowedSourceTags.has(fragment.sourceTag) && + typeof fragment.headingRank === 'number' && + allowedHeadingRanks.has(fragment.headingRank); + if (!isValid) { + throw new SmokeAbort('extractor-failure', 'extractor-invariant'); + } + } +}; + +const observeHtml = (renderedHtml: string, state: SmokeState): void => { + let firstPass: string; + let secondPass: string; + try { + firstPass = normalizeStructure(renderedHtml); + secondPass = normalizeStructure(renderedHtml); + } catch { + throw new SmokeAbort('schema-drift', 'normalization-failure'); + } + + if (firstPass !== secondPass) { + throw new SmokeAbort('schema-drift', 'normalization-failure'); + } + + validateExtractionFragments(renderedHtml); + const signature = createSignature(firstPass); + state.signatureCounts.set(signature, (state.signatureCounts.get(signature) ?? 0) + 1); +}; + +const readContentType = async ( + options: LiveGhostContentSmokeOptions, + contentType: GhostContentType, + state: SmokeState +): Promise => { + const visitedPages = new Set(); + let currentPage = 1; + let declaredPages: number | null = null; + let declaredTotal: number | null = null; + + while (true) { + if (visitedPages.has(currentPage)) { + throw new SmokeAbort('schema-drift', 'invalid-pagination'); + } + visitedPages.add(currentPage); + + const body = await requestPage(options, contentType, currentPage); + const items = body[contentType]; + if (!Array.isArray(items) || items.length > PAGE_LIMIT) { + throw new SmokeAbort('schema-drift', 'invalid-schema'); + } + + const pagination = readPagination(body, currentPage); + declaredPages ??= pagination.pages; + declaredTotal ??= pagination.total; + if (pagination.pages !== declaredPages || pagination.total !== declaredTotal) { + throw new SmokeAbort('schema-drift', 'invalid-pagination'); + } + + for (const item of items) { + if (!isObject(item) || typeof item.html !== 'string') { + throw new SmokeAbort('schema-drift', 'invalid-schema'); + } + observeHtml(item.html, state); + state.totals[contentType].items += 1; + } + state.totals[contentType].pages += 1; + + if (pagination.next === null) { + break; + } + if (visitedPages.has(pagination.next)) { + throw new SmokeAbort('schema-drift', 'invalid-pagination'); + } + currentPage = pagination.next; + } + + if ( + state.totals[contentType].pages !== declaredPages || + state.totals[contentType].items !== declaredTotal + ) { + throw new SmokeAbort('schema-drift', 'invalid-pagination'); + } +}; + +const writeSummary = async ( + options: LiveGhostContentSmokeOptions, + report: SmokeReport, + observedAt: string, + state: SmokeState, + baseline: Readonly> | undefined +): Promise => { + try { + await options.summarySink(formatSmokeSummary(report)); + } catch { + const failureReport = createReport('operational-failure', observedAt, state, baseline); + throw new SmokeError('operational-failure', 'summary-failure', failureReport); + } +}; + +export async function runLiveGhostContentSmoke( + options: LiveGhostContentSmokeOptions +): Promise { + let observedAt = new Date(0).toISOString(); + const state = createState(); + let baseline: Readonly> | undefined; + + try { + observedAt = readObservedAt(options.clock); + validateOptions(options); + baseline = validateBaseline(options.baseline); + await readContentType(options, 'posts', state); + await readContentType(options, 'pages', state); + if (state.totals.posts.items + state.totals.pages.items === 0) { + throw new SmokeAbort('schema-drift', 'empty-census'); + } + } catch (error) { + const failure = + error instanceof SmokeAbort + ? error + : new SmokeAbort('operational-failure', 'transport-failure'); + const report = createReport(failure.category, observedAt, state, baseline); + await writeSummary(options, report, observedAt, state, baseline); + throw new SmokeError(failure.category, failure.code, report); + } + + const drift = classifyDrift(state.signatureCounts, baseline); + const category = drift.added.length === 0 ? 'ok' : 'structural-drift'; + const report = createReport(category, observedAt, state, baseline); + await writeSummary(options, report, observedAt, state, baseline); + return report; +} diff --git a/packages/algolia-html-extractor/test/live-ghost-content-smoke.test.ts b/packages/algolia-html-extractor/test/live-ghost-content-smoke.test.ts new file mode 100644 index 00000000..56a4006d --- /dev/null +++ b/packages/algolia-html-extractor/test/live-ghost-content-smoke.test.ts @@ -0,0 +1,640 @@ +import {createHash} from 'node:crypto'; + +import {describe, expect, it, vi} from 'vitest'; + +import { + SmokeError, + runLiveGhostContentSmoke, + type LiveGhostContentSmokeOptions, + type SmokeTransport, + type SmokeTransportResponse +} from '../smoke/live-ghost-content-smoke.mts'; + +const FIXED_TIME = new Date('2026-08-19T04:17:00.000Z'); + +const createOptions = ( + transport: SmokeTransport, + overrides: Partial = {} +): LiveGhostContentSmokeOptions => ({ + target: 'https://main.ghost.is', + apiVersion: 'v6.0', + contentApiKey: 'test-content-api-key', + transport, + clock: () => FIXED_TIME, + summarySink: () => undefined, + ...overrides +}); + +const createSuccessfulTransport = ( + postHtml: readonly string[], + pageHtml: readonly string[] +): SmokeTransport => { + return vi.fn(async request => { + const htmlValues = request.contentType === 'posts' ? postHtml : pageHtml; + return { + status: 200, + redirected: false, + body: { + [request.contentType]: htmlValues.map(html => ({html})), + meta: { + pagination: { + page: 1, + limit: 100, + pages: 1, + total: htmlValues.length, + next: null, + prev: null + } + } + } + }; + }); +}; + +const signatureFor = (canonicalStructure: unknown): `sha256:${string}` => { + const serializedStructure = JSON.stringify(canonicalStructure); + return `sha256:${createHash('sha256').update(serializedStructure, 'utf8').digest('hex')}`; +}; + +describe('runLiveGhostContentSmoke', () => { + it.each([ + ['missing credentials', {contentApiKey: ''}], + ['unexpected target', {target: 'https://example.com'}], + ['unexpected API version', {apiVersion: 'v5.0'}] + ])('rejects %s before transport and writes a safe failure summary', async (_name, invalid) => { + const transport = vi.fn(); + const summaries: string[] = []; + const secret = 'must-not-escape'; + + const execution = runLiveGhostContentSmoke( + createOptions(transport, { + contentApiKey: secret, + summarySink: summary => { + summaries.push(summary); + }, + ...invalid + }) + ); + + await expect(execution).rejects.toMatchObject({ + category: 'operational-failure' + }); + expect(transport).not.toHaveBeenCalled(); + expect(summaries).toHaveLength(1); + expect(summaries[0]).toContain('operational-failure'); + expect(summaries[0]).not.toContain(secret); + expect(summaries[0]).not.toContain('example.com'); + }); + + it('paginates posts and pages independently and reports only aggregate structure', async () => { + const firstStructure = [ + '

Private heading one

', + '
', + 'Private alternative one', + '
', + '

Private prose one

' + ].join(''); + const sameStructureWithDifferentValues = [ + '

Private heading two

', + '
', + 'Private alternative two', + '
', + '

Private prose two

' + ].join(''); + const responses: SmokeTransportResponse[] = [ + { + status: 200, + redirected: false, + body: { + posts: [{id: 'private-post-id', html: firstStructure}], + meta: { + pagination: {page: 1, limit: 100, pages: 2, total: 2, next: 2, prev: null} + } + } + }, + { + status: 200, + redirected: false, + body: { + posts: [{html: sameStructureWithDifferentValues}], + meta: { + pagination: {page: 2, limit: 100, pages: 2, total: 2, next: null, prev: 1} + } + } + }, + { + status: 200, + redirected: false, + body: { + pages: [{html: '
Private quotation
'}], + meta: { + pagination: { + page: 1, + limit: 100, + pages: 1, + total: 1, + next: null, + prev: null + } + } + } + } + ]; + const transport = vi.fn(async () => { + const response = responses.shift(); + if (response === undefined) { + throw new Error('unexpected request'); + } + return response; + }); + const summaries: string[] = []; + + const report = await runLiveGhostContentSmoke( + createOptions(transport, { + summarySink: summary => { + summaries.push(summary); + } + }) + ); + + expect(report).toMatchObject({ + category: 'ok', + observedAt: '2026-08-19T04:17:00.000Z', + target: 'https://main.ghost.is', + apiVersion: 'v6.0', + totals: { + posts: {pages: 2, items: 2}, + pages: {pages: 1, items: 1} + }, + drift: {added: [], missing: [], countChanged: []} + }); + expect(report.signatures.map(signature => signature.count).sort()).toEqual([1, 2]); + expect(report.signatures.every(({id}) => /^sha256:[0-9a-f]{64}$/.test(id))).toBe(true); + expect( + transport.mock.calls.map(([request]) => [request.contentType, request.page]) + ).toEqual([ + ['posts', 1], + ['posts', 2], + ['pages', 1] + ]); + expect(transport.mock.calls[0]?.[0]).toEqual({ + target: 'https://main.ghost.is', + apiVersion: 'v6.0', + contentApiKey: 'test-content-api-key', + contentType: 'posts', + page: 1, + limit: 100, + fields: 'html', + formats: 'html', + redirect: 'error' + }); + expect(summaries).toHaveLength(1); + expect(summaries[0]).toContain('Result: ok'); + expect(summaries[0]).not.toMatch( + /private|prose|quotation|heading|\.invalid|post-id|test-content-api-key/i + ); + }); + + it('rejects an invalid baseline before transport without echoing its entries', async () => { + const transport = vi.fn(); + const summaries: string[] = []; + const unsafeBaseline = {'private-editorial-value': 1} as Readonly< + Record<`sha256:${string}`, number> + >; + + const execution = runLiveGhostContentSmoke( + createOptions(transport, { + baseline: unsafeBaseline, + summarySink: summary => { + summaries.push(summary); + } + }) + ); + + await expect(execution).rejects.toMatchObject({ + category: 'schema-drift', + code: 'invalid-schema' + }); + expect(transport).not.toHaveBeenCalled(); + expect(summaries).toHaveLength(1); + expect(summaries[0]).not.toContain('private-editorial-value'); + }); + + it.each([ + [ + 'a clock that throws', + () => { + throw new Error('private clock detail'); + } + ], + ['a non-Date observation time', () => 'private time' as unknown as Date], + ['an invalid observation time', () => new Date(Number.NaN)] + ])('sanitizes %s before transport', async (_name, clock) => { + const transport = vi.fn(); + + await expect( + runLiveGhostContentSmoke(createOptions(transport, {clock})) + ).rejects.toMatchObject({ + category: 'operational-failure', + code: 'clock-failure', + message: 'Live Ghost content smoke failed: operational-failure' + }); + expect(transport).not.toHaveBeenCalled(); + }); + + it('rejects a baseline with an invalid aggregate count', async () => { + const transport = vi.fn(); + const identifier = `sha256:${'0'.repeat(64)}` as const; + + await expect( + runLiveGhostContentSmoke(createOptions(transport, {baseline: {[identifier]: 0}})) + ).rejects.toMatchObject({ + category: 'schema-drift', + code: 'invalid-schema' + }); + expect(transport).not.toHaveBeenCalled(); + }); + + it('rejects a non-object baseline', async () => { + const transport = vi.fn(); + const baseline = [] as unknown as Readonly>; + + await expect( + runLiveGhostContentSmoke(createOptions(transport, {baseline})) + ).rejects.toMatchObject({ + category: 'schema-drift', + code: 'invalid-schema' + }); + expect(transport).not.toHaveBeenCalled(); + }); + + it('keeps only the confirmed attribute presence and Ghost class allowlists', async () => { + const privateAttributes = [ + 'data-kg-toggle-state="private-toggle"', + 'data-kg-background-image="https://private.invalid/background.jpg"', + 'data-kg-thumbnail="https://private.invalid/thumbnail.jpg"', + 'data-kg-custom-thumbnail="https://private.invalid/custom.jpg"', + 'data-kg-transistor-embed="private-embed"' + ].join(' '); + const samePresenceWithDifferentValues = [ + 'data-kg-toggle-state="other-toggle"', + 'data-kg-background-image="https://other.invalid/background.jpg"', + 'data-kg-thumbnail="https://other.invalid/thumbnail.jpg"', + 'data-kg-custom-thumbnail="https://other.invalid/custom.jpg"', + 'data-kg-transistor-embed="other-embed"' + ].join(' '); + const summaries: string[] = []; + + const report = await runLiveGhostContentSmoke( + createOptions( + createSuccessfulTransport( + [ + `
`, + `
`, + '
' + ], + [] + ), + { + summarySink: summary => { + summaries.push(summary); + } + } + ) + ); + + expect(report.signatures.map(({count}) => count).sort()).toEqual([1, 2]); + expect(summaries[0]).not.toMatch(/private|other|\.invalid|toggle|thumbnail|embed/i); + }); + + it('emits the canonical preorder structure from a worked structural example', async () => { + const renderedHtml = [ + '

Heading

', + '
Figure
', + '
Table
HeaderCell
', + '
Quote
', + '
  • Item

    Paragraph

' + ].join(''); + const canonicalStructure = { + version: 1, + nodes: [ + {tag: 'html', parent: null, kgClasses: [], attributes: []}, + {tag: 'head', parent: 0, kgClasses: [], attributes: []}, + {tag: 'body', parent: 0, kgClasses: [], attributes: []}, + {tag: 'h2', parent: 2, kgClasses: [], attributes: ['id']}, + {tag: 'a', parent: 3, kgClasses: [], attributes: ['name']}, + { + tag: 'figure', + parent: 2, + kgClasses: ['kg-card', 'kg-image-card'], + attributes: [] + }, + {tag: 'figcaption', parent: 5, kgClasses: [], attributes: []}, + {tag: 'table', parent: 2, kgClasses: [], attributes: []}, + {tag: 'caption', parent: 7, kgClasses: [], attributes: []}, + {tag: 'tbody', parent: 7, kgClasses: [], attributes: []}, + {tag: 'tr', parent: 9, kgClasses: [], attributes: []}, + {tag: 'th', parent: 10, kgClasses: [], attributes: []}, + {tag: 'td', parent: 10, kgClasses: [], attributes: []}, + {tag: 'blockquote', parent: 2, kgClasses: [], attributes: []}, + {tag: 'pre', parent: 13, kgClasses: [], attributes: []}, + {tag: 'ul', parent: 2, kgClasses: [], attributes: []}, + {tag: 'li', parent: 15, kgClasses: [], attributes: []}, + {tag: 'p', parent: 16, kgClasses: [], attributes: []} + ], + headings: [{level: 'h2', anchor: 'descendant'}], + selectedCounts: {p: 1, pre: 1, td: 1, li: 1}, + semanticGaps: { + caption: true, + tableHeader: true, + blockquote: true, + figure: true, + cardWrapper: true + } + }; + + const report = await runLiveGhostContentSmoke( + createOptions(createSuccessfulTransport([renderedHtml], [])) + ); + + expect(report.signatures).toEqual([{id: signatureFor(canonicalStructure), count: 1}]); + }); + + it('records empty heading anchor attributes as present without treating them as anchors', async () => { + const renderedHtml = '

Heading

Text

'; + const canonicalStructure = { + version: 1, + nodes: [ + {tag: 'html', parent: null, kgClasses: [], attributes: []}, + {tag: 'head', parent: 0, kgClasses: [], attributes: []}, + {tag: 'body', parent: 0, kgClasses: [], attributes: []}, + {tag: 'h2', parent: 2, kgClasses: [], attributes: ['id']}, + {tag: 'span', parent: 3, kgClasses: [], attributes: ['name']}, + {tag: 'p', parent: 2, kgClasses: [], attributes: []} + ], + headings: [{level: 'h2', anchor: 'none'}], + selectedCounts: {p: 1, pre: 0, td: 0, li: 0}, + semanticGaps: { + caption: false, + tableHeader: false, + blockquote: false, + figure: false, + cardWrapper: false + } + }; + + const report = await runLiveGhostContentSmoke( + createOptions(createSuccessfulTransport([renderedHtml], [])) + ); + + expect(report.signatures).toEqual([{id: signatureFor(canonicalStructure), count: 1}]); + }); + + it('classifies added signatures as structural drift while missing and count changes stay non-failing', async () => { + const postHtml = ['

First private value

', '

Second private value

']; + const pageHtml = ['
Third private value
']; + const bootstrap = await runLiveGhostContentSmoke( + createOptions(createSuccessfulTransport(postHtml, pageHtml)) + ); + const reviewedBaseline = Object.fromEntries( + bootstrap.signatures.map(({id, count}) => [id, count]) + ) as Record<`sha256:${string}`, number>; + + const known = await runLiveGhostContentSmoke( + createOptions(createSuccessfulTransport(postHtml, pageHtml), { + baseline: reviewedBaseline + }) + ); + expect(known.category).toBe('ok'); + expect(known.drift).toEqual({added: [], missing: [], countChanged: []}); + + const missingIdentifier = `sha256:${'0'.repeat(64)}` as const; + const firstObserved = bootstrap.signatures[0]; + expect(firstObserved).toBeDefined(); + const changedBaseline = { + ...reviewedBaseline, + [firstObserved!.id]: firstObserved!.count + 1, + [missingIdentifier]: 1 + }; + const changed = await runLiveGhostContentSmoke( + createOptions(createSuccessfulTransport(postHtml, pageHtml), { + baseline: changedBaseline + }) + ); + expect(changed.category).toBe('ok'); + expect(changed.drift).toEqual({ + added: [], + missing: [missingIdentifier], + countChanged: [firstObserved!.id] + }); + + const unseenBaseline = {...reviewedBaseline}; + delete unseenBaseline[firstObserved!.id]; + const unseen = await runLiveGhostContentSmoke( + createOptions(createSuccessfulTransport(postHtml, pageHtml), { + baseline: unseenBaseline + }) + ); + expect(unseen.category).toBe('structural-drift'); + expect(unseen.drift.added).toEqual([firstObserved!.id]); + }); + + it.each([ + { + name: 'an invalid transport response', + category: 'operational-failure', + code: 'transport-failure', + transport: vi.fn(async () => null as unknown as SmokeTransportResponse) + }, + { + name: 'a thrown transport error', + category: 'operational-failure', + code: 'transport-failure', + transport: vi.fn(async () => { + throw new Error('private transport detail and test-content-api-key'); + }) + }, + { + name: 'a redirected response', + category: 'operational-failure', + code: 'redirect-rejected', + transport: vi.fn(async () => ({ + status: 200, + redirected: true, + body: {private: 'private redirected response'} + })) + }, + { + name: 'a non-success status', + category: 'operational-failure', + code: 'http-failure', + transport: vi.fn(async () => ({ + status: 401, + redirected: false, + body: {private: 'private error response'} + })) + }, + { + name: 'a malformed parsed body', + category: 'schema-drift', + code: 'invalid-schema', + transport: vi.fn(async () => ({ + status: 200, + redirected: false, + body: 'private malformed JSON input' + })) + }, + { + name: 'a missing resource array', + category: 'schema-drift', + code: 'invalid-schema', + transport: vi.fn(async () => ({ + status: 200, + redirected: false, + body: { + private: 'private schema value', + meta: { + pagination: { + page: 1, + limit: 100, + pages: 1, + total: 1, + next: null, + prev: null + } + } + } + })) + }, + { + name: 'missing pagination metadata', + category: 'schema-drift', + code: 'invalid-schema', + transport: vi.fn(async request => ({ + status: 200, + redirected: false, + body: {[request.contentType]: [], meta: {}} + })) + }, + { + name: 'an item without HTML', + category: 'schema-drift', + code: 'invalid-schema', + transport: vi.fn(async request => ({ + status: 200, + redirected: false, + body: { + [request.contentType]: [{html: {private: 'private item value'}}], + meta: { + pagination: { + page: 1, + limit: 100, + pages: 1, + total: 1, + next: null, + prev: null + } + } + } + })) + } + ])('sanitizes $name', async ({category, code, transport}) => { + const summaries: string[] = []; + + const execution = runLiveGhostContentSmoke( + createOptions(transport, { + summarySink: summary => { + summaries.push(summary); + } + }) + ); + + await expect(execution).rejects.toMatchObject({category, code}); + await expect(execution).rejects.not.toThrow(/private|test-content-api-key/i); + expect(summaries).toHaveLength(1); + expect(summaries[0]).toContain(`Result: ${category}`); + expect(summaries[0]).not.toMatch(/private|test-content-api-key/i); + }); + + it.each([ + { + name: 'repeated pagination', + secondPagination: {page: 2, limit: 100, pages: 3, total: 0, next: 2, prev: 1} + }, + { + name: 'a changing pagination total', + secondPagination: {page: 2, limit: 100, pages: 2, total: 1, next: null, prev: 1} + }, + { + name: 'an invalid current page', + secondPagination: {page: 1, limit: 100, pages: 2, total: 0, next: 2, prev: null} + } + ])('rejects $name', async ({secondPagination}) => { + let requestCount = 0; + const transport = vi.fn(async request => { + requestCount += 1; + const pagination = + requestCount === 1 + ? {page: 1, limit: 100, pages: 2, total: 0, next: 2, prev: null} + : secondPagination; + return { + status: 200, + redirected: false, + body: {[request.contentType]: [], meta: {pagination}} + }; + }); + + await expect(runLiveGhostContentSmoke(createOptions(transport))).rejects.toMatchObject({ + category: 'schema-drift', + code: 'invalid-pagination' + }); + }); + + it('rejects a zero-item combined census', async () => { + await expect( + runLiveGhostContentSmoke(createOptions(createSuccessfulTransport([], []))) + ).rejects.toMatchObject({ + category: 'schema-drift', + code: 'empty-census' + }); + }); + + it('accepts the real extractor interface invariants across every compatibility source tag', async () => { + const renderedHtml = [ + '

Private heading

', + '

Private paragraph

', + '
private code
', + '
Private cell
', + '
  • Private item
    • Private nested item
' + ].join(''); + + const report = await runLiveGhostContentSmoke( + createOptions(createSuccessfulTransport([renderedHtml], [])) + ); + + expect(report.category).toBe('ok'); + expect(report.totals).toEqual({ + posts: {pages: 1, items: 1}, + pages: {pages: 1, items: 0} + }); + expect(report.signatures).toHaveLength(1); + }); + + it('sanitizes summary sink failures', async () => { + const execution = runLiveGhostContentSmoke( + createOptions(createSuccessfulTransport(['

Private source

'], []), { + summarySink: () => { + throw new Error('private sink failure and test-content-api-key'); + } + }) + ); + + await expect(execution).rejects.toBeInstanceOf(SmokeError); + await expect(execution).rejects.toMatchObject({ + category: 'operational-failure', + code: 'summary-failure', + message: 'Live Ghost content smoke failed: operational-failure' + }); + await expect(execution).rejects.not.toThrow(/private|test-content-api-key/i); + }); +}); diff --git a/packages/algolia-html-extractor/test/live-ghost-content-smoke.workflow.test.ts b/packages/algolia-html-extractor/test/live-ghost-content-smoke.workflow.test.ts new file mode 100644 index 00000000..409da2f7 --- /dev/null +++ b/packages/algolia-html-extractor/test/live-ghost-content-smoke.workflow.test.ts @@ -0,0 +1,84 @@ +import {readFile} from 'node:fs/promises'; + +import {describe, expect, test} from 'vitest'; + +const workflowUrl = new URL( + '../../../.github/workflows/live-ghost-content-smoke.yml', + import.meta.url +); + +function topLevelBlock(workflow: string, key: string): string { + const match = workflow.match(new RegExp(`^${key}:\\n(?:^[ \\t].*\\n?)*`, 'm')); + + expect(match, `${key} block`).not.toBeNull(); + return match?.[0].trimEnd() ?? ''; +} + +function stepContaining(workflow: string, value: string): string { + const steps = workflow.split(/(?=^ {6}- )/m); + const step = steps.find(candidate => candidate.includes(value)); + + expect(step, `step containing ${value}`).toBeDefined(); + return step ?? ''; +} + +describe('live Ghost content smoke workflow', () => { + test('keeps authenticated live observation manual, upstream-only, and read-only', async () => { + const workflow = await readFile(workflowUrl, 'utf8'); + + expect(topLevelBlock(workflow, 'on')).toBe('on:\n workflow_dispatch:'); + expect(topLevelBlock(workflow, 'permissions')).toBe('permissions:\n contents: read'); + expect(workflow).toMatch( + /^ {4}if: github\.repository == 'TryGhost\/algolia' && github\.ref == 'refs\/heads\/main'$/m + ); + + const actionReferences = [...workflow.matchAll(/^\s*- uses: ([^@\s]+)@([^\s#]+)/gm)].map( + ([, action, reference]) => ({action, reference}) + ); + + expect(actionReferences).toEqual([ + { + action: 'actions/checkout', + reference: expect.stringMatching(/^[0-9a-f]{40}$/) + }, + { + action: 'pnpm/action-setup', + reference: expect.stringMatching(/^[0-9a-f]{40}$/) + }, + { + action: 'actions/setup-node', + reference: expect.stringMatching(/^[0-9a-f]{40}$/) + } + ]); + + expect(stepContaining(workflow, 'actions/checkout@')).toMatch( + /^ {10}persist-credentials: false$/m + ); + + const executionStep = stepContaining(workflow, 'smoke:live'); + const secretReferences = workflow.match(/\$\{\{\s*secrets\.[^}]+\}\}/g) ?? []; + + expect(secretReferences).toEqual(['${{ secrets.MAIN_GHOST_CONTENT_API_KEY }}']); + expect(executionStep).toContain( + 'MAIN_GHOST_CONTENT_API_KEY: ${{ secrets.MAIN_GHOST_CONTENT_API_KEY }}' + ); + expect(executionStep).toMatch(/^ {10}GHOST_URL: https:\/\/main\.ghost\.is$/m); + expect(executionStep).toMatch(/^ {10}GHOST_API_VERSION: v6\.0$/m); + expect(executionStep).toMatch( + /^ {8}run: pnpm --filter @tryghost\/algolia-html-extractor smoke:live$/m + ); + + const prohibitedCapabilities = [ + /^\s*(?:schedule|pull_request|pull_request_target|push|workflow_run):/m, + /^\s*issues:\s*write\s*$/m, + /actions\/(?:cache|upload-artifact|download-artifact)@/, + /^\s*cache:/m, + /\b(?:git (?:add|commit|push)|gh issue|npm publish|pnpm publish|nx release|pnpm ship)\b/, + /(?:baseline|fixture).*(?:update|write)|(?:update|write).*(?:baseline|fixture)/i + ]; + + for (const prohibitedCapability of prohibitedCapabilities) { + expect(workflow).not.toMatch(prohibitedCapability); + } + }); +}); diff --git a/packages/algolia-html-extractor/tsconfig.json b/packages/algolia-html-extractor/tsconfig.json index ce2f8f9d..5ae6b0a1 100644 --- a/packages/algolia-html-extractor/tsconfig.json +++ b/packages/algolia-html-extractor/tsconfig.json @@ -15,5 +15,5 @@ "types": ["node", "vitest/globals"], "verbatimModuleSyntax": true }, - "include": ["index.mts", "test/**/*.ts", "test/**/*.mts"] + "include": ["index.mts", "smoke/**/*.mts", "test/**/*.ts", "test/**/*.mts"] } diff --git a/vitest.config.mjs b/vitest.config.mjs index ef9556c8..7a516444 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -18,6 +18,7 @@ export default defineConfig({ 'packages/algolia/bin/**/*.js', 'packages/algolia-fragmenter/src/index.mts', 'packages/algolia-html-extractor/index.mts', + 'packages/algolia-html-extractor/smoke/live-ghost-content-smoke.mts', 'packages/algolia-netlify/functions/**/*.{ts,mts}' ], exclude: [ From be8e54eb003fcbd836d446d9ef0720a058c451b9 Mon Sep 17 00:00:00 2001 From: Aileen Booker Date: Wed, 19 Aug 2026 10:37:53 +0400 Subject: [PATCH 2/3] Fixed live smoke review findings Bound live requests and the manual workflow so an upstream stall cannot occupy a runner indefinitely. Preserve the original smoke classification when job-summary reporting also fails, and tighten the workflow action allowlist. --- .../workflows/live-ghost-content-smoke.yml | 1 + packages/algolia-html-extractor/smoke/cli.mts | 5 ++- .../smoke/live-ghost-content-smoke.mts | 17 ++++++++-- .../test/live-ghost-content-smoke.test.ts | 32 +++++++++++++++++-- .../live-ghost-content-smoke.workflow.test.ts | 23 +++++-------- 5 files changed, 57 insertions(+), 21 deletions(-) diff --git a/.github/workflows/live-ghost-content-smoke.yml b/.github/workflows/live-ghost-content-smoke.yml index b96aad87..dc8300d9 100644 --- a/.github/workflows/live-ghost-content-smoke.yml +++ b/.github/workflows/live-ghost-content-smoke.yml @@ -11,6 +11,7 @@ jobs: name: Observe live Ghost content if: github.repository == 'TryGhost/algolia' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/packages/algolia-html-extractor/smoke/cli.mts b/packages/algolia-html-extractor/smoke/cli.mts index 70847238..0af48f3c 100644 --- a/packages/algolia-html-extractor/smoke/cli.mts +++ b/packages/algolia-html-extractor/smoke/cli.mts @@ -7,6 +7,8 @@ import { type SmokeTransportRequest } from './live-ghost-content-smoke.mts'; +const REQUEST_TIMEOUT_MS = 30_000; + const createRequestUrl = (request: SmokeTransportRequest): URL => { const requestUrl = new URL(`/ghost/api/content/${request.contentType}/`, request.target); requestUrl.searchParams.set('key', request.contentApiKey); @@ -21,7 +23,8 @@ const transport: SmokeTransport = async request => { const response = await fetch(createRequestUrl(request), { method: 'GET', headers: {'Accept-Version': request.apiVersion}, - redirect: request.redirect + redirect: request.redirect, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); let body: unknown = null; diff --git a/packages/algolia-html-extractor/smoke/live-ghost-content-smoke.mts b/packages/algolia-html-extractor/smoke/live-ghost-content-smoke.mts index 960cc1f0..4afb2be0 100644 --- a/packages/algolia-html-extractor/smoke/live-ghost-content-smoke.mts +++ b/packages/algolia-html-extractor/smoke/live-ghost-content-smoke.mts @@ -116,13 +116,20 @@ export class SmokeError extends Error { readonly category: FailureCategory; readonly code: SmokeErrorCode; readonly report: SmokeReport; + readonly reportingCode: 'summary-failure' | undefined; - constructor(category: FailureCategory, code: SmokeErrorCode, report: SmokeReport) { + constructor( + category: FailureCategory, + code: SmokeErrorCode, + report: SmokeReport, + reportingCode?: 'summary-failure' + ) { super(`Live Ghost content smoke failed: ${category}`); this.name = 'SmokeError'; this.category = category; this.code = code; this.report = report; + this.reportingCode = reportingCode; } } @@ -588,11 +595,15 @@ const writeSummary = async ( report: SmokeReport, observedAt: string, state: SmokeState, - baseline: Readonly> | undefined + baseline: Readonly> | undefined, + failure?: SmokeAbort ): Promise => { try { await options.summarySink(formatSmokeSummary(report)); } catch { + if (failure !== undefined) { + throw new SmokeError(failure.category, failure.code, report, 'summary-failure'); + } const failureReport = createReport('operational-failure', observedAt, state, baseline); throw new SmokeError('operational-failure', 'summary-failure', failureReport); } @@ -620,7 +631,7 @@ export async function runLiveGhostContentSmoke( ? error : new SmokeAbort('operational-failure', 'transport-failure'); const report = createReport(failure.category, observedAt, state, baseline); - await writeSummary(options, report, observedAt, state, baseline); + await writeSummary(options, report, observedAt, state, baseline, failure); throw new SmokeError(failure.category, failure.code, report); } diff --git a/packages/algolia-html-extractor/test/live-ghost-content-smoke.test.ts b/packages/algolia-html-extractor/test/live-ghost-content-smoke.test.ts index 56a4006d..c73593cd 100644 --- a/packages/algolia-html-extractor/test/live-ghost-content-smoke.test.ts +++ b/packages/algolia-html-extractor/test/live-ghost-content-smoke.test.ts @@ -168,7 +168,9 @@ describe('runLiveGhostContentSmoke', () => { }, drift: {added: [], missing: [], countChanged: []} }); - expect(report.signatures.map(signature => signature.count).sort()).toEqual([1, 2]); + expect( + report.signatures.map(signature => signature.count).toSorted((a, b) => a - b) + ).toEqual([1, 2]); expect(report.signatures.every(({id}) => /^sha256:[0-9a-f]{64}$/.test(id))).toBe(true); expect( transport.mock.calls.map(([request]) => [request.contentType, request.page]) @@ -303,7 +305,7 @@ describe('runLiveGhostContentSmoke', () => { ) ); - expect(report.signatures.map(({count}) => count).sort()).toEqual([1, 2]); + expect(report.signatures.map(({count}) => count).toSorted((a, b) => a - b)).toEqual([1, 2]); expect(summaries[0]).not.toMatch(/private|other|\.invalid|toggle|thumbnail|embed/i); }); @@ -637,4 +639,30 @@ describe('runLiveGhostContentSmoke', () => { }); await expect(execution).rejects.not.toThrow(/private|test-content-api-key/i); }); + + it('preserves the smoke failure when writing its summary also fails', async () => { + const transport = vi.fn(async request => ({ + status: 200, + redirected: false, + body: { + [request.contentType]: 'private invalid collection', + meta: {pagination: 'private invalid pagination'} + } + })); + const execution = runLiveGhostContentSmoke( + createOptions(transport, { + summarySink: () => { + throw new Error('private sink failure and test-content-api-key'); + } + }) + ); + + await expect(execution).rejects.toMatchObject({ + category: 'schema-drift', + code: 'invalid-schema', + reportingCode: 'summary-failure', + report: {category: 'schema-drift'} + }); + await expect(execution).rejects.not.toThrow(/private|test-content-api-key/i); + }); }); diff --git a/packages/algolia-html-extractor/test/live-ghost-content-smoke.workflow.test.ts b/packages/algolia-html-extractor/test/live-ghost-content-smoke.workflow.test.ts index 409da2f7..d3bafe1d 100644 --- a/packages/algolia-html-extractor/test/live-ghost-content-smoke.workflow.test.ts +++ b/packages/algolia-html-extractor/test/live-ghost-content-smoke.workflow.test.ts @@ -32,25 +32,18 @@ describe('live Ghost content smoke workflow', () => { /^ {4}if: github\.repository == 'TryGhost\/algolia' && github\.ref == 'refs\/heads\/main'$/m ); - const actionReferences = [...workflow.matchAll(/^\s*- uses: ([^@\s]+)@([^\s#]+)/gm)].map( - ([, action, reference]) => ({action, reference}) - ); + const actionReferences = [ + ...workflow.matchAll(/^[ \t]+(?:-[ \t]+)?uses:[ \t]+(.+)$/gm) + ].map(([, value]) => value?.replace(/[ \t]+#.*$/, '')); expect(actionReferences).toEqual([ - { - action: 'actions/checkout', - reference: expect.stringMatching(/^[0-9a-f]{40}$/) - }, - { - action: 'pnpm/action-setup', - reference: expect.stringMatching(/^[0-9a-f]{40}$/) - }, - { - action: 'actions/setup-node', - reference: expect.stringMatching(/^[0-9a-f]{40}$/) - } + expect.stringMatching(/^actions\/checkout@[0-9a-f]{40}$/), + expect.stringMatching(/^pnpm\/action-setup@[0-9a-f]{40}$/), + expect.stringMatching(/^actions\/setup-node@[0-9a-f]{40}$/) ]); + expect(workflow).toMatch(/^ {4}timeout-minutes: 15$/m); + expect(stepContaining(workflow, 'actions/checkout@')).toMatch( /^ {10}persist-credentials: false$/m ); From ececf5ff340b27621081b5fa079ac6b9e4c09b7e Mon Sep 17 00:00:00 2001 From: Aileen Booker Date: Wed, 19 Aug 2026 10:58:28 +0400 Subject: [PATCH 3/3] Hardened live smoke privacy boundaries Restricted structural signatures to reviewed Ghost class tokens so authored kg-prefixed values cannot affect the census. Added deterministic subprocess coverage for the real CLI adapter and documented the maintainer-only workflow command. --- packages/algolia-html-extractor/README.md | 16 ++ .../smoke/live-ghost-content-smoke.mts | 47 ++++- .../live-ghost-content-smoke-preload.mts | 65 +++++++ .../test/live-ghost-content-smoke.cli.test.ts | 171 ++++++++++++++++++ .../test/live-ghost-content-smoke.test.ts | 95 +++++++++- 5 files changed, 389 insertions(+), 5 deletions(-) create mode 100644 packages/algolia-html-extractor/test/helpers/live-ghost-content-smoke-preload.mts create mode 100644 packages/algolia-html-extractor/test/live-ghost-content-smoke.cli.test.ts diff --git a/packages/algolia-html-extractor/README.md b/packages/algolia-html-extractor/README.md index 59eadcff..fa214861 100644 --- a/packages/algolia-html-extractor/README.md +++ b/packages/algolia-html-extractor/README.md @@ -24,3 +24,19 @@ position, its heading rank, and the source tag. The returned values are read-onl The package handles HTML parsing itself and has no configuration options. Turning fragments into Algolia records, merging `pre` content, and sending records to Algolia are separate jobs handled by the downstream packages. + +## Maintainer smoke check + +Maintainers can run the `Live Ghost content smoke` workflow by hand to check published posts and +pages from `main.ghost.is`. It runs only from this repository's `main` branch and writes a +privacy-safe structural summary to the job. It does not save live content, write a baseline, run on +pull requests, or publish the package. + +The workflow installs the workspace and then runs: + +```sh +pnpm --filter @tryghost/algolia-html-extractor smoke:live +``` + +This command expects the workflow's URL, API version, Content API key, and job-summary environment. +It is not a general-purpose extractor command. diff --git a/packages/algolia-html-extractor/smoke/live-ghost-content-smoke.mts b/packages/algolia-html-extractor/smoke/live-ghost-content-smoke.mts index 4afb2be0..a9af468e 100644 --- a/packages/algolia-html-extractor/smoke/live-ghost-content-smoke.mts +++ b/packages/algolia-html-extractor/smoke/live-ghost-content-smoke.mts @@ -369,8 +369,48 @@ const STRUCTURAL_ATTRIBUTE_NAMES = [ 'data-kg-custom-thumbnail', 'data-kg-transistor-embed' ] as const; +const GHOST_STRUCTURAL_CLASS_TOKENS = [ + 'kg-card', + 'kg-card-hascaption', + 'kg-content-wide', + 'kg-gallery-container', + 'kg-gallery-image', + 'kg-gallery-row', + 'kg-image', + 'kg-layout-split', + 'kg-width-full', + 'kg-width-regular', + 'kg-width-wide' +] as const; +const GHOST_CARD_FAMILY_CLASS_TOKENS = [ + 'kg-audio-card', + 'kg-blockquote-alt', + 'kg-bookmark-card', + 'kg-button-card', + 'kg-callout-card', + 'kg-code-card', + 'kg-cta-card', + 'kg-embed-card', + 'kg-file-card', + 'kg-gallery-card', + 'kg-header-card', + 'kg-image-card', + 'kg-nft-card', + 'kg-product-card', + 'kg-signup-card', + 'kg-toggle-card', + 'kg-transistor-card', + 'kg-video-card' +] as const; const SELECTED_TAGS = ['p', 'pre', 'td', 'li'] as const; const HEADING_TAGS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const; +const GHOST_CLASS_TOKEN_SET: ReadonlySet = new Set([ + ...GHOST_STRUCTURAL_CLASS_TOKENS, + ...GHOST_CARD_FAMILY_CLASS_TOKENS +]); +const GHOST_CARD_FAMILY_CLASS_TOKEN_SET: ReadonlySet = new Set( + GHOST_CARD_FAMILY_CLASS_TOKENS +); const SELECTED_TAG_SET: ReadonlySet = new Set(SELECTED_TAGS); const HEADING_TAG_SET: ReadonlySet = new Set(HEADING_TAGS); @@ -383,7 +423,9 @@ const getPresentAttributeNames = (element: Element): readonly string[] => { const getGhostClassTokens = (element: Element): readonly string[] => { const classValue = element.attrs.find(attribute => attribute.name === 'class')?.value ?? ''; - return [...new Set(classValue.split(/\s+/u).filter(token => token.startsWith('kg-')))].sort(); + return [ + ...new Set(classValue.split(/\s+/u).filter(token => GHOST_CLASS_TOKEN_SET.has(token))) + ].toSorted(); }; const hasNonEmptyAnchor = (element: Element): boolean => { @@ -462,7 +504,8 @@ const normalizeStructure = (renderedHtml: string): string => { semanticGaps.blockquote ||= child.tagName === 'blockquote'; semanticGaps.figure ||= child.tagName === 'figure'; semanticGaps.cardWrapper ||= kgClasses.some( - className => className === 'kg-card' || className.endsWith('-card') + className => + className === 'kg-card' || GHOST_CARD_FAMILY_CLASS_TOKEN_SET.has(className) ); visit(child, nodeIndex); diff --git a/packages/algolia-html-extractor/test/helpers/live-ghost-content-smoke-preload.mts b/packages/algolia-html-extractor/test/helpers/live-ghost-content-smoke-preload.mts new file mode 100644 index 00000000..79c1797e --- /dev/null +++ b/packages/algolia-html-extractor/test/helpers/live-ghost-content-smoke-preload.mts @@ -0,0 +1,65 @@ +import {appendFileSync} from 'node:fs'; + +const requestLogPath = process.env.LIVE_GHOST_SMOKE_REQUEST_LOG; +if (requestLogPath === undefined || requestLogPath === '') { + throw new Error('The live Ghost smoke preload requires a request log path.'); +} + +const timeoutBySignal = new WeakMap(); + +AbortSignal.timeout = (milliseconds: number): AbortSignal => { + const signal = new AbortController().signal; + timeoutBySignal.set(signal, milliseconds); + return signal; +}; + +globalThis.fetch = async (input: string | URL | Request, init?: RequestInit): Promise => { + const requestUrl = + input instanceof Request + ? new URL(input.url) + : input instanceof URL + ? input + : new URL(input); + const contentTypeMatch = requestUrl.pathname.match(/^\/ghost\/api\/content\/(posts|pages)\/$/u); + if (requestUrl.origin !== 'https://main.ghost.is' || contentTypeMatch === null) { + throw new Error(`Denied unexpected live smoke request to ${requestUrl.origin}.`); + } + + const contentType = contentTypeMatch[1]; + if (contentType !== 'posts' && contentType !== 'pages') { + throw new Error('The live smoke request used an unexpected content type.'); + } + + const signal = init?.signal; + appendFileSync( + requestLogPath, + `${JSON.stringify({ + url: requestUrl.href, + method: init?.method, + acceptVersion: new Headers(init?.headers).get('Accept-Version'), + redirect: init?.redirect, + signalIsAbortSignal: signal instanceof AbortSignal, + timeoutMilliseconds: + signal instanceof AbortSignal ? (timeoutBySignal.get(signal) ?? null) : null + })}\n`, + 'utf8' + ); + + const items = contentType === 'posts' ? [{html: '

Private fixture prose

'}] : []; + return new Response( + JSON.stringify({ + [contentType]: items, + meta: { + pagination: { + page: 1, + limit: 100, + pages: 1, + total: items.length, + next: null, + prev: null + } + } + }), + {status: 200, headers: {'Content-Type': 'application/json'}} + ); +}; diff --git a/packages/algolia-html-extractor/test/live-ghost-content-smoke.cli.test.ts b/packages/algolia-html-extractor/test/live-ghost-content-smoke.cli.test.ts new file mode 100644 index 00000000..751b80dd --- /dev/null +++ b/packages/algolia-html-extractor/test/live-ghost-content-smoke.cli.test.ts @@ -0,0 +1,171 @@ +import {spawnSync, type SpawnSyncReturns} from 'node:child_process'; +import {mkdtemp, readFile, rm, writeFile} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import {fileURLToPath, pathToFileURL} from 'node:url'; + +import {afterEach, describe, expect, it} from 'vitest'; + +const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const cliPath = path.join(packageDirectory, 'smoke', 'cli.mts'); +const preloadPath = path.join( + packageDirectory, + 'test', + 'helpers', + 'live-ghost-content-smoke-preload.mts' +); +const fakeContentApiKey = 'test-content-api-key'; +const temporaryDirectories: string[] = []; + +type LoggedRequest = Readonly<{ + url: string; + method: string; + acceptVersion: string; + redirect: string; + signalIsAbortSignal: boolean; + timeoutMilliseconds: number; +}>; + +type SmokeCliRun = Readonly<{ + result: SpawnSyncReturns; + requestLogPath: string; + summaryPath: string; +}>; + +const createTemporaryDirectory = async (): Promise => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'algolia-live-smoke-')); + temporaryDirectories.push(directory); + return directory; +}; + +const createChildEnvironment = ( + requestLogPath: string, + summaryPath: string | undefined +): NodeJS.ProcessEnv => { + const environment: NodeJS.ProcessEnv = { + PATH: process.env.PATH, + GHOST_URL: 'https://main.ghost.is', + GHOST_API_VERSION: 'v6.0', + MAIN_GHOST_CONTENT_API_KEY: fakeContentApiKey, + LIVE_GHOST_SMOKE_REQUEST_LOG: requestLogPath, + // Vitest's V8 converter cannot parse raw Node coverage for .mts subprocesses. + NODE_V8_COVERAGE: '', + VITEST_SUBPROCESS_COVERAGE_DIR: '' + }; + if (summaryPath !== undefined) { + environment.GITHUB_STEP_SUMMARY = summaryPath; + } + return environment; +}; + +const runSmokeCli = async ( + summaryPathOption: 'file' | 'missing' | 'unwritable' +): Promise => { + const temporaryDirectory = await createTemporaryDirectory(); + const requestLogPath = path.join(temporaryDirectory, 'requests.jsonl'); + const summaryPath = + summaryPathOption === 'unwritable' + ? path.join(temporaryDirectory, 'missing', 'summary.md') + : path.join(temporaryDirectory, 'summary.md'); + await writeFile(requestLogPath, '', {mode: 0o600}); + if (summaryPathOption !== 'unwritable') { + await writeFile(summaryPath, '', {mode: 0o600}); + } + + const result = spawnSync( + process.execPath, + ['--import', pathToFileURL(preloadPath).href, cliPath], + { + encoding: 'utf8', + env: createChildEnvironment( + requestLogPath, + summaryPathOption === 'missing' ? undefined : summaryPath + ), + timeout: 5000, + maxBuffer: 1024 * 1024 + } + ); + + return {result, requestLogPath, summaryPath}; +}; + +const readLoggedRequests = async (requestLogPath: string): Promise => { + const contents = await readFile(requestLogPath, 'utf8'); + if (contents === '') { + return []; + } + return contents + .trim() + .split('\n') + .map(line => JSON.parse(line) as LoggedRequest); +}; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map(directory => rm(directory, {recursive: true, force: true})) + ); +}); + +describe('live Ghost content smoke CLI', () => { + it('maps the workflow environment to bounded offline requests and a safe summary', async () => { + const {result, requestLogPath, summaryPath} = await runSmokeCli('file'); + + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.status).toBe(0); + expect(result.stdout).toBe('Live Ghost content smoke: ok\n'); + expect(result.stderr).toBe(''); + expect(await readLoggedRequests(requestLogPath)).toEqual([ + { + url: `https://main.ghost.is/ghost/api/content/posts/?key=${fakeContentApiKey}&fields=html&formats=html&limit=100&page=1`, + method: 'GET', + acceptVersion: 'v6.0', + redirect: 'error', + signalIsAbortSignal: true, + timeoutMilliseconds: 30_000 + }, + { + url: `https://main.ghost.is/ghost/api/content/pages/?key=${fakeContentApiKey}&fields=html&formats=html&limit=100&page=1`, + method: 'GET', + acceptVersion: 'v6.0', + redirect: 'error', + signalIsAbortSignal: true, + timeoutMilliseconds: 30_000 + } + ]); + + const summary = await readFile(summaryPath, 'utf8'); + expect(summary).toContain('Result: ok'); + expect(summary).toContain('| posts | 1 | 1 |'); + expect(summary).toContain('| pages | 1 | 0 |'); + expect(summary).not.toMatch(/Private fixture prose|test-content-api-key/i); + }); + + it('fails before requesting content when the summary path is missing', async () => { + const {result, requestLogPath, summaryPath} = await runSmokeCli('missing'); + + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.status).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe('Live Ghost content smoke failed: operational-failure\n'); + expect(await readLoggedRequests(requestLogPath)).toEqual([]); + expect(await readFile(summaryPath, 'utf8')).toBe(''); + }); + + it('reports a safe failure when the summary cannot be written', async () => { + const {result, requestLogPath} = await runSmokeCli('unwritable'); + + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.status).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe('Live Ghost content smoke failed: operational-failure\n'); + expect(await readLoggedRequests(requestLogPath)).toHaveLength(2); + expect(`${result.stdout}${result.stderr}`).not.toMatch( + /Private fixture prose|test-content-api-key/i + ); + }); +}); diff --git a/packages/algolia-html-extractor/test/live-ghost-content-smoke.test.ts b/packages/algolia-html-extractor/test/live-ghost-content-smoke.test.ts index c73593cd..4c744007 100644 --- a/packages/algolia-html-extractor/test/live-ghost-content-smoke.test.ts +++ b/packages/algolia-html-extractor/test/live-ghost-content-smoke.test.ts @@ -291,9 +291,9 @@ describe('runLiveGhostContentSmoke', () => { createOptions( createSuccessfulTransport( [ - `
`, - `
`, - '
' + `
`, + `
`, + '
' ], [] ), @@ -309,6 +309,95 @@ describe('runLiveGhostContentSmoke', () => { expect(summaries[0]).not.toMatch(/private|other|\.invalid|toggle|thumbnail|embed/i); }); + it('drops unowned kg class values from signatures and card detection', async () => { + const report = await runLiveGhostContentSmoke( + createOptions( + createSuccessfulTransport( + [ + '
', + '
' + ], + [] + ) + ) + ); + const canonicalStructure = { + version: 1, + nodes: [ + {tag: 'html', parent: null, kgClasses: [], attributes: []}, + {tag: 'head', parent: 0, kgClasses: [], attributes: []}, + {tag: 'body', parent: 0, kgClasses: [], attributes: []}, + {tag: 'figure', parent: 2, kgClasses: [], attributes: []} + ], + headings: [], + selectedCounts: {p: 0, pre: 0, td: 0, li: 0}, + semanticGaps: { + caption: false, + tableHeader: false, + blockquote: false, + figure: true, + cardWrapper: false + } + }; + + expect(report.signatures).toEqual([{id: signatureFor(canonicalStructure), count: 2}]); + }); + + it('recognizes the reviewed Ghost card family roots as wrappers', async () => { + const familyClassTokens = [ + 'kg-audio-card', + 'kg-blockquote-alt', + 'kg-bookmark-card', + 'kg-button-card', + 'kg-callout-card', + 'kg-code-card', + 'kg-cta-card', + 'kg-embed-card', + 'kg-file-card', + 'kg-gallery-card', + 'kg-header-card', + 'kg-image-card', + 'kg-nft-card', + 'kg-product-card', + 'kg-signup-card', + 'kg-toggle-card', + 'kg-transistor-card', + 'kg-video-card' + ] as const; + const report = await runLiveGhostContentSmoke( + createOptions( + createSuccessfulTransport( + familyClassTokens.map(className => `
`), + [] + ) + ) + ); + const expectedIds = familyClassTokens + .map(className => + signatureFor({ + version: 1, + nodes: [ + {tag: 'html', parent: null, kgClasses: [], attributes: []}, + {tag: 'head', parent: 0, kgClasses: [], attributes: []}, + {tag: 'body', parent: 0, kgClasses: [], attributes: []}, + {tag: 'div', parent: 2, kgClasses: [className], attributes: []} + ], + headings: [], + selectedCounts: {p: 0, pre: 0, td: 0, li: 0}, + semanticGaps: { + caption: false, + tableHeader: false, + blockquote: false, + figure: false, + cardWrapper: true + } + }) + ) + .toSorted(); + + expect(report.signatures.map(({id}) => id)).toEqual(expectedIds); + }); + it('emits the canonical preorder structure from a worked structural example', async () => { const renderedHtml = [ '

Heading

',