Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
07e58db
test(visual): bin/qtest scoped visual gate - affected-pages-only runs…
Jul 18, 2026
6d43944
fix(qtest): default to --changed; validate page keys before paying fo…
Jul 18, 2026
782507e
refactor(css): C1.1 components/testimonials.css extracted; about-us s…
Jul 18, 2026
13e374c
docs(css): C1.1 progress + method corrections - slice-member wiring, …
Jul 18, 2026
07b2652
refactor(css): C1.1 clients swapped to components/testimonials.css (2/7)
Jul 18, 2026
4bd7275
refactor(css): C1.1 services swapped to components/testimonials.css (…
Jul 18, 2026
6f1f7c5
refactor(css): C1.1 single-use-cases swapped to components/testimonia…
Jul 18, 2026
a86098a
refactor(css): C1.1 single-service swapped to components/testimonials…
Jul 18, 2026
aa74a5b
refactor(css): use-cases @imports converted to slice members (C1.1 pr…
Jul 18, 2026
7162f20
test(visual): qtest rewritten in Ruby - 90s wall budget + random rota…
Jul 18, 2026
dd35c07
refactor(css): C1.1 use-cases swapped to components/testimonials.css …
Jul 18, 2026
90e00d2
refactor(css): delete dead homepage swiper-svg 24px rule (C1.1 prep, 7a)
Jul 18, 2026
50d67db
fix(qtest): sample random extras per test file - cross-file name batc…
Jul 18, 2026
80981da
refactor(css): C1.1 homepage swapped to components/testimonials.css (…
Jul 18, 2026
1dba353
upgrades postcss
Jul 18, 2026
9bac6a9
test: churn-based test:critical subset + qtest fixtures guard + bin/d…
Jul 19, 2026
5a11cc1
refactor(css): delete 9 dead post-prelude @imports (audit of the sile…
Jul 19, 2026
c2e2691
test(css): bin/css-split - Ruby extraction tool for C1 component sprints
Jul 19, 2026
b4d3202
docs(css): tracker - C1.1 shipped, pre-existing test:all failures logged
Jul 19, 2026
e0460e1
refactor(css): C1.2 components/cta-banner.css extracted; about-us swa…
Jul 19, 2026
c837cbe
refactor(css): C1.2 homepage swapped to components/cta-banner.css (2/6)
Jul 19, 2026
43c3b2c
refactor(css): C1.2 services, single-service, single-use-cases, use-c…
Jul 19, 2026
b7637c8
refactor(css): C1.3 components/header-cta.css - bf72bba trio extracte…
Jul 19, 2026
f1081dc
refactor(css): delete 5 never-shipped c-pp component files (2,623 lines)
Jul 19, 2026
45e2809
test: delete flaky, redundant careers_page component tests + their ba…
Jul 19, 2026
93c8e19
fix(css-split): span-cut remainder mode - untouched regions stay byte…
Jul 19, 2026
ebeee56
test(visual): mask the Gravity Forms region on free_consultation - bi…
Jul 19, 2026
d7ebc83
docs(css): C1 complete - tracker C1.2-C1.5 evidence + ownership map c…
Jul 19, 2026
e696847
fix: address CodeRabbit review - css-split hardening + duplicate decl…
Jul 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions .okf/build/test-gates.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
---
type: Playbook
title: Test gates and when they block commits
description: bin/rake test:critical always; bin/test AND bin/dtest are both mandatory for any themes/, layouts/, or CSS change.
description: bin/qtest --changed per micro-commit; bin/rake test:critical + bin/test AND bin/dtest at milestones and before every PR for themes/, layouts/, or CSS changes.
tags: [testing, visual-regression, gates]
timestamp: 2026-07-13T00:00:00Z
timestamp: 2026-07-19T00:00:00Z
---

# The three suites
# The suites

| Command | What it is | When required |
|---|---|---|
| `bin/rake test:critical` | Critical Minitest suite (46 runs / 84 screenshots) | After every change, before every commit |
| `bin/test` | Visual regression on macOS host (baselines in `test/fixtures/screenshots/macos/`) | Any edit to `themes/`, `layouts/`, `*.css`, or post body HTML |
| `bin/qtest --changed` | Scoped visual gate: builds once (~11s), runs ONLY affected pages' desktop+mobile screenshot tests (~2.5s each) + orphan guard + color-system check; site-wide/unmapped files auto-escalate to the full critical suite | Per micro-commit inside a sprint (~25-60s); NOT a substitute for the milestone/PR gates below |
| `bin/rake test:critical` | Critical Minitest suite (46 runs / 84 screenshots) | At component/task milestones and before every commit outside sprint micro-commit trains |
| `bin/test` | Visual regression on macOS host (baselines in `test/fixtures/screenshots/macos/`) | Any edit to `themes/`, `layouts/`, `*.css`, or post body HTML - at minimum per component milestone + branch head before PR |
| `bin/dtest` | Same suite in Linux/Docker (baselines in `linux/`) - CI runs Linux | Same trigger as bin/test; skipping it ships green-locally / red-in-CI |

`bin/qtest` page keys mirror `themes/beaver/assets/css/pages/*.css` basenames;
the changed-file→page map lives in the script itself - extend it when adding
components. The macOS full suite remains the only dedup-trap catcher
(Linux font resolution masks it) - never finish a component on qtest alone.

# Hard-won caveats

- The snapshot tool REWRITES baselines when a run passes. Never edit CSS
Expand Down
3 changes: 3 additions & 0 deletions .okf/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,6 @@ Root index and workflows index updated with the new entries. No course-side
concepts changed; distilled from CLAUDE.md, bin/hugo-build, Rakefile,
config/_default/hugo.toml, theme layouts, and docs/workflows/ +
docs/90-99-content-strategy/.

## 2026-07-19
- bin/qtest added (scoped visual gate): PRECOMPILED_ASSETS + Minitest -n filter over an ownership-map-derived page set; ~25-60s per micro-commit vs >5 min full stack. Spec Phase C gate stack now items 4 (qtest per commit) + 5 (full suites per milestone/PR). test-gates.md updated.
16 changes: 16 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,26 @@ namespace :test do
t.pattern = "test/unit/**/*_test.rb"
end

# Highest-risk pages only (~2 min): selection grounded in git churn
# (2026-07-19 analysis: blog + homepage + course + site-wide dominate
# 6-month history) + the lead-gen funnel (services, contact,
# free-consultation). Full system suite = test:system; everything = test:all.
CRITICAL_TESTS =
"/homepage|blog_index|blog_post$|visit_blog_post|course_|old_blog_url|" \
"test_services($|_menu)|contact_us|free_consultation|not_found|" \
"hamburger|color_system|mermaid|codeblock_language/"

Rake::TestTask.new(:critical) do |t|
t.libs << "test"
t.libs << "lib"
t.pattern = "test/system/*_test.rb"
t.options = "--name='#{CRITICAL_TESTS}'"
end

Rake::TestTask.new(:system) do |t|
t.libs << "test"
t.libs << "lib"
t.pattern = "test/system/**/*_test.rb"
end

Rake::TestTask.new(:integration) do |t|
Expand Down
180 changes: 180 additions & 0 deletions bin/css-split
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

# css-split — C1 shared-component extraction tool (spec: Phase C, sprint C1).
#
# Splits a page CSS file into component-member rules and a remainder, by a
# normalized-key list, preserving original formatting, order, and @media
# grouping. Replaces the scratchpad awk pipeline that caused two self-inflicted
# defects in C1.1 (wrong-line dedupe, truncated header comment).
#
# Usage:
# bin/css-split keys KEYSFILE PAGE.css # print normalized keys of matching rules
# bin/css-split component KEYSFILE PAGE.css # print matching rules (original text)
# bin/css-split remainder KEYSFILE PAGE.css # print file minus matching rules
# bin/css-split extract PATTERN PAGE.css # print normalized keys of rules whose
# # selector matches PATTERN (regex)
#
# Key format (one per line): [media||]selector{decls} with ALL whitespace
# removed and the long system-ui font stack replaced by var(--font-system-ui).
# Built-in checks: brace balance and comment closure on every input; the
# remainder writer refuses to drop rules not present in KEYSFILE.

require "set"

FONT_STACK = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", ' \
'"Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", ' \
'"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'

Rule = Struct.new(:media, :text, :key, :from, :to, :shell_from, :shell_to)

# Canonicalize while KEEPING descendant-combinator spaces: collapsing all
# whitespace would collide ".a .b" with ".a.b" and silently extract both.
def canonical(str)
str.gsub(/\s+/, " ").gsub(/\s*([{}:;,>~+()])\s*/, '\1').strip
end

def normalize(text, media)
k = canonical(text.gsub(%r{/\*.*?\*/}m, ""))
k = k.gsub(canonical(FONT_STACK), "var(--font-system-ui)")
media ? "#{canonical(media)}||#{k}" : k
end

# Returns [rules, plains] where rules are Rule structs in document order and
# plains are top-level non-rule statements (@import/@charset) with positions.
def parse(css, path)
stripped = css.gsub(%r{/\*.*?\*/}m, "").gsub(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/m, "")
depth = stripped.count("{") - stripped.count("}")
abort "css-split: #{path}: unbalanced braces (#{depth})" unless depth.zero?
abort "css-split: #{path}: unclosed comment" if stripped.include?("/*")

rules = []
i = 0
n = css.length
start = 0
depth = 0
media = nil
mstart = nil
while i < n
c = css[i]
if c == "/" && css[i + 1] == "*"
close = css.index("*/", i + 2)
i = close ? close + 2 : n
next
end
if c == '"' || c == "'"
# skip string literals so braces/comment-openers inside them are inert
j = i + 1
j += (css[j] == "\\" ? 2 : 1) while j < n && css[j] != c
i = j + 1
next
end
case c
when ";"
if depth.zero? && media.nil?
rules << Rule.new(nil, css[start..i], :plain, start, i)
start = i + 1
end
when "{"
if depth.zero?
head = css[start...i].gsub(%r{/\*.*?\*/}m, "").gsub(/\s+/, " ").strip
if media.nil? && head.start_with?("@media")
media = head
mstart = start
start = i + 1
i += 1
next
end
end
depth += 1
when "}"
if depth.zero? && media
rules.reverse_each do |r|
break unless r.media == media && r.shell_to.nil?

r.shell_from = mstart
r.shell_to = i
end
media = nil
start = i + 1
i += 1
next
end
depth -= 1
if depth.zero?
text = css[start..i]
rules << Rule.new(media, text, normalize(text, media), start, i)
start = i + 1
end
end
i += 1
end
rules
Comment thread
coderabbitai[bot] marked this conversation as resolved.
end

mode, keys_arg, path = ARGV
abort "css-split: usage: bin/css-split keys|component|remainder|extract ARG PAGE.css" unless mode && keys_arg && path

css = File.read(path)
rules = parse(css, path)

case mode
when "extract"
pat = Regexp.new(keys_arg)
rules.each do |r|
next if r.key == :plain

sel = r.text[/\A[^{]*/]&.gsub(%r{/\*.*?\*/}m, "")
puts r.key if sel&.match?(pat)
end
when "keys", "component", "remainder"
keyset = File.readlines(keys_arg, chomp: true).reject(&:empty?).to_set
open_media = nil
rules.each do |r|
member = r.key != :plain && keyset.include?(r.key)
case mode
when "keys"
puts r.key if member
when "component"
next unless member

t = r.text.gsub(FONT_STACK, "var(--font-system-ui)")
if r.media != open_media
print "}\n" if open_media
print "#{r.media} {\n" if r.media
open_media = r.media
end
print t
print "\n"
end
end
print "}\n" if open_media && mode == "component"

if mode == "remainder"
# cut removed spans from the ORIGINAL text (untouched regions stay
# byte-identical); a media shell loses its whole span only when ALL
# its inner rules are removed
cuts = []
rules.group_by { |r| r.shell_from }.each do |shell, group|
removed = group.select { |r| r.key != :plain && keyset.include?(r.key) }
next if removed.empty?

if shell && removed.size == group.size
cuts << [shell, group.first.shell_to]
else
removed.each { |r| cuts << [r.from, r.to] }
end
end
out = +""
pos = 0
cuts.sort_by(&:first).each do |from, to|
out << css[pos...from]
pos = to + 1
pos += 1 while css[pos] == "\n" && css[pos, 2] == "\n\n" # collapse the gap a little
end
out << css[pos..]
print out
end
else
abort "css-split: unknown mode #{mode}"
end
41 changes: 41 additions & 0 deletions bin/dtest-all
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

# dtest-all — the WHOLE test suite (rake test:all) in Docker, detached,
# so local work can continue while it runs.
#
# Isolation: builds a dedicated snapshot to _dest/public-dtest-all (never
# touched by bin/test/bin/dtest/qtest), then runs the suite in a background
# container against it. Keep editing freely once "container started" prints -
# the only shared writes are Linux screenshot baselines
# (test/fixtures/screenshots/linux/), which macOS-side work never touches.
# Note: test *code* is read from the live mount at container boot, so avoid
# editing test/**/*.rb during the first seconds of the run.
#
# Usage:
# bin/dtest-all # start; prints log path + container id
# tail -f tmp/dtest-all.log

DEST = "_dest/public-dtest-all"
LOG = "tmp/dtest-all.log"

puts "dtest-all: building snapshot to #{DEST} ..."
system("hugo", "--noBuildLock", "--buildDrafts", "--environment", "production",
"--destination", DEST, "--logLevel", "warn",
"--baseURL", "http://localhost:1314") || abort("dtest-all: hugo build failed")

require "fileutils"
FileUtils.mkdir_p("tmp")

cmd = [
"bin/dc", "run", "--rm", "--detach", "--quiet-pull", "--remove-orphans",
"--env", "PRECOMPILED_ASSETS=true",
"--env", "TEST_SERVER_PORT=1314",
"--env", "HUGO_DEFAULT_PATH=#{DEST}",
"t", "sh", "-c", "bin/rake test:all > #{LOG} 2>&1; echo EXIT=$? >> #{LOG}"
]
system(*cmd) || abort("dtest-all: failed to start container")

puts "dtest-all: started (docker ps | grep jtcom-t)"
puts "dtest-all: follow with tail -f #{LOG}"
puts "dtest-all: last line will read EXIT=0 on success"
Loading
Loading