diff --git a/.github/workflows/live-site-check.yml b/.github/workflows/live-site-check.yml index c9e18080..9503b35d 100644 --- a/.github/workflows/live-site-check.yml +++ b/.github/workflows/live-site-check.yml @@ -1,12 +1,13 @@ name: Live Site Check # Guards the deployed tinystudio.in against soft-404s, WCAG 2.2 tap-target -# drift (SC 2.5.8 24px minimum), and stale bundles without blocking pull -# requests. The deploy lane (deploy-public-site.yml) verifies the live site -# after every publish; this workflow is the nightly net that catches a stale -# or misconfigured deployment within a day even when nothing merges. It is -# deliberately NOT part of `npm run ci` - that blocking chain must stay green -# on repo state alone. +# drift (SC 2.5.8 24px minimum), regressed heading hierarchies, and stale +# bundles without blocking pull requests. The deploy lane +# (deploy-public-site.yml) verifies the live site after every publish; this +# workflow is the nightly net that catches a stale or misconfigured +# deployment within a day even when nothing merges. It is deliberately NOT +# part of `npm run ci` - that blocking chain must stay green on repo state +# alone. # # This job is expected to FAIL while the deployment is stale (currently the # 2026-06-20 bundle, which still serves pre-2026-08-07 public fixes); the red @@ -38,6 +39,7 @@ jobs: cache: npm - run: npm ci --ignore-scripts - run: node scripts/check-public-live-soft-404.mjs + - run: node scripts/test-public-live-heading-hierarchy.mjs live-contact-heading-hierarchy: runs-on: [self-hosted, linux, x64, vps-verify] steps: diff --git a/package.json b/package.json index eb5b647b..3394f923 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,8 @@ "test": "node scripts/test-service-engine.mjs && node scripts/test-sales-intake-contract.mjs && node scripts/test-active-offer-projection.mjs && node scripts/test-active-operator-surfaces.mjs && node scripts/test-direction-proof-gate.mjs && node scripts/test-client-readiness-contract.mjs && node scripts/test-validated-service-client.mjs && node scripts/test-client-acceptance-gates.mjs && node scripts/test-operator-check-strictness.mjs && node scripts/check-product-truth.mjs && node scripts/check-human-service-kit.mjs && node scripts/test-design-system-proving-lab.mjs && node scripts/check-retention-automation.mjs && node scripts/test-retention-automation.mjs && node scripts/check-agency-defaults.mjs && node scripts/check-outbound-claim-safety.mjs && node scripts/check-outbound-send-readiness.mjs && node scripts/test-outbound-send-readiness.mjs && node scripts/test-public-conversion-signal.mjs && node scripts/test-public-structured-data.mjs && node scripts/test-public-brand-disambiguation.mjs && node scripts/test-public-heading-hierarchy.mjs && node scripts/test-public-link-targets.mjs && node scripts/test-public-social-preview.mjs && node scripts/test-public-theme-color.mjs && node scripts/test-public-soft-404.mjs && node scripts/test-public-deploy-bundle.mjs && node scripts/test-deploy-public-site-workflow.mjs && node scripts/test-pages-release.mjs && node scripts/test-pr-duplicates.mjs && node scripts/test-public-support-contact-voice.mjs && node scripts/test-public-app-copy-voice.mjs && node scripts/test-public-meta-descriptions.mjs && node scripts/test-public-footer-copy.mjs && node scripts/test-public-promptly-support-heading-hierarchy.mjs && find scripts -name '*.mjs' -print0 | xargs -0 -n1 node --check", "site:publish": "node scripts/publish-public-site.mjs", "site:publish:check": "node scripts/publish-public-site.mjs --prepare-only", - "site:check-live-promptly-support-heading-hierarchy": "node scripts/test-public-live-promptly-support-heading-hierarchy.mjs" + "site:check-live-promptly-support-heading-hierarchy": "node scripts/test-public-live-promptly-support-heading-hierarchy.mjs", + "site:check-live-heading-hierarchy": "node scripts/test-public-live-heading-hierarchy.mjs" }, "engines": { "node": ">=20" diff --git a/scripts/check-public-live-deploy.mjs b/scripts/check-public-live-deploy.mjs index d53b26f6..767a3e3d 100644 --- a/scripts/check-public-live-deploy.mjs +++ b/scripts/check-public-live-deploy.mjs @@ -24,6 +24,9 @@ // 5. every public page carries exactly one application/ld+json block // (trust/support structured data, PR #26 - the 07acd07 bundle shipped // JSON-LD on only 4 of 12 pages). +// The fifth proof covers the 2026-08-08 dogfood finding pages: +// 5. /drishti/support/ and /privacy-choices/ render H2 after H1 +// (the skipped-heading-levels repair, PR #23). // Proof 2b covers the 2026-08-08 dogfood finding page: // 2b. /contact/ renders H2 after H1 (the heading-hierarchy repair, PR #18). // 5. the deployed stylesheet keeps the WCAG 2.2 24px footer tap-target @@ -98,6 +101,13 @@ try { ok(h2AfterFirstH1(body), "H2 follows the first H1 before any H3") } + console.log("A2. finding pages render H2 after H1 (skipped-heading-levels repair, PR #23)") + for (const path of ["/drishti/support/", "/privacy-choices/"]) { + const { status, body } = await get(path) + ok(status === 200, `${path} returns 200 (got ${status})`) + ok(h2AfterFirstH1(body), `${path} has H2 following the first H1 before any H3`) + } + console.log("B. /contact/ carries structured data (PR #19)") { const { status, body } = await get("/contact/") diff --git a/scripts/test-public-live-heading-hierarchy.mjs b/scripts/test-public-live-heading-hierarchy.mjs new file mode 100644 index 00000000..16ea319d --- /dev/null +++ b/scripts/test-public-live-heading-hierarchy.mjs @@ -0,0 +1,145 @@ +// Guard the LIVE public site against skipped heading levels on the pages +// that carry the repaired outline in source. +// +// The 2026-08-08 dogfood finding "Repair skipped heading levels on Drishti +// support and Privacy Choices pages" was repaired in source by PR #23 (card +// headings promoted from H3 to H2, outline H1 -> H2s -> H3s with no jumps), +// and scripts/test-public-heading-hierarchy.mjs guards the worktree HTML. +// This check re-asserts the repaired outline against the pages the live +// site actually serves, so a stale deployment (like the June-20 bundle +// still serving H3 cards) fails loudly instead of silently re-opening the +// finding. +// +// It runs as `npm run site:check-live-heading-hierarchy`, from the nightly +// live-site-check workflow (the loud staleness alarm while the site is +// stale) and on demand. It is deliberately NOT part of `npm run test` / +// `npm run ci`: those blocking chains must stay green on repo state alone, +// while the live site is deployed by an external mechanism (Cloudflare +// Pages). Blocking CI on the live site would keep every pull request red +// whenever the deployment is stale, and would deadlock the deploy lane's +// pre-deploy `npm run check` gate. +// +// Network-tolerant: a page or stylesheet that is unreachable is skipped +// (ok, no assertion); a page that is reachable but stale fails loudly. +// +// Escape hatch for machines without network access: +// SKIP_LIVE_CHECKS=1 npm run site:check-live-heading-hierarchy +// +// Only public tinystudio.in URLs and the local source files are referenced +// here; there is no per-environment configuration. +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { dirname, join } from "node:path" + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..") +const read = (p) => readFileSync(join(ROOT, p), "utf8") + +if (process.env.SKIP_LIVE_CHECKS === "1") { + console.log("test-public-live-heading-hierarchy: SKIP_LIVE_CHECKS=1, skipping live site checks") + process.exit(0) +} + +let failures = 0 +let checks = 0 +const ok = (cond, msg) => { + checks++ + if (cond) console.log(` ok ${msg}`) + else { + failures++ + console.error(` FAIL ${msg}`) + } +} + +// The deployed pages must keep the repaired heading outline (H1 -> H2 cards +// -> H2 footer -> H3 footer columns, no skipped levels). The local suite +// (test-public-heading-hierarchy.mjs) asserts the same outline against the +// worktree HTML; this guard re-asserts it against what the live site +// serves. +const LIVE_PAGES = [ + { + name: "Drishti support", + url: "https://tinystudio.in/drishti/support/", + source: "public/drishti/support/index.html" + }, + { + name: "Privacy Choices", + url: "https://tinystudio.in/privacy-choices/", + source: "public/privacy-choices/index.html" + } +] +const LIVE_CSS_URL = "https://tinystudio.in/styles.css" +const FETCH_TIMEOUT_MS = 10_000 + +// Heading levels in document order, e.g. [1, 2, 2, 2, 2, 3, 3, 3]. +const headingLevelsOf = (html) => + [...html.matchAll(/]*>/gi)].map((m) => Number(m[1])) + +// Count

