diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml index ec975c0af..ba907dd18 100644 --- a/.github/workflows/link-check.yml +++ b/.github/workflows/link-check.yml @@ -51,7 +51,9 @@ jobs: # ran on). github.sha for workflow_run = current default-branch head. if: ${{ github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_sha != github.sha) }} runs-on: ubuntu-latest - timeout-minutes: 10 + # 15, not 10: html-proofer's image/fragment pass over the full page set + # runs after lychee inside the same job. + timeout-minutes: 15 steps: - uses: actions/checkout@v7 with: @@ -73,4 +75,13 @@ jobs: with: tool: lychee - - run: bundle exec rake test:links + # html-proofer catches what lychee does not: image src files that are + # referenced but missing from the build, and malformed hash fragments. + # The task existed in the Rakefile but was wired into no workflow, + # hook, or script - dead code reading as coverage (2026-08-07 audit). + # It is non-blocking by its own design; test:links stays blocking. + # + # ONE rake invocation, not two steps: build_for_linkcheck memoizes per + # process, so the pair shares a single production build. Two steps + # would build the whole site twice and blow the job timeout. + - run: bundle exec rake test:links test:html_proofer diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1276171a9..3e3544082 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -58,3 +58,42 @@ jobs: env: PRECOMPILED_ASSETS: '1' HUGO_DEFAULT_PATH: _dest/public-test + + # Asset-pipeline integration: dev vs prod branching in css-processor, + # css-inline, and js-processor (fingerprint/integrity/minify/crossorigin). + # Previously ran only inside `rake test` on push-to-master, so a PR could + # not see it - and it used to `skip` itself when a build failed, reporting + # green for the exact regression it guards (both fixed 2026-08-07). + # + # Its own job, not a step in unit_tests: it drives two full Hugo builds of + # its own and would put that job over its timeout. + integration_tests: + name: Asset Pipeline + if: ${{ github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_sha != github.sha) }} + runs-on: ubuntu-latest + # 25, measured, not guessed. The suite itself runs ~10 min (two full Hugo + # builds, and the dev-environment one cannot reuse the production-keyed + # resource cache), and `actions/checkout` on this repo has been observed + # taking 7 min on a slow runner - 17 min against a 15-minute cap. A gate + # that flakes on timeout is worse than no gate: it teaches people to + # ignore red. Headroom is cheaper than that. + timeout-minutes: 25 + steps: + - uses: actions/checkout@v7 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '4.0' + bundler-cache: true + + # build: 'false' - the suite runs its own dev and prod builds. + - uses: ./.github/actions/setup-hugo + with: + build: 'false' + + # This suite shells out to `hugo build` directly (it needs the dev and + # prod builds side by side), so it does not inherit the PATH that + # bin/hugo-build sets for PostCSS - mirror it here. + - run: PATH="./node_modules/.bin:$PATH" bundle exec rake test:integration + env: + HUGO_CACHEDIR: /tmp/hugo_cache diff --git a/.okf/build/ci-gates.md b/.okf/build/ci-gates.md index 13329f376..5150c5dd6 100644 --- a/.okf/build/ci-gates.md +++ b/.okf/build/ci-gates.md @@ -7,6 +7,9 @@ resource: .github/workflows/link-check.yml generated: by: process:okf-migrate at: 2026-07-31T16:30:00Z +verified: + - by: claude/opus-5 + at: 2026-08-07T00:00:00Z --- # What CI enforces on a PR @@ -20,15 +23,57 @@ generated: Plus `bin/lint-css` (stylelint warning ratchet) piggybacked on the unit_tests job. See local pre-PR gates in [test-gates.md](test-gates.md). +# Two more PR gates (added 2026-08-07) + +| Check | Workflow | Runs | +|---|---|---| +| `Asset Pipeline` (`rake test:integration`) | `publish.yml` | Every push/PR | +| `rake test:html_proofer` | `link-check.yml` | Same trigger as `test:links`, same job, non-blocking | + +Both were dead before this: `test:integration` ran only inside `rake test` on +push-to-master AND `skip`ped itself when the Hugo build failed (a broken build +reported green with every test skipped - now `flunk`s with the build output); +`test:html_proofer` was invoked by no workflow, hook, or script at all. + +**Job runtimes are cache-dependent - budget for the cold case** (measured +2026-08-07 across three runs). `Broken Internal Links` ran 3.5 min on a warm +resource cache and **10.7 min** right after master moved and invalidated it; at +its old 10-minute timeout that run would have failed for no reason but cache +state. `Asset Pipeline` runs ~10 min (two full Hugo builds; the +dev-environment one cannot reuse the production-keyed `resources/_gen` cache), +and `actions/checkout` on this repo was observed taking **7 min** on a slow +runner - 17 min of wall clock against what was a 15-minute cap. Timeouts are +now 15 and 25. Do not trim them back toward the observed average: a gate that +flakes on timeout teaches people to ignore red, which costs more than the +runner minutes. + +Two things to preserve when touching either: +- **`test:links` and `test:html_proofer` share ONE rake invocation** + (`rake test:links test:html_proofer`). Both default to the same `OUTPUT_DIR` + and each triggers `build_for_linkcheck`, which is memoized per rake PROCESS. + Split them into two `run:` steps and the site builds twice - the exact + double-build that blew this job's timeout and forced `setup-hugo build: 'false'`. +- **`test:integration` gets its own job**, not a step in `unit_tests`: it drives + two full Hugo builds of its own (~50s locally) and would push that job over + its timeout. + +Still local-only: **`rake test:guards`** runs in `.githooks/pre-push`; a PR +pushed with `SKIP_CHECKS=1` never sees it. + +Full gap analysis with `lib/` coverage numbers and per-layer evidence: +`docs/20-29-testing-qa/20.10-test-coverage-gap-analysis-reference.md`. + # Toolchain single source of truth `.mise.toml` pins hugo/bun/node/ruby (local install via `mise install`; `bin/setup` wraps it + doctor). CI copies of the pins live in `.github/actions/setup-hugo/action.yml` (hugo default, bun-version, node-version), workflow `ruby-version` inputs, and the `.dev/compose.yml` -image tag; the drift test fails the build when any copy diverges - update -them together. `_hugo.yml` must NOT carry its own pins (it calls the -composite with `build: 'false'`); the drift test enforces that too. +image tag. **Nothing enforces this** since the drift gate +(`test/unit/toolchain_pins_test.rb`) was deleted on 2026-08-01 as a +config-mirror anti-pattern - the copies are synced by convention now, so +update them in the same commit by hand. `_hugo.yml` must NOT carry its own +pins (it calls the composite with `build: 'false'`), also by convention. Gotchas: the Ruby pin must be an EXACT patch version - rbenv reads `.ruby-version` and never matches a fuzzy "4.0"; agent containers block `api.github.com` through the proxy, so `mise install` cannot fetch diff --git a/.okf/build/index.md b/.okf/build/index.md index a288d7258..e708f184f 100644 --- a/.okf/build/index.md +++ b/.okf/build/index.md @@ -2,5 +2,5 @@ * [Hugo build pipeline](hugo-build.md) - bin/hugo-build with the 8 course validators * [Test gates](test-gates.md) - the local suites and when each is a commit blocker -* [CI gates](ci-gates.md) - what GitHub Actions enforces: build, unit, path-scoped link check (no visual regression in CI) +* [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 diff --git a/.okf/log.md b/.okf/log.md index 3fe4f01d5..2474fe2ce 100644 --- a/.okf/log.md +++ b/.okf/log.md @@ -652,3 +652,180 @@ than it returns. Those posts still cross-link sibling technical posts, which is what actually keeps readers on the site. Founder-stream posts keep the funnel requirement unchanged. Exemption recorded in 20.08 and applied to `kamal-2-multi-server-deployment-complete-guide`. +## 2026-08-07 - Test coverage gap analysis; two false-green mechanisms found + +Full audit written to +`docs/20-29-testing-qa/20.10-test-coverage-gap-analysis-reference.md`. + +* **`lib/` is healthy**: 91.6% line coverage (716/782 relevant lines), + measured with SimpleCov over `test/unit/sync/**` + + `course_validators_test.rb` (103 runs, 207 assertions). Worst files are + network-error branches in `dev_to_article_fetcher` (67.9%) and + `sources/base` (75.0%). SimpleCov is in the Gemfile but `require`d + nowhere, so no coverage is collected in any run today - the measurement + needs a `RUBYOPT=-r` shim. +* **False green #1**: `test/integration/hugo_pipeline_test.rb:48-51` `skip`s + the whole asset-pipeline suite when the Hugo build fails - the exact + failure it guards. Should `flunk` with the build stderr. +* **False green #2**: 42 conditional assertion guards across the unit tests + (`if robots_meta ... assert ... end`, e.g. `baseof_template_test.rb:153`, + `404_template_test.rb:186`). They pass when the element is absent, while + reading as "present and well-formed". +* **`rake test:html_proofer` is invoked nowhere** (no workflow, hook, or + script); `rake test:integration` never gates a PR. Recorded in + [ci-gates](/build/ci-gates.md). +* **ci-gates.md was stale**: it claimed a toolchain drift test "fails the + build when any copy diverges". That test was deleted 2026-08-01 (see + entry above) - pins are now synced by convention with zero enforcement. + Corrected. Same stale phrase removed from the Rakefile `:guards` comment. +* **Validator scope boundary is measurable**: `CourseValidators` filters on + `course_chapter == true`, so 82 of 727 content pages are gated. Em-dash + files by scope: course 0/82, marketing 1/37, blog 208/607. The CLAUDE.md + `-` not `—` rule holds exactly where a validator enforces it. Blog needs + ratchet semantics (no NEW violations), not a hard fail on 208 legacy + dev.to imports. +* **Untested money paths**: the contact/free-consultation form renders every + field `name` from `.Site.Params.forms.contact.*`; Hugo renders a missing + param as `""` with no error, the page stays pixel-identical, and leads + submit blank. `seo/faq-schema.html` ships on 10 service pages with zero + tests while article/breadcrumb/organization/service schemas each have one. +* **`lib/sync/sources/sanity.rb`** (129 lines) is referenced by nothing + outside itself and loaded by no test - 0% covered, and it holds the + `sanity-ruby` gem dependency in place. Delete-or-test decision. + +## 2026-08-07 - Closed the two false-green mechanisms; wired the two dead CI gates + +Follow-through on the gap analysis logged above. `rake test:unit` 272 -> 285 +runs, 5723 -> 5935 assertions, still 0 failures. + +* **Integration suite fails loudly now**: `hugo_pipeline_test.rb` replaced its + two `*_ready?` predicates with `build_failure`, which returns nil or a + diagnostic and is `flunk`ed in `setup`. It distinguishes "hugo not on PATH" + from a real build error and prints the last 30 lines of build output. + Verified by shimming `hugo` to `/bin/false` - the suite fails instead of + skipping 11 tests into a green report. +* **`rake test:integration` now gates PRs** as an `Asset Pipeline` job in + publish.yml, separate from `unit_tests` (it drives two Hugo builds of its + own, ~50s locally) with `setup-hugo build: 'false'`. +* **`rake test:html_proofer` is finally invoked**: link-check.yml runs + `rake test:links test:html_proofer` as ONE rake invocation. `build_for_linkcheck` + is memoized per process so the pair shares a single production build - two + separate steps would each trigger their own build, which is exactly what blew + that job's timeout before (the reason the workflow passes `build: 'false'`). + Timeout 10 -> 15 min. +* **Two schema test files were dead code**: `breadcrumb_schema_test.rb` and + `service_schema_test.rb` were commented out in full behind stale + "restore when schema implemented in reverted HTML" TODOs. The build emits + both `BreadcrumbList` and `Service` today. Uncommented, 3 tests each, green. + Lesson: a test file existing is not coverage - grep for `def test_`, not for + the filename. +* **New `test/unit/lead_forms_test.rb`**: the funnel forms' field `name`s come + from `[params.forms.*]`, and Hugo renders a missing param as `""` without + failing the build. RED-verified by renaming `first_name` in hugo.toml and + rebuilding - the test names the broken field id and the config key. +* **New `test/unit/meta_tags/faq_schema_test.rb`** (6 tests) including a sweep + over every service page declaring `faqs` in frontmatter, so a template guard + that stops matching turns red instead of silently dropping rich results. +* **Guard sweep, `baseof` + `404`**: presence assertions where the element + exists; dead branches deleted where it does not (`.logo-image-main` no longer + exists anywhere; no `meta[name=referrer]`; no search form). The mermaid SRI + test asserted the retired jsdelivr+SRI implementation while running against + index.html, which never loads mermaid - retargeted to a diagram page and + rewritten to assert same-origin, matching the self-hosting change. +* **Test-env gotcha**: `parse_html_file` uses bare `File.read`, so on a + container with no `LANG` (`Encoding.default_external` = US-ASCII) Nokogiri + aborts with "FATAL: Invalid bytes in character encoding", every selector + returns empty, and 73 template tests fail for a non-template reason. Run the + suite under `LANG=C.UTF-8`. + +## 2026-08-07 - Guard sweep found a live bug the false-green was hiding + +Continued the conditional-assertion sweep into `list_template_test.rb` and +`home_template_test.rb`. `rake test:unit` now 276 runs / 5967 assertions / +0 failures (from 272 / 5723): the suite got SMALLER and checks MORE. + +* **Live bug, hidden for as long as the test existed**: + `test_list_page_date_information` selected post items with + `"article, .post, .post-item, .entry"`. The blog index renders `.blog-post` + and nothing else from that list, so the selector matched ZERO items on every + run - and `if items.any?` turned that into a pass. Both list tests now share + one `ITEM_SELECTOR` constant so the two cannot drift apart again. This is the + concrete argument for the sweep: a guard does not just fail to catch future + regressions, it hides present ones. +* **`setup` skips are the same defect one level up**: `list_template_test` + skipped all 13 tests when no list page was found. The blog index vanishing IS + the regression. Now `refute_empty`. +* **Classification rule that made the sweep tractable**: check the built page + first, then decide. Element present -> replace the guard with a presence + assertion. Element absent -> the branch is dead; delete it and record in + place what to assert if the feature ships. Deleted this round: filtering/ + sorting, RSS head link, search, `.breadcrumb`/`.author`/`.category` elements, + homepage breadcrumbs, CSP meta, analytics (environment-gated out of the test + build). +* **Discarded-value lines are a sibling smell**: `external_scripts.length + + external_stylesheets.length` and `large_images.any? { ... }` computed a value + and dropped it. Where an invariant was behind them it is now asserted - the + 404 page, blog index, and homepage each load zero third-party scripts and + stylesheets, which is also why the site needs no dns-prefetch. +* **Scan over-reports**: `.each` over a literal array, or over a collection the + test already asserted non-empty, always runs. 61 raw hits, 32 addressed; + the rest live in template_cleanup_validation (9), hugo_partials (8), + single_template (5), seo_schema (3) + singletons. Scan script is in + `docs/20-29-testing-qa/20.10-test-coverage-gap-analysis-reference.md` §5. + +## 2026-08-07 - Guard sweep complete: 61 candidates triaged, four live bugs found + +Finished the conditional-assertion sweep (single_template, hugo_partials, +template_cleanup_validation, seo_schema, asset_url_validation, +hugo_asset_validation, testimonial_shortcode). `rake test:unit` 275 runs / +6086 assertions / 0 failures, from 272 / 5723 - assertions +363 while the +test count went DOWN by 13. That ratio is the whole point of the exercise. + +**Four live bugs the guards were hiding** (a guard does not just miss future +regressions, it hides present ones): + +1. **`single_template_test.rb` never tested a single page.** `@test_pages` led + with `"blog/index.html"` and `.first` picked it, so 376 lines nominally + covering `single.html` ran against the LIST page. Pinned to a real post via + `SINGLE_PAGE`. Retargeting immediately exposed bug 4. +2. **Two item selectors omitted `.blog-post`** - the only class the blog index + renders. `test_list_page_date_information` and `test_blog_post_partials` + matched zero items on every run. +3. **`css_urls.any? do |url| assert ... end`** in asset_url_validation: + `any?` short-circuits on the first truthy block result and `assert` returns + true, so only the FIRST stylesheet was ever checked. +4. **Over-strict a11y rule**: image-only links were flagged as having no + accessible name. A link wrapping an image takes its name from the image + `alt` (WCAG 2.1 SC 1.1.1). Blog posts wrap YouTube thumbnails this way. + +**Skip-style guards are the same defect one level up** and are all gone: +list_template skipped 13 tests with no list page; 404_template skipped 12 with +no 404.html; template_cleanup_validation had 9 `next unless test_page_exists?` ++ 3 `return unless` (now one `assert_empty missing` in setup, helper deleted); +seo_schema called `skip "Schema N is empty - might indicate template issue"` - +an empty JSON-LD block IS that template issue. + +**Reusable rule for this class of work**: check the BUILT page first, then +decide. Element present -> presence assertion. Element absent -> the branch is +dead; delete it and record in place what to assert if the feature ships. Never +promote a guard to an assertion without confirming the element exists, and +never delete without confirming it does not. + +## 2026-08-07 - CI timeouts sized from measured cold-cache runs, not averages + +Three runs of the new gates produced hard numbers worth keeping: + +* `Broken Internal Links`: **3.5 min warm, 10.7 min cold** (right after master + moved and invalidated `resources/_gen`). At its original 10-minute timeout + the cold run would have gone red for nothing but cache state. Now 15. +* `Asset Pipeline`: **~10 min** for the suite itself - two full Hugo builds, + and the dev-environment build cannot reuse the production-keyed resource + cache, so it reprocesses images. Plus `actions/checkout` measured at **7 min** + on one slow runner: 17 min of wall clock against a 15-minute cap. Now 25. + +**Rule**: size a CI timeout from the worst observed run plus headroom, never +from the average. A gate that flakes on timeout is worse than no gate - it +trains reviewers to ignore red, and the runner minutes it "saves" are trivial +next to that. Diagnosis tell for this class: read the per-STEP timings in +`list_workflow_jobs`, not just the job duration - the 7-minute checkout was +invisible at job level and would have been misread as a slow test. diff --git a/Rakefile b/Rakefile index 8f61037a8..baf46d6fe 100644 --- a/Rakefile +++ b/Rakefile @@ -70,7 +70,7 @@ namespace :test do t.pattern = "test/integration/**/*_test.rb" end - # Fast repo-shape guards (~2s): toolchain pin drift + bin-script portability. + # Fast repo-shape guards (~2s): bin-script portability + hook wiring. # The pre-push hook's test entrypoint - one task, so the hook never falls # into the `ruby file1.rb file2.rb` trap (only file1 executes). Rake::TestTask.new(:guards) do |t| @@ -97,8 +97,16 @@ namespace :test do # dev default) rather than depending on :build - config/development/hugo.toml # disables the taxonomy/term page kinds for build speed, which would make # every real /blog/tags/* link on the site look broken. + # Memoized per rake process so `rake test:links test:html_proofer` builds + # ONCE. Both tasks default to the same OUTPUT_DIR, and a second full + # production build in the same job is what blew link-check.yml's timeout + # on cold caches before (see the workflow's setup-hugo build: 'false'). def build_for_linkcheck(dir) + @linkcheck_built ||= {} + return if @linkcheck_built[dir] + sh({ "ENVIRONMENT" => "production", "OUTPUT_DIR" => dir }, "./bin/hugo-build") + @linkcheck_built[dir] = true end desc "Broken internal links (offline, blocking) via lychee" diff --git a/docs/20-29-testing-qa/20.10-test-coverage-gap-analysis-reference.md b/docs/20-29-testing-qa/20.10-test-coverage-gap-analysis-reference.md new file mode 100644 index 000000000..36ac2e161 --- /dev/null +++ b/docs/20-29-testing-qa/20.10-test-coverage-gap-analysis-reference.md @@ -0,0 +1,435 @@ +# 20.10 Test Coverage Gap Analysis (2026-08-07) + +Evidence-based audit of what the suite covers, what it does not, and which +gaps are worth closing. Measured on `master` at commit time, Ruby 3.3.6. + +> **Status**: items 1-4 of the suggested order are DONE in the same PR as +> this doc. Findings below keep their original wording, each with a +> `**FIXED**` note where the work has landed. Items 5-9 are open, and §3 +> tracks the remainder. + +**Bottom line**: the Ruby library layer is in good shape (91.6% line coverage). +The gaps are in the layers the suite treats as "rendered output": marketing and +service page templates, form partials, structured-data partials, and the +content-quality gates that stop at `content/course/`. Two mechanisms also +produce false greens today. + +--- + +## 1. What was measured + +| Layer | Size | Test surface | +|---|---|---| +| `lib/` Ruby | 19 files, 782 relevant lines | 10 unit test files, 91.6% line coverage | +| `bin/` scripts | 29 executables | 1 test file (`bin_scripts_test.rb`, 5 shape guards) | +| Hugo templates | 91 layouts, 59 partials, 2 shortcodes | 15 template unit tests + 4 system tests | +| Content | 607 blog posts, 82 course pages, 37 marketing pages | course-only validators + screenshot tests on 25 URLs | +| JS | `navigation.js`, `tabs.js`, `static/sw.js` (145 LOC) | none direct | + +Coverage numbers come from a SimpleCov run over the pure-Ruby tests +(`test/unit/sync/**`, `test/unit/course_validators_test.rb`), 103 runs, +207 assertions, 0 failures. + +### `lib/` line coverage, worst first + +``` +lib/sync/dev_to_article_fetcher.rb 67.9% (19/28) +lib/sync/sources/base.rb 75.0% (15/20) +lib/sync/dev_to_client.rb 84.6% (22/26) +lib/sync/article_updater.rb 87.7% (71/81) +lib/sync/article_cleaner.rb 89.5% (34/38) +lib/sync/post.rb 91.1% (82/90) +lib/sync/sync_script.rb 92.0% (23/25) +lib/course_validators.rb 92.5% (172/186) +lib/sync/images_downloader.rb 93.5% (58/62) +lib/sync/sources/dev_to.rb 95.2% (60/63) +lib/sync/article_sync_checker.rb 95.8% (46/48) +lib/sync/app.rb 96.2% (25/26) +... 6 files at 100% +----------------------------------------------------- +TOTAL lib/ 91.6% (716/782) +``` + +`lib/sync/sources/sanity.rb` (129 lines) does not appear at all: no test ever +loads it, so it is 0% and excluded from the total above. + +--- + +## 2. Findings, ranked + +### P0-1: Two mechanisms produce false greens + +**Integration suite skips itself when the build breaks.** +`test/integration/hugo_pipeline_test.rb:48-51` calls `skip` when the Hugo dev +or prod build fails. The suite it guards is the asset pipeline (fingerprinting, +integrity attributes, minification, `.min.` naming) - exactly what a broken +build would take down. Failure scenario: a `hugo.toml` change breaks the +production build; `rake test` reports 0 failures with every integration test +skipped, and the pipeline regression ships. + +**FIXED**: `build_failure` replaces the two `*_ready?` predicates; a failed +build now `flunk`s with the last 30 lines of build output, and distinguishes +"hugo not on PATH" from a real build error. The two `skip "No * CSS file +found"` cases became `refute_nil`. Verified by shimming `hugo` to +`/bin/false`: the suite fails instead of reporting green. + +**42 conditional assertion guards across the unit tests.** +Pattern (`test/unit/baseof_template_test.rb:153-158`): + +```ruby +robots_meta = doc.css("head meta[name='robots']").first +if robots_meta + assert robots_content.include?("index") || robots_content.include?("noindex") +end +``` + +If the tag disappears entirely, the test passes. Same shape in +`404_template_test.rb:186-192`. These assert "if present, well-formed" while +reading as "present and well-formed." + +A scan for tests whose *shallowest* assertion sits below the method's base +indent counts **61** candidates. That number over-reports: an `.each` over a +literal array (e.g. `sensitive_terms.each { assert ... }`) always runs. Each +site needs the build checked before it can be classified. + +**FIXED** - all 61 triaged against the actual build. The 21 the scan still +reports are the `.each`-over-a-known-non-empty-collection false positive +(a literal array, or a collection the test now asserts non-empty first). + +| Guarded on | Present in build | Action | +|---|---|---| +| `robots`, `twitter:card`, `twitter:site`, `X-UA-Compatible`, serviceWorker script, `.sr-only`, post items, pagination, per-item dates, headings, Organization schema, social links, `preload` | yes | guard replaced with a presence assertion | +| `.logo-image-main` | no - class deleted from the codebase | test removed | +| `meta[name=referrer]`, `meta[http-equiv=Content-Security-Policy]` | no | dead branches/tests removed | +| mermaid CDN script + SRI on `index.html` | no - mermaid is self-hosted and only on diagram pages | retargeted to a diagram page, now asserts same-origin | +| search form, "popular content", filtering/sorting, RSS head link, `.breadcrumb`, `.author`, `.category`/`.tag` elements | no | tests removed, each with an in-place note on what to assert if the feature ships | +| analytics script | no - environment-gated out of the test build | test removed | + +Several more only ever called `puts "INFO: ..."`, and three computed a value and +discarded it (`external_scripts.length + external_stylesheets.length`, +`large_images.any? { ... }`). Those became real assertions on invariants that do +hold: the 404 page, blog index, and homepage each load zero third-party scripts +or stylesheets. + +### The guards were hiding four live bugs + +This is the real argument for the sweep. A guard does not merely fail to catch +future regressions - it hides present ones. + +1. **`single_template_test.rb` never tested a single page.** Its `@test_pages` + array led with `"blog/index.html"`, and `@test_pages.first` picked it, so + 376 lines nominally covering `single.html` ran against the *list* page. Now + pinned to a real post via a `SINGLE_PAGE` constant. Pointing it at a post + immediately surfaced a wrong assertion (below). +2. **Two item selectors omitted `.blog-post`**, the only class the blog index + renders. `test_list_page_date_information` and `test_blog_post_partials` + both matched zero items on every run since they were written, and + `if items.any?` made that read as a pass. The list tests now share one + `ITEM_SELECTOR` constant. +3. **`css_urls.any? do |url| assert ... end`** in `asset_url_validation_test.rb` + - `any?` short-circuits on the first truthy block result and `assert` + returns true, so only the *first* stylesheet was ever checked, and an empty + list checked nothing. +4. **An over-strict accessibility rule** flagged image-only links as having no + accessible name. A link wrapping an image takes its name from the image's + `alt` (WCAG 2.1 SC 1.1.1). It only surfaced once `single_template_test` + started testing a page that has such links. + +Skip-style guards were the same defect one level up, and are gone too: +`list_template_test` skipped all 13 tests when no list page existed; +`404_template_test` skipped all 12 when `404.html` was missing; +`template_cleanup_validation_test` carried 9 `next unless test_page_exists?` +and 3 `return unless`, now one `assert_empty missing` in `setup`; +`seo_schema_test` called `skip "Schema N is empty - might indicate template +issue"` - an empty JSON-LD block IS the template issue it exists to catch. + +### P0-2: The lead-gen forms have no behavioral test + +`themes/beaver/layouts/partials/page/contact-form.html` renders every field's +`name` attribute from site params: + +``` +name='{{ .Site.Params.forms.contact.first_name }}' +``` + +Hugo renders a missing param as the empty string with no error. Rename or drop +`params.forms.contact.first_name` in `config/_default/hugo.toml:312` and the +form still renders, still looks pixel-identical to the screenshot baseline, and +submits `name=""` to the Google Form endpoint. Every lead from `/contact-us/` +and `/free-consultation/` silently lands as blank fields. + +`test_contact_us` and `test_free_consultation` visit the page and take a +screenshot. Neither reads the DOM. This is the site's conversion path and its +only regression detector is a human noticing the CRM went quiet. + +**FIXED**: `test/unit/lead_forms_test.rb` asserts a non-empty absolute https +action and a non-empty `name` on every submittable field of `/contact-us/`, +`/free-consultation/`, and the career form. Assertions are on shape, never on +the `entry.` values or the endpoint URL. + +RED verified rather than assumed: renaming `first_name` to `first_name_RENAMED` +in `config/_default/hugo.toml` and rebuilding produced + +``` +contact-us/index.html: these fields render name="" and submit nothing. +Expected ["input_5_1"] to be empty. +``` + +The failure names the field and the config key to check. + +### P0-3: FAQ structured data is untested, and two sibling schema files are dead code + +`themes/beaver/layouts/baseof.html:42` emits `seo/faq-schema.html` sitewide, and +11 service pages carry FAQ content. `faq` appeared nowhere in `test/`. + +The `test/unit/meta_tags/` directory looked like it covered the sibling +schemas. It did not - **`breadcrumb_schema_test.rb` and +`service_schema_test.rb` were commented out in their entirety**, zero active +tests between them, behind stale `TODO: Restore when schema implemented in +reverted HTML` markers. The build emits both `BreadcrumbList` and `Service` +today, so the TODOs outlived their reason and the files read as coverage that +did not run. (This corrects the first draft of this doc, which counted them as +tested because the files exist.) + +FAQ rich results are a primary AEO surface for the service pages, and malformed +JSON-LD fails silently (Google drops the result, the page still renders). + +**FIXED**: `test/unit/meta_tags/faq_schema_test.rb` added (6 tests): valid +`FAQPage` context and type, every `Question` carrying a non-empty +`acceptedAnswer`, no duplicate questions, exactly one FAQPage block per page, +and a sweep asserting that *every* service page declaring `faqs` in frontmatter +emits the schema - so a template guard that silently stops matching turns red +instead of quietly dropping rich results across the section. +`breadcrumb_schema_test.rb` and `service_schema_test.rb` are uncommented and +passing, 3 tests each. + +### P1-1: Content quality gates stop at `content/course/` + +`CourseValidators` enforces 8 gates (em-dash ban, banned-string ratchet, +year-stamp fabrication, internal links, title/YAML match, table width, +disclaimer consistency, chapter numbering). `course_chapters` +(`lib/course_validators.rb:312`) filters on `course_chapter == true` in +frontmatter, so the scope is 82 pages out of 727. + +Measured effect of the boundary: + +| Scope | Pages | Files containing an em dash | +|---|---|---| +| `content/course/` (validated) | 82 | 0 | +| `content/pages` + `services` + `use-cases` + `clients` | 37 | 1 | +| `content/blog/` (unvalidated) | 607 | 208 | + +CLAUDE.md states the `-` not `—` rule for all content. It holds where a +validator enforces it and does not where one does not. The blog number is +mostly legacy dev.to imports, which is why a hard fail is the wrong shape here. + +Fix: extend the validator to a second scope with **ratchet** semantics - fail +only on files whose violation count increased versus `git HEAD`. That catches +new hand-written posts and new marketing pages without demanding a 208-file +cleanup first. The banned-string ratchet in `check_banned_strings` already has +the right structure to copy. + +### P1-2: Page-template coverage is thin and unevenly distributed + +The 15 template unit tests parse 9 distinct HTML files, 57 of them +`index.html` (the homepage): + +``` +57 index.html +15 about-us/index.html + 4 test/components/diagram/index.html + 4 services/fractional-cto/index.html + 4 blog/devtools-for-ai-agents/index.html + 2 blog/index.html + 2 blog/4-lines-speed-up-your-rails-test-suite-on-circleci/index.html + 1 careers/index.html + 1 blog/tips-for-writing-readable-system-tests-in-rails-capybara-ruby/index.html +``` + +Of the 10 page templates in `themes/beaver/layouts/page/`, four +(`clients.html`, `use-cases.html`, `vibe-code-rescue.html`, +`contact-us.html`) are never parsed by any DOM-level test. They are covered by +screenshots only, which catch layout shifts but not missing links, empty +attributes, or dropped structured data. + +The system suite visits 25 URLs against a 727-page site. Per section: +services 4/13, use-cases 1/6, clients 1/6, course 2/82, lead-magnets 0/1. + +Fix (cheap, high value): the single-page templates are parameterized by +frontmatter, so one representative page per template is enough. Add DOM +assertions for `clients/`, `use-cases/`, `vibe-code-rescue/` and the +lead-magnet page. Prefer template unit tests (no browser, seconds) over new +screenshot tests, which cost wall-clock in every gate. + +### P1-3: `static/sw.js` is untested and sitewide in blast radius + +The service worker (72 lines) does network-first navigation with cache +fallback, deletes every cache not matching `CACHE_NAME`, and calls +`skipWaiting()` + `clients.claim()`. It is registered from +`baseof.html:166` on every page. + +A bug in the `activate` handler's cache filter, or a `respondWith` that +resolves to a bad response, serves stale or broken pages to every repeat +visitor - and no screenshot test exercises a second visit with a warm cache. + +Fix: the file is plain, dependency-free JS. A small Node test harness with a +faked `caches`/`fetch` and dispatched events covers the three handlers in well +under 100 lines. Alternatively, a system test that visits a page twice with the +SW registered, and asserts fresh content on the second load. + +### P2-1: `lib/sync/sources/sanity.rb` is 129 untested, unreferenced lines + +No file under `lib/`, `bin/`, `test/`, `config/`, or `data/` references it +except its own `require "sanity-ruby"`. It carries a real gem dependency +(`sanity-ruby` in the Gemfile). + +Fix: this is a delete-or-test decision, not a test-writing task. If the Sanity +source is not on the roadmap, removing it and the gem is the shorter diff and +drops a dependency. If it is planned, it needs a test file matching the +`test/unit/sync/` pattern before it is wired to anything. + +### P2-2: CI runs a narrower gate than the local docs imply + +What actually runs where: + +| Gate | pre-push | PR | push to master | +|---|---|---|---| +| `bin/lint-css` | yes | yes (`publish.yml`) | yes | +| `bin/validate-course` | yes | via `bin/hugo-build` | via build | +| `rake test:guards` | yes | - | - | +| `rake test:unit` | - | yes | yes | +| `rake test:critical` | - | yes (`test.yml`) | - | +| `rake test` (all, incl. integration + system) | - | - | yes | +| `rake test:links` | - | - | yes (post-deploy `workflow_run`) | +| `rake test:integration` | - | never as its own task | only inside `rake test` | +| `rake test:html_proofer` | - | - | **never invoked anywhere** | + +Two consequences: the asset-pipeline integration tests never gate a PR (they +run after merge, and skip themselves per P0-1), and `test:html_proofer` - +written specifically to catch missing image `src` files and malformed hash +fragments that lychee misses - is dead code in the Rakefile. + +**FIXED**: `link-check.yml` now runs `rake test:links test:html_proofer` as a +single invocation. One invocation, not two steps, because both tasks default to +the same `OUTPUT_DIR` and each used to trigger its own full production build - +the double build is what blew this job's timeout before (hence the workflow's +`build: 'false'`). `build_for_linkcheck` is now memoized per rake process so the +pair shares one build; the job timeout went 10 -> 15 minutes for html-proofer's +pass. `test:links` stays blocking, `test:html_proofer` stays non-blocking. + +`test:integration` now runs on PRs as its own `Asset Pipeline` job in +`publish.yml` - separate from `unit_tests` because it drives two full Hugo +builds of its own (~50s locally) and would push that job over its timeout. + +### P2-3: `bin/` has 29 executables and 5 shape guards + +`bin_scripts_test.rb` guards genuinely well-chosen things (the dash/`pipefail` +trap, the bare-production-`hugo` bypass, the multi-file `ruby` trap in the hook). +It does not execute any script. `bin/qtest`'s CSS-to-page mapping +(`PAGE_TESTS`, `COMPONENT_CONSUMERS`, `pages_for`) is pure, deterministic Ruby +that decides which tests run on every micro-commit - and a wrong mapping means +a change silently gets no gate at all. + +Fix: `pages_for` is directly unit-testable. Assert the escalate-to-`:all` +branch for site-wide files, and that every value in `PAGE_TESTS` matches at +least one real test name in `test/system/` (a stale key means the scoped run +tests nothing). + +--- + +## 3. Suggested order + +Ordered by risk removed per hour of work. + +1. ~~**P0-1 false greens**~~ - integration `flunk` DONE; the + conditional-assertion sweep is COMPLETE - all 61 candidates triaged + against the build, across 10 files (table in P0-1). It turned up four + live bugs. +2. ~~**P0-2 form field names**~~ - DONE, RED-verified. +3. ~~**P0-3 FAQ schema test**~~ - DONE, plus two dead schema files revived. +4. ~~**P2-2 CI wiring**~~ - DONE. +5. **P1-1 validator ratchet** for blog and marketing content. +6. **P1-2 template DOM tests** for the four unparsed page templates. +7. **P1-3 service worker** tests. +8. **P2-1 sanity.rb** delete-or-test decision. +9. **P2-3 `qtest` mapping** unit test. + +After items 1-4: `rake test:unit` is **275 runs / 6086 assertions / 0 +failures**, from 272 / 5723. The test count barely moved while assertions rose +by 363 - 16 added (4 lead-form, 6 FAQ, 3 breadcrumb, 3 service) and 13 removed +because they could not fail. That ratio is the point: the suite got smaller and +started checking more. `rake test:integration` is 11 runs / 0 failures, and +fails loudly on a broken build instead of skipping. + +Items 5-9 add coverage that does not exist yet. + +## 4. Not recommended + +- **Chasing `lib/` from 91.6% toward 100%.** The uncovered lines are mostly + network error branches in `dev_to_article_fetcher` and `dev_to_client`. The + sync job is scheduled, idempotent, and its failures are visible in Actions. + Low value per line. +- **More screenshot tests.** Every added screenshot test costs wall-clock in + `bin/qtest`, `bin/test`, and `bin/dtest`, and needs a baseline in both + `macos/` and `linux/`. Where a DOM assertion answers the question, use a + template unit test. +- **A line-coverage threshold in CI.** It would measure `lib/` (already + healthy) and say nothing about templates, content, or the false greens above, + which is where the actual risk sits. + +## 5. Reproducing the measurement + +SimpleCov is in the Gemfile (`gem "simplecov", require: false`) but is not +required anywhere, so no coverage is collected today. To reproduce: + +```ruby +# cov.rb (outside the repo) +require "simplecov" +SimpleCov.start do + root File.expand_path("") + enable_coverage :branch + skip "/test/" +end +``` + +```sh +RUBYOPT="-r/path/to/cov.rb" bundle exec ruby -Itest -Ilib \ + -e 'Dir["test/unit/sync/**/*_test.rb","test/unit/course_validators_test.rb"].each { |f| require File.expand_path(f) }' +``` + +The template unit tests cannot be measured this way: `test/base_page_test_case.rb` +shells out to `hugo` at load time (`test/support/hugo_helpers.rb:36`), so +`rake test:unit` needs the Hugo binary on `PATH` and produces coverage for +Ruby that only reads the built HTML. + +**Locale**: run the suite under a UTF-8 locale. `parse_html_file` reads with +`File.read` and no explicit encoding, so on a container where +`Encoding.default_external` is US-ASCII (no `LANG`), Nokogiri aborts with +`FATAL: Invalid bytes in character encoding`, every selector returns empty, and +73 template tests fail for a reason that has nothing to do with the templates. +`LANG=C.UTF-8` fixes it. Worth knowing before debugging a mass failure. + +### Re-running the conditional-assertion scan + +Lists every test whose shallowest assertion sits below the method's base +indent, i.e. every candidate for the P0-1 guard class: + +```sh +LANG=C.UTF-8 ruby -e ' +Dir["test/unit/**/*_test.rb"].sort.each do |f| + lines = File.readlines(f, encoding: "bom|utf-8") + cur = nil; asserts = []; out = [] + flush = lambda { out << "#{f}:#{cur[1]}:#{cur[0]}" if cur && !asserts.empty? && asserts.min > 4 } + lines.each_with_index do |l, i| + if l =~ /^ def (test_\w+)/ then flush.call; cur = [$1, i + 1]; asserts = [] + elsif l =~ /^(\s*)(assert\w*|refute\w*|flunk)\b/ then asserts << $1.length if cur + elsif l =~ /^ (def |end$|private)/ && cur then flush.call; cur = nil; asserts = [] + end + end + flush.call; puts out +end' +``` + +It over-reports: an `.each` over a literal array, or over a collection the test +already asserted non-empty, always runs its assertions. Check the built page +before classifying a hit. diff --git a/docs/20-29-testing-qa/README.md b/docs/20-29-testing-qa/README.md index 133d0c68f..eb3ac7c1e 100644 --- a/docs/20-29-testing-qa/README.md +++ b/docs/20-29-testing-qa/README.md @@ -15,6 +15,10 @@ This area contains all testing documentation, quality assurance procedures, and - `20.01-test-optimization-overview-reference.md` - Overview of test optimization strategies - `test-optimization/` - Detailed optimization guides, caching strategies, and implementation +### Coverage + +- `20.10-test-coverage-gap-analysis-reference.md` - measured coverage per layer, ranked gaps, and what not to bother testing + ### Screenshot Testing - `20.02-screenshot-testing-workflow-tutorial.md` - Screenshot testing workflow and procedures diff --git a/test/integration/hugo_pipeline_test.rb b/test/integration/hugo_pipeline_test.rb index d99cf671f..ab6aae4bc 100644 --- a/test/integration/hugo_pipeline_test.rb +++ b/test/integration/hugo_pipeline_test.rb @@ -15,7 +15,8 @@ # filenames, crossorigin=anonymous on script tags # # Builds run once per test class via class-level memoization (~7s each). -# All tests skip if either build fails. +# A failed build FAILS the suite (with the build output) rather than +# skipping it - see build_failure below. HUGO_PROJECT_ROOT = File.expand_path("../..", __dir__) HUGO_DEV_DIR = "/tmp/hugo-test-dev" @@ -24,31 +25,44 @@ class HugoPipelineIntegrationTest < Minitest::Test # -- One-time builds (class-level memoization) --------------------------- - def self.dev_ready? - @dev_ready ||= begin - FileUtils.rm_rf(HUGO_DEV_DIR) - system({ "HUGO_ENVIRONMENT" => "development", "BASE_URL" => "http://localhost:1313" }, - "hugo build --noBuildLock --environment development --destination #{HUGO_DEV_DIR}", - chdir: HUGO_PROJECT_ROOT, - %i[out err] => "/dev/null") - end - end - - def self.prod_ready? - @prod_ready ||= begin - FileUtils.rm_rf(HUGO_PROD_DIR) - system({ "HUGO_ENVIRONMENT" => "production", "BASE_URL" => "https://jetthoughts.com" }, - "hugo build --noBuildLock --environment production --destination #{HUGO_PROD_DIR}", - chdir: HUGO_PROJECT_ROOT, - %i[out err] => "/dev/null") - end + # Returns nil when the build is usable, else a diagnostic string. + # + # This used to `skip` the whole suite on a failed build. That is a false + # green: the asset pipeline is exactly what a broken build takes down, so + # the one regression these tests exist to catch reported zero failures + # with every test skipped. Fail loudly, and print the build output - a + # bare "build failed" sends you back to reproduce it by hand. + def self.build_failure(environment, dir, base_url) + @build_failures ||= {} + return @build_failures[dir] if @build_failures.key?(dir) + + FileUtils.rm_rf(dir) + log = "#{dir}.build.log" + ok = system({ "HUGO_ENVIRONMENT" => environment, "BASE_URL" => base_url }, + "hugo build --noBuildLock --environment #{environment} --destination #{dir}", + chdir: HUGO_PROJECT_ROOT, + %i[out err] => log) + output = File.exist?(log) ? File.read(log, encoding: "bom|utf-8") : "" + FileUtils.rm_f(log) + + @build_failures[dir] = + if ok.nil? + "`hugo` not found on PATH - install the pinned version (see .mise.toml) " \ + "before running the integration suite" + elsif !ok + "#{environment} Hugo build failed:\n#{output.lines.last(30).join}" + elsif !Dir.exist?(dir) + "#{environment} Hugo build reported success but wrote nothing to #{dir}" + end end def setup - skip "Dev Hugo build failed or unavailable" unless self.class.dev_ready? - skip "Prod Hugo build failed or unavailable" unless self.class.prod_ready? - skip "Dev output missing" unless Dir.exist?(HUGO_DEV_DIR) - skip "Prod output missing" unless Dir.exist?(HUGO_PROD_DIR) + problems = [ + self.class.build_failure("development", HUGO_DEV_DIR, "http://localhost:1313"), + self.class.build_failure("production", HUGO_PROD_DIR, "https://jetthoughts.com") + ].compact + + flunk problems.join("\n\n") if problems.any? end # -- HTML output helpers ------------------------------------------------- @@ -115,8 +129,8 @@ def test_css_content_is_minified_in_production dev_file = dev_css_files.first prod_file = prod_css_files.first - skip "No dev CSS file found" unless dev_file - skip "No prod CSS file found" unless prod_file + refute_nil dev_file, "Dev build emitted no homepage CSS bundle" + refute_nil prod_file, "Prod build emitted no homepage CSS bundle" dev_size = File.size(dev_file) prod_size = File.size(prod_file) diff --git a/test/unit/404_template_test.rb b/test/unit/404_template_test.rb index 01ec0ce98..53b78e800 100644 --- a/test/unit/404_template_test.rb +++ b/test/unit/404_template_test.rb @@ -8,9 +8,11 @@ class NotFoundTemplateTest < BasePageTestCase def setup @test_page = "404.html" - unless File.exist?("#{root_path}/#{@test_page}") - skip "404.html not found for testing" - end + # Was `skip "404.html not found for testing"`. The build silently + # dropping 404.html is the regression this file exists to catch, not a + # reason to report green on all 12 tests. + assert File.exist?("#{root_path}/#{@test_page}"), + "Build did not emit 404.html" end def test_404_page_has_error_title @@ -94,66 +96,16 @@ def test_404_page_provides_helpful_navigation end end - def test_404_page_has_search_functionality - doc = parse_html_file(@test_page) - - # Search helps users find what they're looking for - search_indicators = [ - doc.css("form[action*='search']").any?, - doc.css("input[type='search']").any?, - doc.css("input[name*='search']").any?, - doc.css(".search-form, .search-box").any? - ] - - # Search is helpful but not mandatory for 404 pages - # This is informational for UX improvement - search_present = search_indicators.any? - - if search_present - # If search is present, should be properly implemented - search_forms = doc.css("form") - search_forms.each do |form| - search_inputs = form.css("input[type='search'], input[name*='search']") - if search_inputs.any? - search_input = search_inputs.first - assert search_input["name"], "Search input should have name attribute" - end - end - end - end - - def test_404_page_suggests_popular_content - doc = parse_html_file(@test_page) - - # Popular content suggestions help users find alternatives - suggestion_indicators = [ - doc.css(".popular, .recent, .featured").any?, - doc.css("ul li a, ol li a").length > 2, - doc.text.downcase.include?("popular"), - doc.text.downcase.include?("recent"), - doc.text.downcase.include?("might") - ] - - # Content suggestions improve UX but not mandatory - suggestions_present = suggestion_indicators.any? - - if suggestions_present - # If suggestions are present, links should be valid - suggestion_links = doc.css(".popular a, .recent a, .featured a, main ul a, main ol a") - suggestion_links.each do |link| - href = link["href"] - assert href, "Suggestion links should have href attribute" - - text = link.text.strip - assert text.length > 0, "Suggestion links should have descriptive text" - - if href && !href.start_with?("http") - assert href.start_with?("/", "#", "./", "../"), - "Internal suggestion links should use proper paths" - end - end - end - end + # test_404_page_has_search_functionality removed 2026-08-07: the site has + # no search, so `search_present` was always false and the test asserted + # nothing. Its own comments said search was "not mandatory" - a test that + # cannot fail is documentation, and belongs in a doc. + # + # test_404_page_suggests_popular_content removed for the same reason: its + # assertions sat behind a "suggestions improve UX but not mandatory" + # guard, so it passed whether or not the page offered any recovery links. + # test_404_page_provides_helpful_navigation below covers the real + # invariant (the page links somewhere useful). def test_404_page_meta_description doc = parse_html_file(@test_page) @@ -182,33 +134,24 @@ def test_404_page_meta_description def test_404_page_prevents_indexing doc = parse_html_file(@test_page) - # 404 pages should not be indexed by search engines + # The site deliberately serves "index, follow" here rather than noindex + # (link discovery). Whether that is the right SEO call is a product + # decision, so this pins the shape, not the policy: the directive must + # be present and meaningful. Losing the tag entirely IS a regression. robots_meta = doc.css("head meta[name='robots']").first + refute_nil robots_meta, "404 page should carry a robots meta tag" - if robots_meta - robots_content = robots_meta["content"].downcase - - # Should prevent indexing - however, some SEO strategies allow indexing for link discovery - indexing_prevented = robots_content.include?("noindex") || - robots_content.include?("none") - - # This is informational - some sites allow 404 indexing for SEO discovery - unless indexing_prevented - puts "INFO: 404 page allows indexing - consider noindex for traditional SEO approach" - end - else - puts "INFO: No robots meta tag found - 404 pages typically benefit from noindex directive" - end + robots_content = robots_meta["content"].downcase + assert robots_content.match?(/\b(no)?index\b|\bnone\b/), + "404 robots meta should state an indexing directive, got #{robots_content.inspect}" - # Canonical should not point to 404 page itself + # The canonical branch that used to follow only printed an INFO line + # either way, so it could not fail. Assert the tag exists and resolves; + # which URL it canonicalizes to stays a product decision. canonical_link = doc.css("head link[rel='canonical']").first - if canonical_link - href = canonical_link["href"] - # Note: Some 404 implementations may canonicalize to themselves for SEO reasons - if href.include?("404") - puts "INFO: 404 page canonical points to itself - consider alternative canonical strategy" - end - end + refute_nil canonical_link, "404 page should carry a canonical link" + refute canonical_link["href"].to_s.strip.empty?, + "404 canonical link should have a non-empty href" end def test_404_page_proper_http_status_context @@ -348,53 +291,33 @@ def test_404_page_contact_information doc.text.include?("@") ] - # Contact information is helpful but not mandatory - contact_present = contact_indicators.any? + assert contact_indicators.any?, + "404 page should offer a way to reach a human (contact link, mailto, or support copy)" - if contact_present - # If contact info is present, should be accessible - contact_links = doc.css("a[href*='contact'], a[href*='mailto:']") - contact_links.each do |link| - href = link["href"] - assert href, "Contact links should have href attribute" + contact_links = doc.css("a[href*='contact'], a[href*='mailto:']") + refute_empty contact_links, "404 page should link to contact" - text = link.text.strip - assert text.length > 0, "Contact links should have descriptive text" - end + contact_links.each do |link| + refute_nil link["href"], "Contact links should have href attribute" + refute link.text.strip.empty?, "Contact links should have descriptive text" end end def test_404_page_performance_considerations doc = parse_html_file(@test_page) - # 404 pages should load quickly - - # Minimize external resources - external_scripts = doc.css("script[src^='http']") - external_stylesheets = doc.css("link[rel='stylesheet'][href^='http']") - - external_scripts.length - external_stylesheets.length - - # 404 pages benefit from minimal external dependencies - # This is informational for performance optimization - - # Images should be optimized - images = doc.css("img") - images.each do |img| - alt = img["alt"] - assert !alt.nil?, "404 page images should have alt attributes" - - src = img["src"] - if src - # Large images on 404 pages should be avoided - # This is informational for performance - end + # The two `external_*.length` lines here evaluated a value and threw it + # away - no assertion, no effect. The invariant they were reaching for + # is real and currently holds: the 404 page pulls zero third-party + # scripts or stylesheets, so it renders even when a CDN is down. + assert_empty doc.css("script[src^='http']").map { |s| s["src"] }, + "404 page should not depend on third-party scripts" + assert_empty doc.css("link[rel='stylesheet'][href^='http']").map { |l| l["href"] }, + "404 page should not depend on third-party stylesheets" + + doc.css("img").each do |img| + refute_nil img["alt"], "404 page images should have alt attributes" end - - # Page should focus on core functionality - # Heavy JavaScript/animations may not be appropriate - # This is informational for UX/performance balance end def test_404_page_security_considerations @@ -414,18 +337,10 @@ def test_404_page_security_considerations end # Note: General business terms like "database design" in service descriptions are acceptable - - # External links should have security attributes - external_links = doc.css("a[href^='http']").reject do |link| - href = link["href"] - href.include?("jetthoughts.com") || href.include?("localhost") - end - - # Security attributes are good practice but not strictly required - external_links.each do |link| - link["rel"] - # External links benefit from rel="noopener noreferrer" - # This is informational for security enhancement - end + # + # The external-link loop that used to follow read `link["rel"]` and + # discarded it - no assertion. Removed rather than promoted: the site + # emits no third-party links on the 404 page (see the performance test + # above), so there is nothing to guard here yet. end end diff --git a/test/unit/asset_url_validation_test.rb b/test/unit/asset_url_validation_test.rb index 5acbf191d..ab72657a0 100644 --- a/test/unit/asset_url_validation_test.rb +++ b/test/unit/asset_url_validation_test.rb @@ -30,9 +30,15 @@ def test_css_files_use_relative_urls_with_fingerprinting css_links = doc.css('link[rel="stylesheet"]') css_urls = css_links.map { |link| link["href"] }.compact - # Should contain relative URLs with fingerprinting for CSS - css_urls.any? do |url| - assert url.match?(/\/css\/.*?\.[a-f0-9]*?(\.min)?\.css/), "Should have fingerprinted CSS assets: #{url}" + # Was `css_urls.any? do |url| assert ... end`. Two defects in one line: + # `any?` short-circuits on the first truthy block result, and `assert` + # returns true - so only the FIRST stylesheet was ever checked. With an + # empty list it checked nothing at all. + refute_empty css_urls, "Page should link stylesheets" + + css_urls.each do |url| + assert url.match?(/\/css\/.*?\.[a-f0-9]*?(\.min)?\.css/), + "Should have fingerprinted CSS assets: #{url}" end end diff --git a/test/unit/baseof_template_test.rb b/test/unit/baseof_template_test.rb index c7c6948dc..dbcdd70ab 100644 --- a/test/unit/baseof_template_test.rb +++ b/test/unit/baseof_template_test.rb @@ -5,29 +5,25 @@ class BaseofTemplateTest < BasePageTestCase # Validates security, accessibility, and architectural improvements # Implements TDD coverage per /knowledge/20.01-tdd-methodology-reference.md - def test_sri_integrity_implementation_for_mermaid - doc = parse_html_file("index.html") + # Mermaid loads only on pages that render a diagram, never on index.html. + MERMAID_PAGE = "blog/hidden-cost-poor-development-vendor-management-fix/index.html" + + # Mermaid is self-hosted from themes/beaver/static/js/vendor (2026-08-01), + # which retires the jsdelivr CDN + SRI pin this test used to assert. It + # ran on index.html, which never loads mermaid at all, so the whole body + # sat inside a guard that was permanently false - and every assertion in + # it described the CDN implementation that no longer exists. Same security + # intent, current implementation: the script must be same-origin, so no + # third party can swap it. + def test_mermaid_script_is_self_hosted + doc = parse_html_file(MERMAID_PAGE) - # Find Mermaid script tag mermaid_scripts = doc.css("script[src*='mermaid']") + refute_empty mermaid_scripts, "Mermaid page should load the mermaid bundle" - if mermaid_scripts.any? - mermaid_script = mermaid_scripts.first - src = mermaid_script["src"] - integrity = mermaid_script["integrity"] - crossorigin = mermaid_script["crossorigin"] - - # Validate SRI implementation per security requirements - assert src.include?("mermaid@11"), "Mermaid script should specify version 11" - refute_nil integrity, "Mermaid script must have integrity attribute for security" - assert integrity.start_with?("sha384-"), "Mermaid integrity must use SHA384 hash" - assert_equal "anonymous", crossorigin, "Mermaid script must have crossorigin=anonymous" - - # Validate hash format - hash_part = integrity.gsub("sha384-", "") - assert_match(/^[A-Za-z0-9+\/]+=*$/, hash_part, "Integrity hash must be valid base64") - assert hash_part.length >= 64, "SHA384 hash must be sufficiently long" - end + src = mermaid_scripts.first["src"] + assert src.start_with?("/"), "Mermaid must be served same-origin, got #{src.inspect}" + refute_match %r{^https?://}, src, "Mermaid must not be loaded from a third-party CDN" end def test_no_hardcoded_inline_css_styles @@ -53,24 +49,10 @@ def test_no_hardcoded_inline_css_styles "Previously hardcoded CSS (.logo-image-main, .skip-link, .sr-only) should be extracted to separate stylesheets" end - def test_logo_styles_in_external_css - # Validate that logo styles are properly loaded from theme-main.css - doc = parse_html_file("index.html") - - # Check if logo element exists (indicating styles should be loaded) - logo_elements = doc.css(".logo-image-main") - - if logo_elements.any? - # Should have external CSS that includes theme styles - css_links = doc.css("head link[rel='stylesheet']") - theme_css_loaded = css_links.any? do |link| - href = link["href"] - href && (href.include?("theme") || href.include?("main")) - end - - assert theme_css_loaded, "Logo styles should be loaded from external theme CSS file" - end - end + # test_logo_styles_in_external_css removed 2026-08-07: it guarded every + # assertion behind `doc.css(".logo-image-main").any?`, and that class no + # longer exists anywhere in themes/ or layouts/. Zero assertions ran, and + # the test reported green. def test_accessibility_skip_link doc = parse_html_file("index.html") @@ -87,10 +69,12 @@ def test_accessibility_skip_link def test_screen_reader_utilities_present doc = parse_html_file("index.html") - # Check for screen reader only elements + # Check for screen reader only elements. The presence assertion is the + # point: PurgeCSS dropped .sr-only from the production nav bundle once + # (2026-07-19), and an `each` over an empty set would have said nothing. sr_only_elements = doc.css(".sr-only") + refute_empty sr_only_elements, "Page should render screen-reader-only elements" - # Validate sr-only implementation if present sr_only_elements.each do |element| # Should have proper accessibility class assert element["class"].include?("sr-only"), @@ -151,11 +135,11 @@ def test_seo_meta_tags_from_partial # Check robots meta tag robots_meta = doc.css("head meta[name='robots']").first - if robots_meta - robots_content = robots_meta["content"] - assert robots_content.include?("index") || robots_content.include?("noindex"), - "Robots meta should specify indexing directive" - end + refute_nil robots_meta, "Page should have a robots meta tag" + + robots_content = robots_meta["content"] + assert robots_content.include?("index") || robots_content.include?("noindex"), + "Robots meta should specify indexing directive" end def test_open_graph_tags_present @@ -181,18 +165,14 @@ def test_twitter_card_meta_tags # Validate Twitter Card implementation twitter_card = doc.css("head meta[name='twitter:card']").first - if twitter_card - card_type = twitter_card["content"] - assert ["summary", "summary_large_image"].include?(card_type), - "Twitter card should use appropriate card type" - end + refute_nil twitter_card, "Page should have a twitter:card meta tag" + assert ["summary", "summary_large_image"].include?(twitter_card["content"]), + "Twitter card should use appropriate card type" twitter_site = doc.css("head meta[name='twitter:site']").first - if twitter_site - site_handle = twitter_site["content"] - assert site_handle.start_with?("@"), - "Twitter site should include @ handle" - end + refute_nil twitter_site, "Page should have a twitter:site meta tag" + assert twitter_site["content"].start_with?("@"), + "Twitter site should include @ handle" end def test_service_worker_registration @@ -203,38 +183,33 @@ def test_service_worker_registration script.text.include?("serviceWorker") end - if sw_scripts.any? - sw_script = sw_scripts.first - script_content = sw_script.text + refute_empty sw_scripts, "Page should register a service worker" - assert script_content.include?("navigator.serviceWorker"), - "Service worker should check for navigator support" - assert script_content.include?("register"), - "Service worker should call register method" - assert script_content.include?("/sw.js") || script_content.include?("sw.js"), - "Service worker should register sw.js file" - end + script_content = sw_scripts.first.text + assert script_content.include?("navigator.serviceWorker"), + "Service worker should check for navigator support" + assert script_content.include?("register"), + "Service worker should call register method" + assert script_content.include?("sw.js"), + "Service worker should register sw.js file" end + # Ran against index.html, which never loads mermaid - the guard was + # permanently false and no assertion ever executed. Points at a page that + # actually renders a diagram now. def test_mermaid_initialization_script - doc = parse_html_file("index.html") + doc = parse_html_file(MERMAID_PAGE) - # Check for Mermaid initialization when feature is enabled mermaid_scripts = doc.css("script").select do |script| script.text.include?("mermaid") end + refute_empty mermaid_scripts, "Mermaid page should carry mermaid scripts" - if mermaid_scripts.any? - init_script = mermaid_scripts.find do |script| - script.text.include?("initialize") - end - - refute_nil init_script, "Mermaid should have initialization script" + init_script = mermaid_scripts.find { |script| script.text.include?("initialize") } + refute_nil init_script, "Mermaid should have initialization script" - init_content = init_script.text - assert init_content.include?("startOnLoad"), - "Mermaid should initialize with startOnLoad option" - end + assert init_script.text.include?("startOnLoad"), + "Mermaid should initialize with startOnLoad option" end def test_favicon_and_manifest_links @@ -324,19 +299,12 @@ def test_security_headers_meta_tags # Check for security-related meta tags xua_compatible = doc.css("head meta[http-equiv='X-UA-Compatible']").first - if xua_compatible - assert_equal "IE=edge", xua_compatible["content"], - "X-UA-Compatible should use IE=edge" - end + refute_nil xua_compatible, "Page should have an X-UA-Compatible meta tag" + assert_equal "IE=edge", xua_compatible["content"], + "X-UA-Compatible should use IE=edge" - # Check for referrer policy if implemented - referrer_policy = doc.css("head meta[name='referrer']").first - if referrer_policy - valid_policies = ["no-referrer", "no-referrer-when-downgrade", "origin", - "origin-when-cross-origin", "same-origin", "strict-origin", - "strict-origin-when-cross-origin", "unsafe-url"] - assert valid_policies.include?(referrer_policy["content"]), - "Referrer policy should use valid value" - end + # The referrer-policy branch that used to live here was dead: the site + # emits no , so it asserted nothing. Add it back + # with a presence assertion if a referrer policy is ever shipped. end end diff --git a/test/unit/home_template_test.rb b/test/unit/home_template_test.rb index 8dbad59fa..1fc12c690 100644 --- a/test/unit/home_template_test.rb +++ b/test/unit/home_template_test.rb @@ -102,24 +102,14 @@ def test_homepage_contact_information_present def test_homepage_social_media_integration doc = parse_html_file("index.html") - # Check for social media links or sharing + # The homepage links out to four social profiles. The old guard meant + # dropping all four read the same as having them. social_links = doc.css("a[href*='facebook'], a[href*='twitter'], a[href*='linkedin'], a[href*='github']") - social_classes = doc.css(".social, .social-media, .social-links") + refute_empty social_links, "Homepage should link to the company social profiles" - # Social media is optional but if present should be properly implemented - if social_links.any? || social_classes.any? - social_links.each do |link| - href = link["href"] - assert href.start_with?("http"), - "Social media links should use full URLs" - - # Should open in new tab/window for external links - link["target"] - if href.start_with?("http") && !href.include?("jetthoughts.com") - # External social links should ideally open in new tab - # This is a recommendation, not a strict requirement - end - end + social_links.each do |link| + assert link["href"].start_with?("http"), + "Social media links should use full URLs" end end @@ -135,19 +125,11 @@ def test_homepage_performance_critical_elements assert !alt.nil?, "Images should have alt attributes" end - # Check for lazy loading on images - large_images = images.select { |img| - src = img["src"] - src && (src.include?("hero") || src.include?("banner") || src.include?("large")) - } - - # Large images benefit from lazy loading (optional optimization) - if large_images.any? - large_images.any? { |img| - img["loading"] == "lazy" || img["data-src"] - } - # Note: Lazy loading is an optimization, not a requirement - end + # The lazy-loading block that used to follow computed `large_images.any? + # { ... }` and discarded the result - no assertion either way. Dropped + # rather than promoted: whether a given hero should be lazy-loaded is a + # per-image LCP decision, not an invariant. bin/lighthouse is the tool + # for that question. end def test_homepage_structured_data_organization @@ -163,37 +145,23 @@ def test_homepage_structured_data_organization false end - if organization_schemas.any? - org_data = JSON.parse(organization_schemas.first.text) - - assert_schema_context(org_data) - assert_schema_fields(org_data, "@type", "name") - assert_equal "Organization", org_data["@type"] - assert org_data["name"].length > 0, "Organization should have name" - - # Optional but recommended fields - if org_data["url"] - assert_valid_url(org_data["url"], "Organization URL should be valid") - end - end - end - - def test_homepage_breadcrumb_handling - doc = parse_html_file("index.html") + refute_empty organization_schemas, "Homepage should publish Organization schema" - # Homepage typically doesn't need breadcrumbs, but if present should be minimal - breadcrumbs = doc.css(".breadcrumb, .breadcrumbs, nav[aria-label*='breadcrumb']") + org_data = JSON.parse(organization_schemas.first.text) - if breadcrumbs.any? - # If breadcrumbs exist on homepage, should be simple - breadcrumb_links = breadcrumbs.css("a") + assert_schema_context(org_data) + assert_schema_fields(org_data, "@type", "name") + assert_equal "Organization", org_data["@type"] + assert org_data["name"].length > 0, "Organization should have name" - # Homepage breadcrumbs should be minimal (typically just "Home") - assert breadcrumb_links.length <= 2, - "Homepage breadcrumbs should be minimal" - end + refute_nil org_data["url"], "Organization schema should carry a url" + assert_valid_url(org_data["url"], "Organization URL should be valid") end + # test_homepage_breadcrumb_handling removed 2026-08-07: it guarded on + # `.breadcrumb/.breadcrumbs` elements, which the homepage does not render + # (and should not - a homepage is the breadcrumb root). Zero assertions ran. + def test_homepage_call_to_action_elements doc = parse_html_file("index.html") @@ -227,9 +195,9 @@ def test_homepage_mobile_responsiveness_indicators assert content.include?("width=device-width"), "Viewport should include device-width for mobile responsiveness" - # Check for responsive CSS classes (optional but common) - doc.css(".container, .row, .col, .mobile, .tablet, .desktop") - # Note: Responsive classes are optional as CSS frameworks vary + # A `doc.css(...)` line whose result was discarded used to sit here. + # Removed: which utility classes a CSS framework emits is not an + # invariant, and the visual suites cover responsive rendering. end def test_homepage_loading_performance_optimization @@ -239,71 +207,32 @@ def test_homepage_loading_performance_optimization # Preload critical resources preload_links = doc.css("head link[rel='preload']") - preload_links.each do |link| - as_attr = link["as"] - assert as_attr, "Preload links should specify resource type with 'as' attribute" - end - - # DNS prefetch for external resources - dns_prefetch = doc.css("head link[rel='dns-prefetch']") - preconnect = doc.css("head link[rel='preconnect']") + refute_empty preload_links, "Homepage should preload its critical CSS" - # External resources benefit from DNS optimization (optional) - external_resources = doc.css("script[src^='http'], link[href^='http']") - if external_resources.any? && (dns_prefetch.any? || preconnect.any?) - # Good practice: DNS optimization for external resources - end - end - - def test_homepage_security_headers_integration - doc = parse_html_file("index.html") - - # Check for Content Security Policy meta tag (if implemented) - csp_meta = doc.css("head meta[http-equiv='Content-Security-Policy']").first - - if csp_meta - csp_content = csp_meta["content"] - assert csp_content.length > 10, "CSP should have meaningful policy" - assert csp_content.include?("default-src") || csp_content.include?("script-src"), - "CSP should include security directives" + preload_links.each do |link| + refute_nil link["as"], "Preload links should specify resource type with 'as' attribute" end - # Check for other security-related meta tags - xframe_options = doc.css("head meta[http-equiv='X-Frame-Options']").first - if xframe_options - valid_values = ["DENY", "SAMEORIGIN"] - assert valid_values.include?(xframe_options["content"]), - "X-Frame-Options should use DENY or SAMEORIGIN" - end + # The DNS-prefetch block that used to follow had an empty `if` body - it + # asserted nothing whether or not the optimization was present. Replaced + # with the invariant that actually holds and matters: the homepage loads + # zero third-party scripts or stylesheets, which is why it needs no DNS + # optimization in the first place (fonts and mermaid are self-hosted). + assert_empty doc.css("script[src^='http']").map { |s| s["src"] }, + "Homepage should not load third-party scripts" + assert_empty doc.css("link[rel='stylesheet'][href^='http']").map { |l| l["href"] }, + "Homepage should not load third-party stylesheets" end - def test_homepage_analytics_integration - doc = parse_html_file("index.html") - - # Check for analytics integration (Google Analytics, etc.) - analytics_scripts = doc.css("script").select do |script| - content = script.text - src = script["src"] - content.include?("google-analytics") || - content.include?("gtag") || - content.include?("analytics") || - (src && (src.include?("google-analytics") || src.include?("gtag"))) - end + # test_homepage_security_headers_integration removed 2026-08-07: it guarded + # every assertion on a the site + # does not emit. CSP belongs in response headers, not a meta tag, so this + # would not be the place to assert it even once a policy ships. - # Analytics is optional but if present should be properly configured - if analytics_scripts.any? - # Basic validation that analytics code exists - analytics_scripts.each do |script| - if script["src"] - assert script["src"].start_with?("http"), - "Analytics scripts should use proper URLs" - else - assert script.text.length > 20, - "Inline analytics scripts should have meaningful content" - end - end - end - end + # test_homepage_analytics_integration removed 2026-08-07: the test build + # emits no analytics script at all (page/analytics.html is environment + # gated), so the guard never opened. Asserting analytics in a test build + # would pin the wrong environment; leave it to the production build. def test_homepage_accessibility_landmarks doc = parse_html_file("index.html") diff --git a/test/unit/hugo_asset_validation_test.rb b/test/unit/hugo_asset_validation_test.rb index 38e1d6094..f6505eb31 100644 --- a/test/unit/hugo_asset_validation_test.rb +++ b/test/unit/hugo_asset_validation_test.rb @@ -72,19 +72,18 @@ def test_image_assets_use_relative_urls doc = parse_html_file("index.html") img_elements = doc.css("img[src*='.jpg'], img[src*='.png'], img[src*='.gif'], img[src*='.webp']") + refute_empty img_elements, "Homepage should render images" - if img_elements.any? - img_elements.each do |img| - src = img["src"] - next unless src - - # Should use relative paths or correct localhost - if src.start_with?("http://", "https://") - uri = URI.parse(src) - assert_equal EXPECTED_HOST, uri.host, "Image should use correct host: #{src}" - else - assert src.start_with?("./", "/"), "Image should use relative path: #{src}" - end + img_elements.each do |img| + src = img["src"] + next unless src + + # Should use relative paths or correct localhost + if src.start_with?("http://", "https://") + uri = URI.parse(src) + assert_equal EXPECTED_HOST, uri.host, "Image should use correct host: #{src}" + else + assert src.start_with?("./", "/"), "Image should use relative path: #{src}" end end end diff --git a/test/unit/hugo_partials_test.rb b/test/unit/hugo_partials_test.rb index 43af69769..5dbec7e95 100644 --- a/test/unit/hugo_partials_test.rb +++ b/test/unit/hugo_partials_test.rb @@ -4,6 +4,10 @@ class HugoPartialsTest < BasePageTestCase # Unit tests for Hugo partial templates functionality and rendering # Tests rendered HTML output from public-test directory (no server required) + # Partials that only render on a post (share, article schema) need a real + # post, not the blog list page. + BLOG_POST = "blog/devtools-for-ai-agents/index.html" + def test_header_partial_rendering doc = parse_html_file("index.html") @@ -87,6 +91,8 @@ def test_asset_processing_partials # CSS processor partial results css_files = doc.css("head link[rel='stylesheet']") + refute_empty css_files, "Homepage should link processed stylesheets" + css_files.each do |link| href = link["href"] next if href&.start_with?("http") @@ -98,6 +104,8 @@ def test_asset_processing_partials # JavaScript files if any js_files = doc.css("script[src]").select { |s| !s["src"]&.start_with?("http") } + refute_empty js_files, "Homepage should load processed local scripts" + js_files.each do |script| src = script["src"] @@ -107,81 +115,67 @@ def test_asset_processing_partials end end + # Selected with "article, .post-item, .blog-posts article" - a list that + # omits `.blog-post`, the only class the blog index renders. It matched + # zero articles on every run and `if articles.any?` made that a pass. Same + # defect as list_template_test's date check. def test_blog_post_partials - # Skip if blog directory doesn't exist - return unless File.exist?(File.join(root_path, "blog/index.html")) - doc = parse_html_file("blog/index.html") - # Blog listing partial structure - articles = doc.css("article, .post-item, .blog-posts article") - if articles.any? - # Test first few articles - articles.first(3).each do |article| - # Each post should have title - headings = article.css("h1, h2, h3, .title, .heading") - assert headings.any?, "Blog post should have heading" - - # Publication date - dates = article.css("time, .date, .post-date") - assert dates.any?, "Blog post should show publication date" - end + articles = doc.css("article, .post-item, .blog-post, .blog-posts article") + refute_empty articles, "Blog index should render post entries" + + articles.first(3).each do |article| + assert article.css("h1, h2, h3, .title, .heading").any?, + "Blog post should have heading" + assert article.css("time, .date, .post-date").any?, + "Blog post should show publication date" end end + # Checked index.html and about-us/index.html, neither of which renders + # blog/share.html - so `next if social_share.empty?` skipped both and the + # test asserted nothing on any run. The partial lives on blog posts. def test_social_sharing_partial - # Check on a specific page that likely has social sharing - pages_to_check = ["index.html", "about-us/index.html"] - - pages_to_check.each do |page| - next unless File.exist?(File.join(root_path, page)) + doc = parse_html_file(BLOG_POST) - doc = parse_html_file(page) - social_share = doc.css(".social-share") + social_share = doc.css(".social-share") + refute_empty social_share, "Blog post should render the social share partial" - next if social_share.empty? + social_links = social_share.css("a") + assert social_links.count >= 2, "Should have multiple social sharing options" - # Social sharing links - social_links = social_share.css("a") - assert social_links.count >= 2, "Should have multiple social sharing options" + social_links.each do |link| + assert_equal "_blank", link["target"], + "Social links should open in new tab" + assert link["rel"]&.include?("noopener"), + "Social links should have security attributes" - social_links.each do |link| - # Proper attributes for social sharing - assert_equal "_blank", link["target"], - "Social links should open in new tab" - assert link["rel"]&.include?("noopener"), - "Social links should have security attributes" + aria_label = link["aria-label"] + title = link["title"] + assert aria_label&.length&.positive? || title&.length&.positive?, + "Social links should have accessibility labels" - # Accessibility - aria_label = link["aria-label"] - title = link["title"] - assert aria_label&.length&.positive? || title&.length&.positive?, - "Social links should have accessibility labels" - - # Should have SVG icons - assert link.css("svg").any?, "Social links should use SVG icons" - end - break # Test only the first page that has social sharing + assert link.css("svg").any?, "Social links should use SVG icons" end end def test_critical_css_partial doc = parse_html_file("index.html") - # Check for critical CSS inlined in head + # Check for critical CSS inlined in head. Losing the inline block is a + # first-paint regression, which is exactly what the old guard hid. inline_styles = doc.css("head style") + refute_empty inline_styles, "Homepage should inline its critical CSS" - if inline_styles.any? - # Critical CSS should be inlined - critical_css = inline_styles.first.text + critical_css = inline_styles.first.text - # Should contain basic layout and typography styles - assert critical_css.include?("body") || critical_css.include?("."), - "Critical CSS should contain actual CSS rules" + # Should contain basic layout and typography styles + assert critical_css.include?("body") || critical_css.include?("."), + "Critical CSS should contain actual CSS rules" - # Accept both formatted and minified CSS - Hugo may include readable critical styles - # This is acceptable as critical CSS serves different purposes - end + # Accept both formatted and minified CSS - Hugo may include readable + # critical styles; that serves a different purpose and is fine. end def test_favicon_partial @@ -200,87 +194,53 @@ def test_favicon_partial assert doc.css("head meta[name='theme-color']").any?, "Should have theme color meta tag" end - def test_analytics_partial - doc = parse_html_file("index.html") - - # Google Analytics or similar tracking - gtag_scripts = doc.css("script").select do |s| - s.text.include?("gtag") || s["src"]&.include?("googletagmanager") - end - - if gtag_scripts.any? - # Should have proper privacy settings - analytics_script = gtag_scripts.find { |s| s.text.include?("gtag") } - if analytics_script - script_content = analytics_script.text - - # Should have privacy-compliant settings - assert script_content.include?("consent") || script_content.include?("analytics_storage"), - "Analytics should include privacy consent handling" - end - end - end + # test_analytics_partial removed 2026-08-07: page/analytics.html is + # environment-gated, so the test build emits no gtag script and both + # guards were permanently false. Its intent - "if analytics ships, it must + # carry consent handling" - is worth keeping, but it has to run against a + # production build to mean anything. Nothing here asserted that. + # The `return unless File.exist?` and `if breadcrumb_script` guards both + # made a missing breadcrumb indistinguishable from a valid one. def test_breadcrumb_partial_json_ld - # Skip if about-us page doesn't exist - return unless File.exist?(File.join(root_path, "about-us/index.html")) - doc = parse_html_file("about-us/index.html") - # Look for breadcrumb structured data json_ld_scripts = doc.css('script[type="application/ld+json"]') breadcrumb_script = json_ld_scripts.find { |s| s.text.include?("BreadcrumbList") } + refute_nil breadcrumb_script, "about-us should publish BreadcrumbList structured data" - if breadcrumb_script - breadcrumb_data = JSON.parse(breadcrumb_script.text) + breadcrumb_data = JSON.parse(breadcrumb_script.text) - assert_equal "BreadcrumbList", breadcrumb_data["@type"], - "Breadcrumb should have correct schema type" + assert_equal "BreadcrumbList", breadcrumb_data["@type"], + "Breadcrumb should have correct schema type" - items = breadcrumb_data["itemListElement"] - assert items.is_a?(Array) && items.count >= 1, - "Breadcrumb should have list items" + items = breadcrumb_data["itemListElement"] + assert items.is_a?(Array) && items.count >= 1, + "Breadcrumb should have list items" - # First item should be homepage - first_item = items.first - assert_equal "Home", first_item["name"], - "First breadcrumb item should be Home" - end + assert_equal "Home", items.first["name"], + "First breadcrumb item should be Home" end + # Walked three candidate pages and `next`ed past any that lacked the + # schema, so all three coming up empty read the same as one succeeding. + # A service page must publish it; assert against the canonical one. def test_service_schema_partial - # Check multiple potential service pages - service_pages = [ - "services/fractional-cto/index.html", - "services/index.html", - "index.html" - ] - - service_pages.each do |page| - next unless File.exist?(File.join(root_path, page)) - - doc = parse_html_file(page) - - # Look for service-related structured data - json_ld_scripts = doc.css('script[type="application/ld+json"]') - service_script = json_ld_scripts.find do |s| - s.text.include?("Service") || s.text.include?("Organization") - end + doc = parse_html_file("services/fractional-cto/index.html") - next unless service_script + json_ld_scripts = doc.css('script[type="application/ld+json"]') + service_script = json_ld_scripts.find do |s| + s.text.include?("Service") || s.text.include?("Organization") + end + refute_nil service_script, "Service page should publish Service/Organization schema" - service_data = JSON.parse(service_script.text) + service_data = JSON.parse(service_script.text) - # Should have schema.org context - assert service_data["@context"]&.include?("schema.org"), - "Service schema should use schema.org context" + assert service_data["@context"]&.include?("schema.org"), + "Service schema should use schema.org context" - # Should have organization or service type - type = service_data["@type"] - assert ["Service", "Organization", "LocalBusiness"].include?(type), - "Should have appropriate schema type" - break # Test only the first page that has service schema - end + assert ["Service", "Organization", "LocalBusiness"].include?(service_data["@type"]), + "Should have appropriate schema type" end def test_performance_partial_integration @@ -290,28 +250,23 @@ def test_performance_partial_integration # Preload critical resources preload_links = doc.css("head link[rel='preload']") + refute_empty preload_links, "Homepage should preload critical resources" + preload_links.each do |link| - as_attr = link["as"] - assert %w[style script font image].include?(as_attr), + assert %w[style script font image].include?(link["as"]), "Preload links should specify resource type" end - # DNS prefetch for external resources - prefetch_links = doc.css("head link[rel='preconnect'], head link[rel='dns-prefetch']") - prefetch_links.each do |link| - href = link["href"] - # Allow external URLs and protocol-relative URLs (starting with //) - valid_prefetch = href&.start_with?("http", "/", "//") - assert valid_prefetch, - "Prefetch should be for external domains or CDN paths, got: #{href}" - end + # The site emits no preconnect/dns-prefetch at all - fonts and mermaid + # are self-hosted - so the loop that used to sit here iterated an empty + # set. Assert the reason it is empty instead. + assert_empty doc.css("script[src^='http']").map { |s| s["src"] }, + "Homepage should not load third-party scripts" # Service worker registration sw_scripts = doc.css("script").select { |s| s.text.include?("serviceWorker") } - if sw_scripts.any? - sw_script = sw_scripts.first.text - assert sw_script.include?("register"), - "Service worker should be registered" - end + refute_empty sw_scripts, "Homepage should register a service worker" + assert sw_scripts.first.text.include?("register"), + "Service worker should be registered" end end diff --git a/test/unit/lead_forms_test.rb b/test/unit/lead_forms_test.rb new file mode 100644 index 000000000..617a13b1a --- /dev/null +++ b/test/unit/lead_forms_test.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require_relative "../base_page_test_case" + +# The lead-gen forms post to Google Forms, whose fields are addressed by +# opaque `entry.` names supplied through site params +# (`[params.forms.*]` in config/_default/hugo.toml). Hugo renders a missing +# param as the empty string WITHOUT failing the build, so a renamed or +# dropped param yields a form that still renders, still matches the +# screenshot baselines pixel for pixel, and silently submits blank fields. +# The existing system tests visit these pages and screenshot them; nothing +# read the DOM until this file. +# +# Assertions are on shape (non-empty name, absolute https action), never on +# the entry ids or the endpoint URL - those are tunable config. +class LeadFormsTest < BasePageTestCase + FUNNEL_PAGES = [ + "contact-us/index.html", + "free-consultation/index.html" + ].freeze + + def test_funnel_forms_post_to_an_absolute_endpoint + FUNNEL_PAGES.each do |page| + action = first_form(page)["action"].to_s + + assert_match %r{\Ahttps://\S+}, action, + "#{page}: form action must be an absolute https endpoint (got #{action.inspect}). " \ + "An empty action posts back to the page itself and the lead is lost." + end + end + + def test_funnel_form_fields_all_carry_a_name + FUNNEL_PAGES.each do |page| + fields = submittable_fields(first_form(page)) + refute_empty fields, "#{page}: form renders no submittable fields" + + unnamed = fields.select { |field| field["name"].to_s.strip.empty? } + + assert_empty unnamed.map { |field| field["id"] || field.name }, + "#{page}: these fields render name=\"\" and submit nothing. " \ + "Check [params.forms.contact] in config/_default/hugo.toml - Hugo renders " \ + "a missing param as the empty string without failing the build." + end + end + + def test_funnel_forms_collect_the_expected_field_types + FUNNEL_PAGES.each do |page| + types = submittable_fields(first_form(page)).map { |field| field_type(field) } + + %w[text email tel textarea].each do |type| + assert_includes types, type, "#{page}: form should collect a #{type} field" + end + end + end + + def test_career_form_field_carries_a_name + fields = submittable_fields(first_form("careers/index.html")) + refute_empty fields, "careers page: form renders no submittable fields" + + unnamed = fields.select { |field| field["name"].to_s.strip.empty? } + + assert_empty unnamed.map { |field| field["id"] || field.name }, + "careers page: fields render name=\"\" - check [params.forms.career] " \ + "in config/_default/hugo.toml" + end + + private + + def first_form(page) + form = parse_html_file(page).css("form").first + refute_nil form, "#{page} should render a form" + form + end + + # Submit buttons carry no name by design; everything else that posts does. + def submittable_fields(form) + form.css("input, textarea, select").reject do |field| + %w[submit button image reset].include?(field["type"]) + end + end + + def field_type(field) + (field.name == "textarea") ? "textarea" : field["type"] + end +end diff --git a/test/unit/list_template_test.rb b/test/unit/list_template_test.rb index 978b99d69..1867164fc 100644 --- a/test/unit/list_template_test.rb +++ b/test/unit/list_template_test.rb @@ -5,6 +5,12 @@ class ListTemplateTest < BasePageTestCase # Validates archive/category page functionality, pagination, and content listing # Implements TDD coverage per /knowledge/20.01-tdd-methodology-reference.md + # One selector, used by every test that walks the listed posts. Before + # this, test_list_page_date_information used a list missing `.blog-post` - + # the ONLY class the page actually renders - so it matched zero items on + # every run, and its `if items.any?` guard made that look like a pass. + ITEM_SELECTOR = "article, .post, .post-item, .entry, .blog-post" + def setup # Test with blog list page or category pages @test_pages = [ @@ -13,7 +19,10 @@ def setup "tags/index.html" ].select { |page| File.exist?("#{root_path}/#{page}") } - skip "No list pages found for testing" if @test_pages.empty? + # Was `skip "No list pages found"`. The blog index is a core page; if the + # build stops emitting every list page, that is the regression, not a + # reason to report green on 13 tests. + refute_empty @test_pages, "Build emitted no list pages at all (blog/, categories/, tags/)" @test_page = @test_pages.first end @@ -71,36 +80,36 @@ def test_list_page_item_structure doc = parse_html_file(@test_page) # Find list items (posts, articles) - items = doc.css("article, .post, .post-item, .entry, .blog-post") - - if items.any? - # Test first few items - items.first(3).each_with_index do |item, index| - # Each item should have a heading or title - item_headings = item.css("h1, h2, h3, h4, .title, .heading") - assert item_headings.any?, - "List item #{index + 1} should have a heading or title" - - # Each item should have some content or excerpt - content_indicators = [ - item.css("p").any?, - item.css(".excerpt, .summary, .content").any?, - item.text.strip.length > 50 - ] - - assert content_indicators.any?, - "List item #{index + 1} should have content, excerpt, or substantial text" - - # Links should be properly formatted - item_links = item.css("a") - item_links.each do |link| - href = link["href"] - assert href, "Item links should have href attribute" - - if href && !href.start_with?("http", "mailto:", "tel:") - assert href.start_with?("/", "#", "./", "../"), - "Internal item links should use proper relative paths" - end + items = doc.css(ITEM_SELECTOR) + + # A blog index that lists nothing is the headline failure for this page, + # and the old `if items.any?` guard made it the quietest. + refute_empty items, "List page should render post items" + + items.first(3).each_with_index do |item, index| + # Each item should have a heading or title + item_headings = item.css("h1, h2, h3, h4, .title, .heading") + assert item_headings.any?, + "List item #{index + 1} should have a heading or title" + + # Each item should have some content or excerpt + content_indicators = [ + item.css("p").any?, + item.css(".excerpt, .summary, .content").any?, + item.text.strip.length > 50 + ] + + assert content_indicators.any?, + "List item #{index + 1} should have content, excerpt, or substantial text" + + # Links should be properly formatted + item.css("a").each do |link| + href = link["href"] + assert href, "Item links should have href attribute" + + if href && !href.start_with?("http", "mailto:", "tel:") + assert href.start_with?("/", "#", "./", "../"), + "Internal item links should use proper relative paths" end end end @@ -127,283 +136,90 @@ def test_list_page_meta_description # Informational - helps with SEO but not strictly required end - def test_list_page_pagination_if_present + # The blog index IS paginated (204 paginator pages), so the old + # "_if_present" guard was hiding a real invariant behind an optional one. + def test_list_page_pagination doc = parse_html_file(@test_page) - # Look for pagination elements pagination_elements = doc.css(".pagination, .pager, .page-navigation, nav[aria-label*='pagination']") + refute_empty pagination_elements, "Blog list page should render pagination" - if pagination_elements.any? - pagination = pagination_elements.first - - # Pagination should have proper structure - page_links = pagination.css("a") - page_numbers = pagination.css(".page-number, .current, .active") - - # Should have navigation links or page numbers - assert page_links.any? || page_numbers.any?, - "Pagination should contain navigation links or page numbers" + pagination = pagination_elements.first + page_links = pagination.css("a") - # Pagination links should be valid - page_links.each do |link| - href = link["href"] - assert href, "Pagination links should have href attribute" + assert page_links.any? || pagination.css(".page-number, .current, .active").any?, + "Pagination should contain navigation links or page numbers" - # Should be relative URLs for same site - if href && !href.start_with?("http") - assert href.start_with?("/", "#", "./", "../"), - "Pagination links should use proper relative paths" - end - end + page_links.each do |link| + href = link["href"] + assert href, "Pagination links should have href attribute" - # Check for accessibility attributes - if pagination["aria-label"] - assert pagination["aria-label"].downcase.include?("pagination"), - "Pagination should have descriptive aria-label" + if href && !href.start_with?("http") + assert href.start_with?("/", "#", "./", "../"), + "Pagination links should use proper relative paths" end end end - def test_list_page_filtering_or_sorting_if_present + # Removed 2026-08-07. Each guarded every assertion behind a feature this + # site does not render, so none of them could fail: + # + # test_list_page_filtering_or_sorting_if_present - no .filter / .sort / + # select[name*=sort] anywhere in the build. + # test_list_page_rss_feed_link - the list page carries no + # application/rss+xml link and no *.xml link at all. (Whether it SHOULD + # is a separate SEO question; a test that passes either way answers it + # for nobody.) + # test_list_page_search_functionality_if_present - the site has no search. + # test_list_page_breadcrumb_navigation - no .breadcrumb/.breadcrumbs + # elements. The JSON-LD BreadcrumbList the page DOES emit is covered by + # test/unit/meta_tags/breadcrumb_schema_test.rb. + # test_list_page_author_information_if_present - no .author elements. + # test_list_page_category_tag_information - no .category/.tag elements. + # + # Bring any of them back with a presence assertion on the day the feature + # ships, not before. + + # The list page emits Organization + Article + BreadcrumbList, NOT Blog or + # CollectionPage - so the old version of this test selected an empty set + # and skipped every assertion. Assert what the page actually publishes: + # each JSON-LD block must be parseable and typed. Malformed JSON-LD fails + # silently in production (Google drops the result, the page renders fine). + def test_list_page_structured_data_is_valid doc = parse_html_file(@test_page) + scripts = extract_json_ld_schemas(doc) - # Look for filter or sort controls - filter_elements = doc.css(".filter, .sort, .category-filter, .tag-filter") - sort_elements = doc.css(".sort-by, .order-by, select[name*='sort']") + refute_empty scripts, "List page should publish JSON-LD structured data" - # If filtering/sorting exists, should be properly implemented - if filter_elements.any? || sort_elements.any? - # Filter links should be properly formatted - filter_links = doc.css(".filter a, .category-filter a, .tag-filter a") - filter_links.each do |link| - href = link["href"] - assert href, "Filter links should have href attribute" - end - - # Sort controls should have proper form attributes - sort_selects = doc.css("select[name*='sort']") - sort_selects.each do |select| - options = select.css("option") - assert options.length > 1, "Sort select should have multiple options" - end - end - end - - def test_list_page_rss_feed_link - doc = parse_html_file(@test_page) - - # RSS feed link for list pages - rss_links = doc.css("head link[type='application/rss+xml'], head link[href*='.xml']") - - if rss_links.any? - rss_links.each do |link| - href = link["href"] - assert href, "RSS links should have href attribute" - - title = link["title"] - # RSS links benefit from descriptive titles - if title - assert title.length > 3, "RSS link should have descriptive title" - end - end - end - end - - def test_list_page_structured_data_blog - doc = parse_html_file(@test_page) - - # Look for Blog or CollectionPage schema - json_scripts = extract_json_ld_schemas(doc) - - blog_schemas = json_scripts.select do |script| - data = JSON.parse(script.text) - data.is_a?(Hash) && (data["@type"] == "Blog" || data["@type"] == "CollectionPage") - rescue JSON::ParserError - false - end - - # Blog schema is optional but if present should be valid - if blog_schemas.any? - blog_data = JSON.parse(blog_schemas.first.text) - - assert_schema_context(blog_data) - assert_schema_fields(blog_data, "@type", "name") - - valid_types = ["Blog", "CollectionPage"] - assert valid_types.include?(blog_data["@type"]), - "List page schema should be Blog or CollectionPage" - - if blog_data["name"] - assert blog_data["name"].length > 0, "Blog should have name" - end - - # Check for blogPost items if it's a Blog - if blog_data["@type"] == "Blog" && blog_data["blogPost"] - assert blog_data["blogPost"].is_a?(Array), - "blogPost should be an array" - end - end - end - - def test_list_page_breadcrumb_navigation - doc = parse_html_file(@test_page) - - # Breadcrumbs are helpful for list pages - breadcrumbs = doc.css(".breadcrumb, .breadcrumbs, nav[aria-label*='breadcrumb']") - - if breadcrumbs.any? - breadcrumb_links = breadcrumbs.css("a") - - breadcrumb_links.each do |link| - href = link["href"] - assert href, "Breadcrumb links should have href" - - text = link.text.strip - assert text.length > 0, "Breadcrumb links should have descriptive text" - - if href && !href.start_with?("http") - assert href.start_with?("/", "#", "./", "../"), - "Internal breadcrumb links should use proper paths" - end - end - - # Should show hierarchy (Home > Blog, etc.) - breadcrumb_text = breadcrumbs.text - hierarchy_indicators = [">", "/", "»", "→"] - hierarchy_indicators.any? { |indicator| - breadcrumb_text.include?(indicator) - } - - # Hierarchy indicators help user orientation but not required - end - end - - def test_list_page_search_functionality_if_present - doc = parse_html_file(@test_page) - - # Look for search form - search_forms = doc.css("form[action*='search'], form .search") - search_inputs = doc.css("input[type='search'], input[name*='search'], input[placeholder*='search']") - - if search_forms.any? || search_inputs.any? - # Search forms should be properly implemented - search_forms.each do |form| - action = form["action"] - method = form["method"] - - assert action, "Search form should have action attribute" - - # Method should be GET for search (standard practice) - if method - assert_equal "get", method.downcase, - "Search forms should typically use GET method" - end - - # Should have search input - search_input = form.css("input[type='search'], input[name*='search']").first - assert search_input, "Search form should contain search input" - - # Search input should have proper attributes - if search_input - name = search_input["name"] - assert name, "Search input should have name attribute" - - search_input["placeholder"] - # Placeholder is helpful for UX but not required - end - end + scripts.each do |script| + data = assert_valid_json(script.text, "List page JSON-LD") + refute_nil data["@type"], "Each JSON-LD block should declare an @type" + assert_schema_context(data) end end + # Every listed post carries a date today. Three nested guards used to make + # "no dates rendered at all" indistinguishable from "all dates valid". def test_list_page_date_information doc = parse_html_file(@test_page) - # List items should show date information - items = doc.css("article, .post, .post-item, .entry") - - if items.any? - items.first(3).each_with_index do |item, index| - # Look for date elements - date_elements = item.css("time, .date, .published, .post-date") - - if date_elements.any? - date_elements.each do |date_elem| - if date_elem.name == "time" - datetime = date_elem["datetime"] - # Time elements should have datetime attribute - if datetime - # Basic date format check - assert datetime.match?(/\d{4}-\d{2}-\d{2}/), - "DateTime attribute should include valid date format" - end - end - - # Date should have readable text - date_text = date_elem.text.strip - assert date_text.length > 3, - "Date elements should have readable text" - end - end - end - end - end + items = doc.css(ITEM_SELECTOR) + refute_empty items, "List page should render post items" - def test_list_page_author_information_if_present - doc = parse_html_file(@test_page) + items.first(3).each_with_index do |item, index| + date_elements = item.css("time, .date, .published, .post-date") + refute_empty date_elements, "List item #{index + 1} should show a date" - # List items may show author information - items = doc.css("article, .post, .post-item, .entry") - - if items.any? - items.first(3).each do |item| - # Look for author elements - author_elements = item.css(".author, .by-author, .post-author") - - author_elements.each do |author_elem| - author_text = author_elem.text.strip - assert author_text.length > 0, - "Author elements should have readable text" - - # Author links should be properly formatted - author_links = author_elem.css("a") - author_links.each do |link| - href = link["href"] - assert href, "Author links should have href attribute" - end + date_elements.each do |date_elem| + if date_elem.name == "time" + datetime = date_elem["datetime"] + refute_nil datetime, "