From 1630d08b4239162e8ea6606bb6d3ba209723cda9 Mon Sep 17 00:00:00 2001 From: Paul Keen <125715+pftg@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:43:06 +0200 Subject: [PATCH] Close three fault-injection gaps: 8 of 8, each gate broken before trusted The 2026-08-22 audit planted eight defects and the suite caught three. Three of the five misses get gates here. Every one was injected, watched fail, reverted and watched pass - green alone proves a test runs, not that it works. D - a frozen tenure number. The rendered assertion cannot tell `derived` from `frozen` while both read "18+" in 2026, and saying so in the test body is honest but guards nothing. The freeze is plainly visible one layer down, so NextRailTest now asserts both halves: the rendered value, and that every pilot stub carries `derived: tenure` with no stat frozen at today's derived string. Not a config test - the hardcoded literal IS the defect class, the way the 2026-08-14 canon audit found the founding year wrong in eight places because each kept its own copy. G - the PurgeCSS trap the audit called unguardable. It is unguardable by the VISUAL gate, which builds production and so purges exactly as production does, matching its own baseline. It is trivially visible to a set diff: styled in the register's source CSS, present on an element in the rendered HTML, absent from the bundle the page links. next_purge_guard_test.rb is keyed on that mechanism rather than on `th`, so the next element type Hugo stops recording is covered without an edit, and it reads the bundle from the page's own href - stale fingerprinted bundles sit in the same directory. B and the contrast hole - computed styles, not per-band screenshots. The audit's own recommendation buys four more baselines per pilot, each needing its noise floor measured on the platform that judges it, and still returns a pixel delta rather than a ratio - so an intentional recolour and an AA regression stay indistinguishable. next_pilot_contrast_test.rb asserts the comparison table's "theirs" column computes the register's own --*-muted token, then walks every visible text run for 4.5:1 (3:1 large, SC 1.4.3) and every control fill for 3:1 against its surround (SC 1.4.11). ~1.3s for three pages, no baselines, wired into test:critical. It closes the hole the audit flagged separately: nothing in this suite measured contrast as a number, and a 3.33:1 black-on-ruby button shipped this rail on human review alone. One mistake worth keeping. The muted-column assertion was first written as "differs from the sibling cell and from body colour" and PASSED the exact #564 injection - in that register the "ours" column IS body colour and the injected ink is a third value. Wrong for two runs, green both times; only the injection said so. Injections and failures, all reverted before the next: D `derived: tenure` -> `value: "18+"` the tenure stat must be `derived: tenure` so layouts/next/landing.html computes it from site.Params.foundingYear D a second stat frozen at "18+" alongside the derived one Expected ["Years of average developer experience"] to be empty G class="rr-th-theirs" on a + a matching rule Expected ["rr-th-theirs"] to be empty control: the SAME class moved onto the in that row goes green, so it flags the purge and not merely a new class B .rr-td-muted -> var(--ed-ink), below the fold computes rgb(27, 28, 28), not --ed-muted (rgb(107, 101, 96)). It resolves to --ed-ink A .rr-btn-primary label -> var(--ed-ink) on the accent fill 2.31:1 (needs 4.5:1) text a.rr-btn.rr-btn-primary A' .rr-td-muted -> #b5b0ab, below the fold - invisible to every screenshot 2.15:1 (needs 4.5:1) on five cells A" a near-paper button fill with a readable label 1.12:1 (needs 3.0:1) fill a.rr-btn.rr-btn-primary Each walk asserts it measured something (>50 text pairs, >0 fills, a non-empty styled-and-rendered set) - a walk that finds nothing is empty, and empty passes. Gates: test:unit 289 runs / 6187 assertions / 0 failures in 1.42s (287 / 6143 / 1.33s before - the purge guard reuses the build the suite already makes); test:critical 38 runs / 0 failures, 55 screenshots compared, no failures, no baseline rewritten; bin/hugo-build clean; standardrb clean on the new files. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011SP5gaqXEgUie8pdFrmbeJ --- .okf/build/hugo-stats-th-classes.md | 28 ++- .okf/build/index.md | 4 +- .okf/build/test-gates.md | 57 ++++- .okf/log.md | 47 ++++ Rakefile | 7 +- ...te-fault-injection-2026-08-22-reference.md | 24 ++ test/system/next_pilot_contrast_test.rb | 224 ++++++++++++++++++ test/unit/next_purge_guard_test.rb | 70 ++++++ test/unit/next_rail_test.rb | 39 +++ 9 files changed, 486 insertions(+), 14 deletions(-) create mode 100644 test/system/next_pilot_contrast_test.rb create mode 100644 test/unit/next_purge_guard_test.rb diff --git a/.okf/build/hugo-stats-th-classes.md b/.okf/build/hugo-stats-th-classes.md index b1595b986..28ae9cb12 100644 --- a/.okf/build/hugo-stats-th-classes.md +++ b/.okf/build/hugo-stats-th-classes.md @@ -33,13 +33,33 @@ Development builds hide it completely: `postcss.config.js` sets entirely, so `bin/hugo-build` renders the styling correctly while production ships it unstyled. -# Why no gate catches it +# Why the VISUAL gate cannot catch it, and what does The visual regression suite compares a baseline against a candidate that were **both** built the same way, so a rule that is purged in both is invisible to -it. `css_orphan_guard_test.rb` does not help either - it asks whether a FILE -is reachable from a template, never whether a SELECTOR survived the purge. -This class of defect reaches production green. +it - not at a lower tolerance, not with a bigger capture. It is unguardable +there in principle. `css_orphan_guard_test.rb` does not help either: it asks +whether a FILE is reachable from a template, never whether a SELECTOR survived +the purge. + +A unit test does catch it, because the defect is a set relation, not a picture. +`test/unit/next_purge_guard_test.rb` flags any class that is (a) styled in the +register's source CSS, (b) present on an element in the rendered production +HTML, and (c) missing from the fingerprinted bundle that page actually links. +All three at once is a silently-purged rule every time. It is keyed on the +MECHANISM, not on `th`, so the next element type Hugo stops recording is +covered without an edit. + +Two things make it trustworthy rather than decorative: + +- It reads the bundle from the page's own ``, never a glob of + `css/` - stale fingerprinted bundles from earlier builds sit in the same + directory, and reading one measures a build nobody ships. +- Broken before trusted (2026-08-22): injecting `class="rr-th-theirs"` on a + `` plus a matching rule failed it with `Expected ["rr-th-theirs"] to be + empty`; moving the SAME class onto the `` in the same row went green, + which is the control that proves it flags the purge and not merely a new + class. # What to do instead diff --git a/.okf/build/index.md b/.okf/build/index.md index 21fed65fa..f053b936e 100644 --- a/.okf/build/index.md +++ b/.okf/build/index.md @@ -1,7 +1,7 @@ # Build & Test * [Hugo build pipeline](hugo-build.md) - bin/hugo-build with the 8 course validators; also the PurgeCSS cold-start race and the minified-unquoted-attribute audit-tool trap -* [Test gates](test-gates.md) - the local suites, when each is a commit blocker, the 0.0001 default tolerance, why the SECTION_CONFIGS shield was deleted and what measuring it cost, why a green run never refreshes a baseline, and why below-fold content is invisible at any tolerance, bin/record-baselines for accepting only the baselines you meant to move, and why a deleted source file still serves from every local _dest/ tree, plus the NULL CHANGE - a diff that passes every gate and alters nothing - and what `okf_validate` actually guards (shape, not truth; error-only conformance) with the two-spec trap +* [Test gates](test-gates.md) - the local suites, when each is a commit blocker, the 0.0001 default tolerance, why the SECTION_CONFIGS shield was deleted and what measuring it cost, why a green run never refreshes a baseline, and why below-fold content is invisible at any tolerance, bin/record-baselines for accepting only the baselines you meant to move, and why a deleted source file still serves from every local _dest/ tree, plus the NULL CHANGE - a diff that passes every gate and alters nothing - and what `okf_validate` actually guards (shape, not truth; error-only conformance) with the two-spec trap, and why computed-style assertions beat more screenshots for below-fold coverage and for contrast (a screenshot returns a pixel delta, never a ratio) * [CI gates](ci-gates.md) - what GitHub Actions enforces: build, unit, path-scoped link check (visual regression is report-only), and what gates a PR never sees * [Template PDFs](pdf-templates.md) - regenerating the downloadable course PDFs -* [Classes only on <th> get purged](hugo-stats-th-classes.md) - Hugo 0.165 writeStats records no class attrs on ``, so PurgeCSS deletes those rules from the production bundle while dev builds look fine and the visual gate stays green; also two sibling false-greens on the /next/ rail (site-scripts in a landing baseof, computed `resources.Get` paths defeating the orphan guard) +* [Classes only on <th> get purged](hugo-stats-th-classes.md) - Hugo 0.165 writeStats records no class attrs on ``, so PurgeCSS deletes those rules from the production bundle while dev builds look fine and the visual gate stays green - unguardable by a screenshot, guardable by a set-diff unit test (`next_purge_guard_test.rb`); also two sibling false-greens on the /next/ rail (site-scripts in a landing baseof, computed `resources.Get` paths defeating the orphan guard) diff --git a/.okf/build/test-gates.md b/.okf/build/test-gates.md index f26522931..cd2d7659f 100644 --- a/.okf/build/test-gates.md +++ b/.okf/build/test-gates.md @@ -196,9 +196,45 @@ This is why the suite leans on per-section screenshots - each one scrolls its section INTO the viewport first (`verify_section_for` → `scroll_to find(css)`), which is the existing workaround, not an accident. A page asserted only as one top-of-page shot is verified for its first fold and -nothing else. Closing the gap properly means either more section shots or -full-page capture; both are out of scope for the tolerance change and belong to -the follow-up that re-records baselines. +nothing else. + +# Computed styles beat more screenshots for below-fold and for contrast + +More captures is the tempting answer to the fold problem and the expensive one: +each new baseline inherits the tolerance problem above (a font/SVG-heavy band +needs its noise floor MEASURED by recording twice - the 0.0001 default is +unsafe there), and every one is a file two platforms have to agree on. + +A computed-style assertion in a system test has neither cost. It does not care +about the viewport, and it returns a NUMBER rather than a pixel delta - which +matters because a screenshot cannot tell an intentional recolour from an +accessibility regression. Nothing in this suite measured contrast until +2026-08-22; a 3.33:1 black-on-ruby button shipped and was caught in human +review. + +`test/system/next_pilot_contrast_test.rb` is the worked example, ~1.3s for +three pages, no baselines, wired into `test:critical`: + +- The comparison table's "theirs" column must compute the register's own + `--*-muted` token. Asserted against the token, not a literal and not the + sibling cell: two registers set the "ours" column to the body colour and one + sets it to ink, so a sibling comparison passes on a real defect in the + registers where ink and body differ. That mistake was made and caught here by + injection, not by reading. +- A contrast walk over every visible text run: composite each translucent layer + down to an opaque background, then require 4.5:1 (3:1 for large text, SC + 1.4.3) and 3:1 for a control's fill against its surround (SC 1.4.11). Text + over a background image or gradient is REPORTED as unresolvable rather than + measured against a guessed white. +- Guarded against the false green it would otherwise be: the walk asserts it + measured more than 50 text pairs and at least one fill, because a walk that + finds nothing is empty and empty passes. + +Broken before trusted (2026-08-22), all three branches: an above-fold +`.rr-btn-primary { color: var(--ed-ink) }` failed at `2.31:1 (needs 4.5:1)`; a +below-fold `.rr-td-muted { color: #b5b0ab }` - the class the screenshot gate +cannot see at all - failed at `2.15:1` on five cells; a low-contrast fill with +a readable label failed only the fill branch at `1.12:1 (needs 3.0:1)`. # Rake tasks and suite layout @@ -815,10 +851,17 @@ Skipping step 1 has cost this repo repeatedly: on a site with five real broken links, one of them a conversion path and one a post's own canonical pointing at a 404. -**When a gate cannot discriminate yet, write that in the test.** The derived -tenure assertion cannot tell `derived` from `frozen` while both read "18+" in -2026 - it starts biting on 2027-01-01. That is stated in the test body, so the -next reader does not mistake a passing run for proof. +**When a gate cannot discriminate yet, write that in the test - then ask what +CAN see the defect today.** The rendered tenure assertion cannot tell `derived` +from `frozen` while both read "18+" in 2026; it starts biting on 2027-01-01, +and that limit is stated in the test body so a passing run is not mistaken for +proof. But the limit is a property of the RENDERED surface, not of the defect: +the freeze is plainly visible in the stub's frontmatter. `NextRailTest` +therefore carries both halves, and they are one gate - the rendered assertion +for the value, a source assertion that the tenure stat is `derived: tenure` and +that no stat is frozen at today's derived string. This is not a config test: +the hardcoded literal IS the defect class, the way the 2026-08-14 canon audit +found the founding year wrong in eight places because each kept its own copy. Full fault-injection matrix, including what nothing guards: `docs/20-29-testing-qa/20.11-gate-fault-injection-2026-08-22-reference.md`. diff --git a/.okf/log.md b/.okf/log.md index 13f2331b0..868f42e23 100644 --- a/.okf/log.md +++ b/.okf/log.md @@ -51,6 +51,53 @@ make it green: restructure same-day entries under one heading, and add `timestamp` to the 23 concepts missing it (anchored to each file's last commit time, which is verifiable - never invented). +## 2026-08-22 - closing three of the fault-injection gaps, each broken before it was trusted + +Three of the five misses above now have gates. Every one of them was injected, +watched fail, reverted and watched pass - the procedure this bundle added the +same day, applied to its own output. + +**A gate blind until 2027 needed a second half, not a note.** The rendered +tenure assertion cannot separate a frozen `18+` from a derived one in 2026, and +saying so in the test body is honest but does not guard anything. The freeze is +plainly visible one layer down, in the stub's frontmatter, so `NextRailTest` +now asserts both: the rendered value AND that every pilot's tenure stat is +`derived: tenure` with no stat frozen at today's derived string. General +lesson: when a gate cannot discriminate on one surface, ask which surface it +CAN discriminate on before accepting the hole. + +**"Unguardable" was true only of the gate that was reached for first.** The +`` PurgeCSS trap really is invisible to the visual suite in principle - +baseline and candidate are both built purged. It is trivially visible to a set +diff: styled in source CSS ∩ present in rendered HTML − present in the built +bundle. `test/unit/next_purge_guard_test.rb` is 60 lines, adds ~0.1s, and is +keyed on the mechanism rather than on `th`. The control that makes it +believable is the second injection, not the first: the same class moved onto a +`` goes green, so it flags the purge and not merely a new class. + +**The audit's own recommendation was the expensive answer.** It proposed +per-band screenshots for the 71.7% of a pilot page below the fold. That buys +four more baselines per pilot, each needing its noise floor measured on the +platform that judges it, and still returns a pixel delta rather than a ratio - +so an intentional recolour and an AA regression stay indistinguishable. +`test/system/next_pilot_contrast_test.rb` does both jobs in ~1.3s with no +baselines: computed styles ignore the viewport, and contrast comes out as a +number. It closes the separately-flagged hole that nothing in this suite +measured contrast at all - a 3.33:1 black-on-ruby button shipped this rail and +only human review caught it. + +One mistake worth keeping. The muted-column assertion was first written as +"differs from the sibling cell and from body colour". It PASSED the exact #564 +injection, because in that register the "ours" column is body colour and the +injected ink is a third value. Rewritten to assert the register's own +`--*-muted` token, it fails with the token it actually landed on. The test was +wrong for two runs and green both times; only the injection said so. + +Gates: `test:unit` 289 runs / 6187 assertions / 0 failures in 1.42s (287 / +6143 / 1.33s before - the purge guard reuses the build the suite already +makes); `test:critical` 38 runs / 0 failures, 55 screenshots compared, no +failures, no baseline rewritten; `bin/hugo-build` clean. + ## 2026-08-22 - fault injection: the suite caught 3 of 8, and the misses were the point Eight realistic defects planted one at a time, predictions written down BEFORE diff --git a/Rakefile b/Rakefile index b97575f39..04075fbfe 100644 --- a/Rakefile +++ b/Rakefile @@ -25,10 +25,15 @@ namespace :test do # (2026-07-19 analysis: blog + homepage + course + site-wide dominate # 6-month history) + the lead-gen funnel (services, contact, # free-consultation). Full system suite = test:system; everything = test:all. + # NextPilotContrastTest rides along (~1.3s, no baselines): it is the only + # thing in this suite that measures contrast as a NUMBER rather than as a + # pixel delta, and the only thing that sees the 71.7% of a register pilot + # that sits below the 1080px capture (20.11 §"What nothing guards"). CRITICAL_TESTS = "/homepage|blog_index|blog_post$|visit_blog_post|course_|old_blog_url|" \ "test_services($|_menu)|contact_us|free_consultation|not_found|" \ - "hamburger|color_system|mermaid|codeblock_language|friday_report/" + "hamburger|color_system|mermaid|codeblock_language|friday_report|" \ + "NextPilotContrastTest/" Rake::TestTask.new(:critical) do |t| t.libs << "test" diff --git a/docs/20-29-testing-qa/20.11-gate-fault-injection-2026-08-22-reference.md b/docs/20-29-testing-qa/20.11-gate-fault-injection-2026-08-22-reference.md index 975a31afd..f0727b342 100644 --- a/docs/20-29-testing-qa/20.11-gate-fault-injection-2026-08-22-reference.md +++ b/docs/20-29-testing-qa/20.11-gate-fault-injection-2026-08-22-reference.md @@ -145,6 +145,30 @@ lychee --offline --root-dir \ markup survives into the built bundle, which is a bigger piece of work than the defect has so far justified. +## What was closed afterwards (same day) + +The results above are the measurement and stay as recorded. Three of the five +misses have gates now, each injected, failed, reverted and passed before being +believed: + +| # | Gate added | Where | +|---|---|---| +| D | Source half of the pair - every pilot's tenure stat must be `derived: tenure`, and no stat may be frozen at today's derived string. Bites now, not on 2027-01-01. | `test/unit/next_rail_test.rb` | +| G | Set diff: styled in the register's source CSS ∩ present in the rendered HTML − present in the bundle the page links. Keyed on the mechanism, so the next blind element type is covered. | `test/unit/next_purge_guard_test.rb` | +| B + the contrast hole | Computed styles instead of per-band screenshots: the "theirs" column must compute the register's `--*-muted` token, and every visible text pair must clear 4.5:1 (3:1 large, 3:1 for control fills). Viewport-independent, no baselines, in `test:critical`. | `test/system/next_pilot_contrast_test.rb` | + +Two of this document's own conclusions were wrong and are worth naming: + +- **Recommendation 5 - "the `` purge trap has no cheap automated fix" - + was false.** It is unguardable by the VISUAL gate, which is what the analysis + actually established; a unit test finds it in a set operation for ~0.1s. The + reasoning generalised "this gate cannot" into "no gate can". +- **Recommendation 4 - per-band screenshots for the below-fold gap - was the + expensive answer**, and it inherits the tolerance problem: four more + baselines per pilot, each needing its noise floor measured on the platform + that judges it, and each still reporting a pixel delta rather than a ratio. + Computed styles cover the whole page and return the number. + ## Method notes - Every injection was reverted and the tree checked `git status --porcelain` diff --git a/test/system/next_pilot_contrast_test.rb b/test/system/next_pilot_contrast_test.rb new file mode 100644 index 000000000..6eb8c3cbb --- /dev/null +++ b/test/system/next_pilot_contrast_test.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +require "application_system_test_case" + +# What the screenshot gate cannot see on the register pilots. +# +# 1. BELOW THE FOLD. The capture is viewport-sized: measured on +# /next/pilots/editorial/fractional-cto/ at 1920x1080 the page is 3822px +# tall, so 71.7% of it is outside every baseline at every tolerance +# (20.11 §"What nothing guards"). The comparison table's muted column - the +# #564 defect - starts at y=1090, ten pixels past the frame, and an +# injection that recoloured it shipped green. +# 2. CONTRAST AS A NUMBER. A screenshot only knows pixels changed, so an +# intentional recolour and an AA regression look identical to it. Defect A +# in the same audit was caught only as a pixel delta; nothing measured the +# ratio. A 3.33:1 black-on-ruby button shipped this rail and was caught in +# human review. +# +# Computed styles answer both: they do not care about the viewport, and they +# give a ratio instead of a pixel count. Deliberately NOT per-band +# screenshots - those inherit the tolerance problem (font/SVG-heavy bands need +# their noise floor MEASURED, and the 0.0001 default is unsafe for them, +# .okf/build/test-gates.md) and cost four more baselines per pilot. +class NextPilotContrastTest < ApplicationSystemTestCase + PILOTS = %w[rescue-room editorial precision].freeze + + # SC 1.4.3: 4.5:1 for body text, 3:1 for large text (>=24px, or >=18.66px + # bold). SC 1.4.11: 3:1 for the fill of a control against its surround. + TEXT_MINIMUM = 4.5 + LARGE_TEXT_MINIMUM = 3.0 + NON_TEXT_MINIMUM = 3.0 + + def setup + Capybara.current_driver = :desktop_chrome + super + end + + # The whole point of the muted column is that "theirs" reads quieter than + # "ours". #564 shipped it computing the ink instead - the rule was in source + # and looked applied; only the computed value showed it losing the cascade. + # Asserted against the register's own --*-muted token rather than against a + # literal or a sibling cell: two registers set the "ours" column to the body + # colour and one sets it to ink, so a sibling comparison passes on a defect + # in the registers where ink and body differ. + def test_comparison_table_theirs_column_computes_the_muted_token + PILOTS.each do |register| + visit "/next/pilots/#{register}/fractional-cto/" + + assert_selector ".rr-table tbody .rr-td-muted", wait: 5 + got, tokens = page.evaluate_script(<<~JS) + (function () { + var probe = document.createElement('span'); + document.body.appendChild(probe); + var root = getComputedStyle(document.documentElement); + var tokens = {}; + for (var i = 0; i < root.length; i++) { + var name = root[i]; + // Surface tokens only: --ed-muted, not --ed-ink-muted (which is + // the muted tone ON the dark band, a different surface). + if (!/^--[a-z]+-(muted|ink|body)$/.test(name)) continue; + probe.style.color = ''; + probe.style.color = root.getPropertyValue(name).trim(); + tokens[name] = getComputedStyle(probe).color; + } + probe.remove(); + var cell = document.querySelector('.rr-table tbody .rr-td-muted'); + return [getComputedStyle(cell).color, tokens]; + })() + JS + + muted = tokens.find { |name, _| name.end_with?("-muted") } + refute_nil muted, "#{register}: no --*-muted token on :root - this gate would pass by finding nothing" + + assert_equal muted.last, got, + "#{register}: the \"theirs\" column computes #{got}, not #{muted.first} (#{muted.last}). " \ + "It resolves to #{tokens.select { |_, v| v == got }.keys.join(", ").then { |n| n.empty? ? "no token" : n }} - " \ + "the muted rule lost the cascade and the comparison stopped comparing (#564)" + end + end + + # Every visible text run on the WHOLE page, fold or no fold, plus the fill of + # every control against its surround. + def test_every_pilot_text_pair_clears_wcag_aa + PILOTS.each do |register| + visit "/next/pilots/#{register}/fractional-cto/" + + assert_selector ".rr-hero", wait: 5 + report = page.evaluate_script(contrast_walk) + + assert_operator report["textChecked"], :>, 50, + "#{register}: only #{report["textChecked"]} text pairs measured - this gate would pass by " \ + "finding nothing (the walk found no visible text, or the page did not render)" + assert_operator report["fillsChecked"], :>, 0, + "#{register}: no control fills measured - this gate would pass by finding nothing" + + assert report["failures"].empty?, "#{register}: WCAG AA contrast failures\n" + + report["failures"].map { |f| + format(" %.2f:1 (needs %s) %s %s - %s on %s - %s", + f["ratio"], f["need"], f["kind"], f["selector"], f["fg"], f["bg"], f["sample"]) + }.join("\n") + end + end + + private + + # Runs in the page. Composites every translucent layer down to an opaque + # background before measuring, and reports a pair it cannot resolve (a + # background image or gradient behind the text) rather than guessing white. + def contrast_walk + <<~JS + (function () { + var TEXT = #{TEXT_MINIMUM}, LARGE = #{LARGE_TEXT_MINIMUM}, FILL = #{NON_TEXT_MINIMUM}; + + function parse(value) { + var m = /rgba?\\(([^)]+)\\)/.exec(value || ''); + if (!m) return null; + var p = m[1].split(/[\\s,\\/]+/).filter(Boolean).map(Number); + return {r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1}; + } + function channel(v) { + v = v / 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + } + function luminance(c) { + return 0.2126 * channel(c.r) + 0.7152 * channel(c.g) + 0.0722 * channel(c.b); + } + function ratio(a, b) { + var x = luminance(a), y = luminance(b); + return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); + } + function over(top, bottom) { + return { + r: top.r * top.a + bottom.r * (1 - top.a), + g: top.g * top.a + bottom.g * (1 - top.a), + b: top.b * top.a + bottom.b * (1 - top.a), + a: 1 + }; + } + function label(el) { + return el.tagName.toLowerCase() + (el.className && el.className.baseVal === undefined + ? '.' + String(el.className).trim().split(/\\s+/).join('.') : ''); + } + // Walks up compositing translucent layers. null = an image or gradient + // sits behind this text and no number here would mean anything. + function background(el) { + var layers = [], node = el; + while (node && node.nodeType === 1) { + var s = getComputedStyle(node); + if (s.backgroundImage && s.backgroundImage !== 'none') return null; + var c = parse(s.backgroundColor); + if (c && c.a > 0) { + layers.push(c); + if (c.a === 1) break; + } + node = node.parentElement; + } + var base = {r: 255, g: 255, b: 255, a: 1}; + for (var i = layers.length - 1; i >= 0; i--) base = over(layers[i], base); + return base; + } + function visible(el) { + var s = getComputedStyle(el); + if (s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) === 0) return false; + // .sr-only and friends: clipped to nothing, never seen. + if (s.clipPath && s.clipPath.indexOf('inset(50%') === 0) return false; + var r = el.getBoundingClientRect(); + return r.width >= 1 && r.height >= 1; + } + function ownText(el) { + var out = ''; + for (var i = 0; i < el.childNodes.length; i++) { + if (el.childNodes[i].nodeType === 3) out += el.childNodes[i].nodeValue; + } + return out.trim(); + } + + var failures = [], textChecked = 0, fillsChecked = 0; + + Array.prototype.forEach.call(document.body.querySelectorAll('*'), function (el) { + var text = ownText(el); + if (!text || !visible(el)) return; + var s = getComputedStyle(el); + var fg = parse(s.color), bg = background(el); + if (!fg) return; + if (!bg) { + failures.push({kind: 'text', selector: label(el), fg: s.color, bg: 'image/gradient', + ratio: 0, need: 'a resolvable background', sample: text.slice(0, 40)}); + return; + } + var size = parseFloat(s.fontSize), weight = parseInt(s.fontWeight, 10) || 400; + var need = (size >= 24 || (size >= 18.66 && weight >= 700)) ? LARGE : TEXT; + var got = ratio(over(fg, bg), bg); + textChecked++; + if (got + 0.005 < need) { + failures.push({kind: 'text', selector: label(el), fg: s.color, + bg: 'rgb(' + Math.round(bg.r) + ', ' + Math.round(bg.g) + ', ' + Math.round(bg.b) + ')', + ratio: got, need: need.toFixed(1) + ':1', sample: text.slice(0, 40)}); + } + }); + + // SC 1.4.11: a filled control has to be distinguishable from what is + // behind it, whatever its label does. + Array.prototype.forEach.call( + document.body.querySelectorAll('a[class*="btn"], button, input[type="submit"]'), + function (el) { + if (!visible(el)) return; + var fill = parse(getComputedStyle(el).backgroundColor); + if (!fill || fill.a === 0) return; + var behind = el.parentElement ? background(el.parentElement) : null; + if (!behind) return; + fillsChecked++; + var got = ratio(over(fill, behind), behind); + if (got + 0.005 < FILL) { + failures.push({kind: 'fill', selector: label(el), fg: getComputedStyle(el).backgroundColor, + bg: 'rgb(' + Math.round(behind.r) + ', ' + Math.round(behind.g) + ', ' + Math.round(behind.b) + ')', + ratio: got, need: FILL.toFixed(1) + ':1', sample: (el.textContent || '').trim().slice(0, 40)}); + } + }); + + return {failures: failures, textChecked: textChecked, fillsChecked: fillsChecked}; + })() + JS + end +end diff --git a/test/unit/next_purge_guard_test.rb b/test/unit/next_purge_guard_test.rb new file mode 100644 index 000000000..63428404d --- /dev/null +++ b/test/unit/next_purge_guard_test.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "base_page_test_case" + +# PurgeCSS keeps a rule only if the class reached hugo_stats.json, and Hugo's +# writeStats does not record class attrs on every element - `` is one it +# misses (.okf/build/hugo-stats-th-classes.md). The result is a rule that +# exists in source, markup that still carries the class, and NOTHING in the +# bundle the browser downloads. +# +# No visual gate can see this even in principle: it builds with +# ENVIRONMENT=production, so it purges exactly as production does and matches +# its own baseline (20.11 defect G - the screenshot test reported +# "1 screenshot compared, no failures" over a rule that had been deleted). +# Caught by human review in #563; this is the automated half. +# +# Deliberately keyed on the MECHANISM, not on ``: any element type Hugo +# stops recording, or starts, is covered without an edit here. +class NextPurgeGuardTest < BasePageTestCase + REPO_ROOT = File.expand_path("../..", __dir__) + SOURCE_CSS = "themes/beaver/assets/css/pages/next-%s.css" + + # A class selector: a dot that is not part of a decimal, a filename, or the + # tail of another identifier. + CLASS_SELECTOR = /(?