used as .info-card card titles (inside
). +const infoCardTitleCount = (html) => + (html.match(/
]*>[\s\S]*? { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }) + if (!res.ok) { + console.log(` ok skipped: ${url} answered ${res.status}, deployment not reachable - no assertions run for it`) + return null + } + return await res.text() + } catch (err) { + console.log(` ok skipped: ${url} unreachable (${err?.cause?.code ?? err?.name ?? "network error"}) - no assertions run for it`) + return null + } +} + +const assertRepairedOutline = (name, source, html) => { + const levels = headingLevelsOf(html) + ok(levels.length > 0, `live ${name} page contains at least one heading`) + ok(levels.filter((l) => l === 1).length === 1, `live ${name} page has exactly one H1`) + ok(levels[0] === 1, `live ${name} page has the H1 as the first heading in the outline`) + ok(infoCardTitleCount(html) === 3, `live ${name} page has the three card headings as H2s inside .info-card articles`) + const cardH2s = levels.filter((l) => l === 2).length + ok(cardH2s >= 4, `live ${name} page keeps the flat H2 band (card H2s plus the footer H2) before the footer H3s`) + let jumps = 0 + for (let i = 1; i < levels.length; i++) { + if (levels[i] - levels[i - 1] > 1) { + jumps++ + console.error(` bad transition H${levels[i - 1]} -> H${levels[i]} on ${name}`) + } + } + ok(jumps === 0, `live ${name} page has no heading-level jump greater than one (no H1 -> H3 skip)`) + if (failures > 0) { + console.error(` the deployed ${source} is stale: it misses the heading-hierarchy repair that the worktree copy of ${source} already has. Refresh the live deployment from origin/main.`) + } +} + +console.log("test-public-live-heading-hierarchy: the deployed tinystudio.in pages keep the repaired heading outline (no skipped levels)") + +console.log("A. live pages carry the repaired heading hierarchy") +for (const page of LIVE_PAGES) { + const html = await fetchLive(page.url) + if (html !== null) assertRepairedOutline(page.name, page.source, html) +} + +console.log("B. live stylesheet keeps the shared card-heading rule at the former card scale") +const css = await fetchLive(LIVE_CSS_URL) +if (css !== null) { + const ruleStart = css.indexOf(".info-card :is(h2, h3) {") + ok(ruleStart !== -1, `live styles.css defines .info-card :is(h2, h3) {`) + const ruleEnd = ruleStart === -1 ? -1 : css.indexOf("}", ruleStart) + const ruleBody = ruleStart === -1 ? "" : css.slice(ruleStart, ruleEnd) + for (const decl of ["margin-top: 12px", "font-size: clamp(1.65rem, 2vw, 2.35rem)", "max-width: none"]) { + ok(ruleBody.includes(decl), `live card rule keeps ${decl}`) + } + ok( + !/\.info-card\s+h3\s*{/.test(css), + "live styles.css replaced the old .info-card h3-only rule with the shared :is(h2, h3) rule" + ) + if (failures > 0) { + console.error(" the deployed stylesheet is stale: it misses the card-heading pairing that public/styles.css already has. Refresh the live deployment from origin/main.") + } +} + +console.log("\nLive heading-hierarchy guard result: the finding stays open against tinystudio.in until a refresh of the live deployment lands on origin/main.") +console.log(`\n${checks} checks, ${failures} failures`) +process.exit(failures === 0 ? 0 : 1)