jekyll-theme-zer0 1.29.0 → 1.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +715 -2
- data/_data/README.md +2 -0
- data/_data/ai.yml +5 -3
- data/_data/ai_pricing.yml +36 -0
- data/_data/backlog.yml +169 -18
- data/_data/consumers.yml +48 -5
- data/_data/features.yml +146 -20
- data/_data/feedback_types.yml +17 -12
- data/_data/landing.yml +5 -2
- data/_data/navigation/quickstart.yml +4 -0
- data/_data/site_builder.yml +874 -0
- data/_data/theme-manifest.yml +122 -114
- data/_data/ui-text.yml +18 -0
- data/_includes/README.md +11 -1
- data/_includes/analytics/posthog.html +2 -2
- data/_includes/components/admin-links.html +2 -2
- data/_includes/components/admin-tabs.html +2 -2
- data/_includes/components/ai-chat.html +14 -11
- data/_includes/components/analytics-dashboard.html +8 -8
- data/_includes/components/author-bio.html +1 -1
- data/_includes/components/author-card.html +10 -2
- data/_includes/components/author-eeat.html +4 -4
- data/_includes/components/background-customizer.html +8 -8
- data/_includes/components/background-image.html +114 -0
- data/_includes/components/background-settings.html +28 -15
- data/_includes/components/collection-manager.html +5 -5
- data/_includes/components/component-showcase.html +13 -13
- data/_includes/components/config-editor.html +12 -12
- data/_includes/components/config-viewer.html +8 -8
- data/_includes/components/cookie-consent.html +11 -11
- data/_includes/components/cta-button.html +7 -2
- data/_includes/components/dev-shortcuts.html +7 -7
- data/_includes/components/env-dashboard.html +8 -8
- data/_includes/components/env-switcher.html +9 -9
- data/_includes/components/feature-card.html +2 -2
- data/_includes/components/halfmoon.html +2 -2
- data/_includes/components/info-section.html +36 -36
- data/_includes/components/js-cdn.html +15 -15
- data/_includes/components/language-toggle.html +4 -4
- data/_includes/components/mermaid.html +72 -435
- data/_includes/components/nanobar.html +5 -5
- data/_includes/components/nav-editor.html +2 -2
- data/_includes/components/nav-export.html +2 -2
- data/_includes/components/nav-overview.html +2 -2
- data/_includes/components/page-feedback.html +45 -30
- data/_includes/components/page-views-init.html +1 -1
- data/_includes/components/post-card.html +22 -22
- data/_includes/components/post-type-badge.html +2 -2
- data/_includes/components/powered-by.html +2 -2
- data/_includes/components/preview-image.html +6 -0
- data/_includes/components/quick-index.html +2 -2
- data/_includes/components/search-modal.html +27 -2
- data/_includes/components/searchbar.html +2 -2
- data/_includes/components/svg-background.html +2 -2
- data/_includes/components/theme-customizer.html +2 -2
- data/_includes/components/theme-info.html +6 -6
- data/_includes/components/theme-preview-gallery.html +22 -22
- data/_includes/content/giscus.html +2 -2
- data/_includes/content/intro.html +8 -8
- data/_includes/content/jsonld-faq.html +2 -2
- data/_includes/content/jsonld-software.html +2 -2
- data/_includes/content/seo.html +4 -4
- data/_includes/content/sitemap.html +27 -27
- data/_includes/content/toc.html +183 -183
- data/_includes/core/branding.html +6 -6
- data/_includes/core/console-capture.html +32 -74
- data/_includes/core/favicon.html +49 -7
- data/_includes/core/footer-fabs.html +17 -3
- data/_includes/core/footer.html +31 -18
- data/_includes/core/head.html +102 -90
- data/_includes/core/header.html +71 -52
- data/_includes/docs/bootstrap-docs.html +8 -8
- data/_includes/landing/landing-install-cards.html +2 -2
- data/_includes/landing/landing-quick-links.html +1 -1
- data/_includes/navigation/admin-nav.html +2 -2
- data/_includes/navigation/nav-tree.html +8 -8
- data/_includes/navigation/navbar.html +12 -12
- data/_includes/navigation/section-sidebar.html +16 -16
- data/_includes/navigation/sidebar-config.html +36 -2
- data/_includes/navigation/sidebar-left.html +15 -15
- data/_includes/navigation/sidebar-right.html +6 -6
- data/_includes/obsidian/full-graph.html +2 -2
- data/_includes/setup/claude-session.html +72 -0
- data/_includes/setup/prereq-checklist.html +90 -0
- data/_includes/setup/wizard.html +906 -291
- data/_includes/stats/stats-categories.html +8 -8
- data/_includes/stats/stats-header.html +14 -14
- data/_includes/stats/stats-metrics.html +12 -12
- data/_includes/stats/stats-no-data.html +12 -12
- data/_includes/stats/stats-overview.html +6 -6
- data/_includes/stats/stats-tags.html +8 -8
- data/_layouts/404.html +38 -24
- data/_layouts/admin.html +22 -22
- data/_layouts/article.html +39 -34
- data/_layouts/author.html +20 -20
- data/_layouts/authors.html +2 -2
- data/_layouts/book-abc.html +12 -12
- data/_layouts/book-story.html +15 -15
- data/_layouts/book.html +12 -12
- data/_layouts/collection.html +33 -33
- data/_layouts/cookbook.html +12 -12
- data/_layouts/default.html +27 -24
- data/_layouts/home.html +23 -23
- data/_layouts/index.html +10 -10
- data/_layouts/landing.html +17 -17
- data/_layouts/news.html +44 -44
- data/_layouts/note.html +38 -38
- data/_layouts/notebook.html +34 -34
- data/_layouts/recipe.html +24 -24
- data/_layouts/root.html +73 -55
- data/_layouts/section.html +23 -23
- data/_layouts/setup.html +3 -3
- data/_layouts/sitemap-collection.html +49 -49
- data/_layouts/stats.html +40 -40
- data/_layouts/tag.html +12 -12
- data/_layouts/welcome.html +21 -21
- data/_sass/components/_mermaid.scss +375 -0
- data/_sass/components/_setup-wizard.scss +569 -40
- data/_sass/core/_navbar.scss +11 -31
- data/_sass/layouts/_navbar-extras.scss +14 -4
- data/assets/css/main.scss +1 -0
- data/assets/js/ai-chat.js +47 -5
- data/assets/js/fleet-feedback-capture.js +124 -0
- data/assets/js/fleet-feedback.js +853 -0
- data/assets/js/mermaid-diagrams.js +1267 -0
- data/assets/js/modules/navigation/config.js +9 -6
- data/assets/js/modules/navigation/scroll-spy.js +315 -80
- data/assets/js/modules/theme/appearance.js +8 -2
- data/assets/js/obsidian-wiki-links.js +8 -3
- data/assets/js/page-feedback.js +125 -192
- data/assets/js/search-modal.js +26 -0
- data/assets/js/setup-wizard.js +2112 -361
- data/assets/js/site-builder.js +1834 -0
- data/assets/js/ui-enhancements.js +11 -3
- data/scripts/README.md +15 -0
- data/scripts/ai/README.md +38 -0
- data/scripts/ai/api_call.rb +124 -0
- data/scripts/ai/usage.rb +314 -0
- data/scripts/ai/usage_report.rb +225 -0
- data/scripts/ci/test_visual_evidence_autogen.py +341 -0
- data/scripts/ci/visual_evidence_autogen.py +1060 -0
- data/scripts/content-review.rb +20 -1
- data/scripts/test/integration/mermaid +22 -8
- data/scripts/test/lib/run_tests.sh +1 -0
- data/scripts/test/lib/test_visual_evidence_autogen.sh +24 -0
- data/scripts/translate.rb +23 -1
- metadata +19 -2
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# usage_report.rb — publish a job's AI usage records (summary, artifact, PR comment)
|
|
4
|
+
# -----------------------------------------------------------------------------
|
|
5
|
+
# The reporting half of AI metering. usage.rb captured one JSONL record per AI
|
|
6
|
+
# call into AI_USAGE_DIR/records.jsonl; this script, run at the end of the
|
|
7
|
+
# job (the claude-run composite calls it automatically), does four things:
|
|
8
|
+
#
|
|
9
|
+
# 1. ATTRIBUTE — resolve which PR the spend belongs to: an explicit --pr,
|
|
10
|
+
# the pull_request event payload, or the pr-result.txt file
|
|
11
|
+
# the factory/fleet agents write after opening a PR (that
|
|
12
|
+
# run's records become the PR's CREATION cost).
|
|
13
|
+
# 2. CONSUME — move records.jsonl to a reported-*.jsonl file (so a second
|
|
14
|
+
# AI step in the same job never double-reports) and print
|
|
15
|
+
# `file=` / `name=` to $GITHUB_OUTPUT for the artifact upload.
|
|
16
|
+
# 3. SUMMARIZE — append a per-call table to $GITHUB_STEP_SUMMARY.
|
|
17
|
+
# 4. COMMENT — upsert ONE sticky "AI usage & cost" comment on the PR,
|
|
18
|
+
# found by its <!-- lh-ai-usage --> marker (never
|
|
19
|
+
# `--edit-last`, which grabs whatever the bot said last).
|
|
20
|
+
# The comment embeds its own base64 data blob, so each run
|
|
21
|
+
# merges records by id — cumulative, idempotent, and safe to
|
|
22
|
+
# re-run. Concurrent jobs can still race the read-merge-write
|
|
23
|
+
# (last writer wins for the VIEW); the artifacts + nightly
|
|
24
|
+
# ledger remain the source of truth.
|
|
25
|
+
#
|
|
26
|
+
# Every dollar figure is API-equivalent: what the tokens would bill at list
|
|
27
|
+
# prices. Subscription (OAuth) runs cost $0 marginal — the label says so.
|
|
28
|
+
# Best-effort by design: metering must never fail the job. Stdlib + `gh` only.
|
|
29
|
+
#
|
|
30
|
+
# ruby scripts/ai/usage_report.rb [--pr N] [--pr-result pr-result.txt]
|
|
31
|
+
# =============================================================================
|
|
32
|
+
require 'json'
|
|
33
|
+
require 'time'
|
|
34
|
+
require 'digest'
|
|
35
|
+
require 'securerandom'
|
|
36
|
+
require_relative 'usage'
|
|
37
|
+
|
|
38
|
+
MARKER = '<!-- lh-ai-usage -->'.freeze
|
|
39
|
+
DATA_HEAD = '<!-- lh-ai-usage-data:'.freeze
|
|
40
|
+
MAX_BLOB_RECORDS = 150 # older records fold into a rollup so the comment stays < 64KB
|
|
41
|
+
MAX_TABLE_ROWS = 30
|
|
42
|
+
|
|
43
|
+
pr_arg = nil
|
|
44
|
+
pr_result_file = 'pr-result.txt'
|
|
45
|
+
args = ARGV.dup
|
|
46
|
+
until args.empty?
|
|
47
|
+
case (a = args.shift)
|
|
48
|
+
when '--pr' then pr_arg = args.shift.to_i
|
|
49
|
+
when '--pr-result' then pr_result_file = args.shift.to_s
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# --- 1. load this job's records ------------------------------------------------
|
|
54
|
+
src = AIUsage.records_path
|
|
55
|
+
records = File.exist?(src) ? File.read(src, encoding: 'UTF-8').split("\n").map { |l| JSON.parse(l) rescue nil }.compact : []
|
|
56
|
+
if records.empty?
|
|
57
|
+
warn '[usage_report] no AI usage records this job — nothing to report.'
|
|
58
|
+
File.open(ENV['GITHUB_OUTPUT'], 'a') { |io| io.puts('file='); io.puts('name=') } if ENV['GITHUB_OUTPUT']
|
|
59
|
+
exit 0
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# --- 2. attribute to a PR --------------------------------------------------------
|
|
63
|
+
pr = nil
|
|
64
|
+
pr_source = nil
|
|
65
|
+
if pr_arg && pr_arg > 0
|
|
66
|
+
pr = pr_arg
|
|
67
|
+
pr_source = 'event'
|
|
68
|
+
elsif ENV['GITHUB_EVENT_PATH'] && File.exist?(ENV['GITHUB_EVENT_PATH'])
|
|
69
|
+
ev = JSON.parse(File.read(ENV['GITHUB_EVENT_PATH'])) rescue {}
|
|
70
|
+
n = ev.dig('pull_request', 'number')
|
|
71
|
+
if n
|
|
72
|
+
pr = n.to_i
|
|
73
|
+
pr_source = 'event'
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
if pr.nil? && File.exist?(pr_result_file)
|
|
77
|
+
# The factory/fleet convention: the agent writes the PR/issue URL(s) it opened
|
|
78
|
+
# to pr-result.txt. The first pull URL is the PR this run CREATED — its spend
|
|
79
|
+
# is that PR's creation cost.
|
|
80
|
+
if (m = File.read(pr_result_file, encoding: 'UTF-8')[%r{/pull/(\d+)}, 1])
|
|
81
|
+
pr = m.to_i
|
|
82
|
+
pr_source = 'created'
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
records.each { |r| r['pr'] ||= pr; r['pr_source'] ||= pr_source if pr }
|
|
86
|
+
|
|
87
|
+
# --- 3. consume: move to a reported file, hand the path to the uploader ----------
|
|
88
|
+
reported = File.join(AIUsage.dir, "reported-#{Time.now.utc.strftime('%H%M%S')}-#{SecureRandom.hex(3)}.jsonl")
|
|
89
|
+
File.open(reported, 'w') { |io| records.each { |r| io.puts(JSON.generate(r)) } }
|
|
90
|
+
File.delete(src)
|
|
91
|
+
if ENV['GITHUB_OUTPUT']
|
|
92
|
+
artifact = "ai-usage-#{ENV['GITHUB_RUN_ID'] || 'local'}-#{(ENV['GITHUB_JOB'] || 'job').gsub(/[^A-Za-z0-9_-]/, '_')}-#{SecureRandom.hex(3)}"
|
|
93
|
+
File.open(ENV['GITHUB_OUTPUT'], 'a') { |io| io.puts("file=#{reported}"); io.puts("name=#{artifact}") }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
fmt_usd = ->(v) { format('$%.4f', v.to_f) }
|
|
97
|
+
fmt_tok = ->(v) { v.to_i >= 10_000 ? "#{(v.to_i / 1000.0).round}k" : v.to_i.to_s }
|
|
98
|
+
|
|
99
|
+
# --- 4. step summary --------------------------------------------------------------
|
|
100
|
+
if ENV['GITHUB_STEP_SUMMARY']
|
|
101
|
+
total = records.sum { |r| r['cost_usd'].to_f }
|
|
102
|
+
lines = []
|
|
103
|
+
lines << '## 🤖 AI usage (this job)'
|
|
104
|
+
lines << ''
|
|
105
|
+
lines << '| role | model | turns | in | out | cache r/w | cost (API-equiv) | via |'
|
|
106
|
+
lines << '|---|---|---|---|---|---|---|---|'
|
|
107
|
+
records.each do |r|
|
|
108
|
+
t = r['tokens'] || {}
|
|
109
|
+
lines << "| #{r['agent'].to_s.empty? ? '—' : r['agent']} | #{r['model']} | #{r['num_turns'] || '—'} " \
|
|
110
|
+
"| #{fmt_tok.call(t['input'])} | #{fmt_tok.call(t['output'])} " \
|
|
111
|
+
"| #{fmt_tok.call(t['cache_read'])}/#{fmt_tok.call(t['cache_creation'])} " \
|
|
112
|
+
"| #{fmt_usd.call(r['cost_usd'])}#{r['cost_source'] == 'estimated' ? '*' : ''} | #{r['auth']} |"
|
|
113
|
+
end
|
|
114
|
+
lines << ''
|
|
115
|
+
# A failed call bills nothing, so it is invisible in the table above — spell
|
|
116
|
+
# out what the model refused, right where the operator is already looking.
|
|
117
|
+
records.select { |r| r['error'] }.each do |r|
|
|
118
|
+
e = r['error']
|
|
119
|
+
lines << "> ❌ **#{r['agent'].to_s.empty? ? 'AI call' : r['agent']} failed** " \
|
|
120
|
+
"(`#{e['subtype'].to_s.empty? ? "exit #{e['exit_code']}" : e['subtype']}`): #{e['message']}"
|
|
121
|
+
lines << ''
|
|
122
|
+
end
|
|
123
|
+
lines << "**Job total: #{fmt_usd.call(total)}** (API-equivalent#{records.any? { |r| r['cost_source'] == 'estimated' } ? '; * = estimated from _data/ai_pricing.yml' : ''})."
|
|
124
|
+
lines << ''
|
|
125
|
+
File.open(ENV['GITHUB_STEP_SUMMARY'], 'a') { |io| io.puts(lines.join("\n")) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# --- 5. sticky PR comment (best-effort) --------------------------------------------
|
|
129
|
+
repo = ENV['GITHUB_REPOSITORY'].to_s
|
|
130
|
+
exit 0 if pr.nil? || repo.empty?
|
|
131
|
+
unless system('gh --version > /dev/null 2>&1') && !(ENV['GH_TOKEN'].to_s + ENV['GITHUB_TOKEN'].to_s).empty?
|
|
132
|
+
warn '[usage_report] no gh/token — skipping the PR comment (records still in the artifact).'
|
|
133
|
+
exit 0
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def gh_json(args)
|
|
137
|
+
out = IO.popen(['gh'] + args, err: %i[child out], &:read)
|
|
138
|
+
return nil unless $?.success?
|
|
139
|
+
JSON.parse(out)
|
|
140
|
+
rescue StandardError
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Find the existing sticky comment by MARKER (any author, any position).
|
|
145
|
+
existing = nil
|
|
146
|
+
page = 1
|
|
147
|
+
loop do
|
|
148
|
+
batch = gh_json(['api', "repos/#{repo}/issues/#{pr}/comments?per_page=100&page=#{page}"])
|
|
149
|
+
break unless batch.is_a?(Array)
|
|
150
|
+
existing = batch.find { |c| c['body'].to_s.start_with?(MARKER) }
|
|
151
|
+
break if existing || batch.size < 100 || page >= 10
|
|
152
|
+
page += 1
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Merge this job's records into the comment's embedded blob (dedup by id).
|
|
156
|
+
blob = { 'records' => [], 'folded' => nil }
|
|
157
|
+
if existing && (m = existing['body'].to_s[/#{Regexp.escape(DATA_HEAD)}([A-Za-z0-9+\/=]+) -->/, 1])
|
|
158
|
+
blob = JSON.parse(m.unpack1('m')) rescue { 'records' => [], 'folded' => nil }
|
|
159
|
+
end
|
|
160
|
+
compact = ->(r) do
|
|
161
|
+
t = r['tokens'] || {}
|
|
162
|
+
{ 'i' => r['id'], 't' => r['ts'], 'w' => r['workflow'], 'j' => r['job'], 'r' => r['run_id'],
|
|
163
|
+
'a' => r['agent'], 'm' => r['model'], 'ti' => t['input'].to_i, 'to' => t['output'].to_i,
|
|
164
|
+
'tr' => t['cache_read'].to_i, 'tc' => t['cache_creation'].to_i,
|
|
165
|
+
'c' => r['cost_usd'].to_f.round(6), 's' => r['cost_source'], 'au' => r['auth'],
|
|
166
|
+
'st' => r['status'], 'ps' => r['pr_source'] }
|
|
167
|
+
end
|
|
168
|
+
known = blob['records'].map { |r| r['i'] }
|
|
169
|
+
records.each { |r| blob['records'] << compact.call(r) unless known.include?(r['id']) }
|
|
170
|
+
blob['records'].sort_by! { |r| r['t'].to_s }
|
|
171
|
+
while blob['records'].size > MAX_BLOB_RECORDS
|
|
172
|
+
old = blob['records'].shift
|
|
173
|
+
f = blob['folded'] ||= { 'n' => 0, 'c' => 0.0, 'ti' => 0, 'to' => 0 }
|
|
174
|
+
f['n'] += 1
|
|
175
|
+
f['c'] = (f['c'] + old['c'].to_f).round(6)
|
|
176
|
+
f['ti'] += old['ti'].to_i
|
|
177
|
+
f['to'] += old['to'].to_i
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
all = blob['records']
|
|
181
|
+
folded = blob['folded']
|
|
182
|
+
total = all.sum { |r| r['c'].to_f } + (folded ? folded['c'].to_f : 0.0)
|
|
183
|
+
creation = all.select { |r| r['ps'] == 'created' }.sum { |r| r['c'].to_f }
|
|
184
|
+
downstream = total - creation
|
|
185
|
+
estimated = all.any? { |r| r['s'] == 'estimated' }
|
|
186
|
+
oauth_only = all.all? { |r| r['au'] == 'oauth' }
|
|
187
|
+
|
|
188
|
+
body = []
|
|
189
|
+
body << MARKER
|
|
190
|
+
body << '## 🤖 AI usage & cost for this PR'
|
|
191
|
+
body << ''
|
|
192
|
+
body << "**Total: #{fmt_usd.call(total)} API-equivalent** across #{all.size + (folded ? folded['n'] : 0)} AI call(s) — " \
|
|
193
|
+
"creation #{fmt_usd.call(creation)}, reviews/fixes/checks #{fmt_usd.call(downstream)}."
|
|
194
|
+
body << ''
|
|
195
|
+
body << '| when (UTC) | workflow · job | role | model | out tok | cost |'
|
|
196
|
+
body << '|---|---|---|---|---|---|'
|
|
197
|
+
body << "| _earlier_ | _#{folded['n']} older call(s), folded_ | | | #{fmt_tok.call(folded['to'])} | #{fmt_usd.call(folded['c'])} |" if folded
|
|
198
|
+
all.last(MAX_TABLE_ROWS).each do |r|
|
|
199
|
+
run_link = r['r'].to_s.empty? ? (r['w'].to_s.empty? ? 'local' : r['w']) : "[#{r['w']} · #{r['j']}](https://github.com/#{repo}/actions/runs/#{r['r']})"
|
|
200
|
+
body << "| #{r['t'].to_s[5, 11]} | #{run_link} | #{r['a'].to_s.empty? ? '—' : r['a']}#{r['ps'] == 'created' ? ' 🌱' : ''} " \
|
|
201
|
+
"| #{r['m']} | #{fmt_tok.call(r['to'])} | #{fmt_usd.call(r['c'])}#{r['s'] == 'estimated' ? '*' : ''} |"
|
|
202
|
+
end
|
|
203
|
+
body << "| | _…#{all.size - MAX_TABLE_ROWS} more in the ledger_ | | | | |" if all.size > MAX_TABLE_ROWS
|
|
204
|
+
body << ''
|
|
205
|
+
notes = ['🌱 = the run that opened this PR (creation cost).']
|
|
206
|
+
notes << '\\* = estimated from `_data/ai_pricing.yml` (that path reports tokens, not dollars).' if estimated
|
|
207
|
+
notes << (oauth_only ? 'All calls ran on Claude Code subscription auth (OAuth) — $0 marginal spend; the figure is what these tokens would bill at API list prices.' \
|
|
208
|
+
: 'Some calls used a metered API key — those dollars are real.')
|
|
209
|
+
notes << 'Updated automatically after every AI job; full history at [/docs/ai-usage/](https://lifehacker.dev/docs/ai-usage/).'
|
|
210
|
+
body << notes.map { |n| "_#{n}_" }.join(' ')
|
|
211
|
+
body << ''
|
|
212
|
+
body << "#{DATA_HEAD}#{[JSON.generate(blob)].pack('m0')} -->"
|
|
213
|
+
|
|
214
|
+
payload = JSON.generate('body' => body.join("\n"))
|
|
215
|
+
tmp = File.join(AIUsage.dir, 'comment-payload.json')
|
|
216
|
+
File.write(tmp, payload)
|
|
217
|
+
ok =
|
|
218
|
+
if existing
|
|
219
|
+
system('gh', 'api', '-X', 'PATCH', "repos/#{repo}/issues/comments/#{existing['id']}", '--input', tmp, out: File::NULL, err: %i[child out])
|
|
220
|
+
else
|
|
221
|
+
system('gh', 'api', "repos/#{repo}/issues/#{pr}/comments", '--input', tmp, out: File::NULL, err: %i[child out])
|
|
222
|
+
end
|
|
223
|
+
warn(ok ? "[usage_report] PR ##{pr} cost comment #{existing ? 'updated' : 'created'} (total #{fmt_usd.call(total)})." \
|
|
224
|
+
: "[usage_report] PR ##{pr} comment update failed (non-fatal; records are in the artifact).")
|
|
225
|
+
exit 0
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Feature: ZER0-085
|
|
3
|
+
"""Unit tests for scripts/ci/visual_evidence_autogen.py — the deterministic half
|
|
4
|
+
of the visual-evidence autogen lane.
|
|
5
|
+
|
|
6
|
+
What these pin, and why:
|
|
7
|
+
|
|
8
|
+
* the PLAN — from a PR's changed files alone, does the lane decide to act, and
|
|
9
|
+
on what? The shape of PR #454 (UI + spec + generator + README-only evidence)
|
|
10
|
+
must yield exactly one bespoke job; a docs-only PR must yield nothing; the
|
|
11
|
+
loop guard and the budget must stop it cold;
|
|
12
|
+
* the DECISION — only a well-formed `intentional` verdict on a genuinely failing
|
|
13
|
+
verify pass may bless baselines (issue #417: a blessed regression and a green
|
|
14
|
+
check are indistinguishable, so "the check is red" must never be enough);
|
|
15
|
+
* the CONTRACT with the workflows — the evidence gate's UI paths, ci.yml's
|
|
16
|
+
`styling` filter coverage, the hooks update-snapshots.sh exposes, and the
|
|
17
|
+
subcommands the workflow calls all exist where this script expects them.
|
|
18
|
+
|
|
19
|
+
Standard library only; no Docker, no network.
|
|
20
|
+
|
|
21
|
+
python3 scripts/ci/test_visual_evidence_autogen.py
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
31
|
+
|
|
32
|
+
import visual_evidence_autogen as vea # noqa: E402
|
|
33
|
+
|
|
34
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
35
|
+
FAILURES: list[str] = []
|
|
36
|
+
PASSED = 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def check(label: str, ok: bool) -> None:
|
|
40
|
+
global PASSED
|
|
41
|
+
if ok:
|
|
42
|
+
PASSED += 1
|
|
43
|
+
print(f" ✓ {label}")
|
|
44
|
+
else:
|
|
45
|
+
FAILURES.append(label)
|
|
46
|
+
print(f" ✗ {label}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class FakeFS:
|
|
50
|
+
"""A head tree: {path: content}; PNGs are any key ending in .png."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, files: dict[str, str]):
|
|
53
|
+
self.files = files
|
|
54
|
+
|
|
55
|
+
def exists(self, path: str) -> bool:
|
|
56
|
+
return path in self.files
|
|
57
|
+
|
|
58
|
+
def pngs(self, folder: str) -> list[str]:
|
|
59
|
+
return sorted(p for p in self.files if p.startswith(folder + "/") and p.endswith(".png"))
|
|
60
|
+
|
|
61
|
+
def read(self, path: str) -> str:
|
|
62
|
+
return self.files[path]
|
|
63
|
+
|
|
64
|
+
def generators(self) -> list[str]:
|
|
65
|
+
return sorted(p for p in self.files if vea.GENERATOR_RE.match(p))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
GEN_405 = "test/visual/navbar-tiers-evidence.mjs"
|
|
69
|
+
GEN_405_SRC = "import { generateEvidence } from './evidence-kit.mjs';\nawait generateEvidence({\n slug: 'navbar-tiers-405',\n});\n"
|
|
70
|
+
SHA = "e34e4065b99983b2960015e8e27e2d09fc1bf2c6"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def plan_for(changed, files, *, head_subject="feat(navigation): tiers", subjects=(), default_slug="agent-issue-405"):
|
|
74
|
+
return vea.make_plan(changed=changed, head_sha=SHA, head_subject=head_subject,
|
|
75
|
+
branch_subjects=list(subjects), fs=FakeFS(files),
|
|
76
|
+
default_slug=default_slug, base_ref="origin/main", head_ref="agent/issue-405")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_detect_slug() -> None:
|
|
80
|
+
print("detect_slug")
|
|
81
|
+
check("reads `slug: '…'` from a kit-based generator", vea.detect_slug(GEN_405_SRC) == "navbar-tiers-405")
|
|
82
|
+
check("reads OUT = 'test/visual/evidence/<slug>' from a hand-rolled one",
|
|
83
|
+
vea.detect_slug("const OUT = 'test/visual/evidence/navbar-fit';") == "navbar-fit")
|
|
84
|
+
check("returns None when nothing names a folder", vea.detect_slug("console.log('hi')") is None)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_plan_pr_454_shape() -> None:
|
|
88
|
+
print("plan — PR #454: UI + spec + generator + README-only evidence")
|
|
89
|
+
changed = ["_sass/core/_navbar.scss", "_includes/core/header.html", "CHANGELOG.md",
|
|
90
|
+
"test/visual/features/navbar-tiers.spec.js", GEN_405,
|
|
91
|
+
"test/visual/evidence/navbar-tiers-405/README.md"]
|
|
92
|
+
files = {GEN_405: GEN_405_SRC, "test/visual/evidence/navbar-tiers-405/README.md": "# pending"}
|
|
93
|
+
plan = plan_for(changed, files)
|
|
94
|
+
check("needed", plan["needed"] is True)
|
|
95
|
+
check("exactly one job", len(plan["jobs"]) == 1)
|
|
96
|
+
job = plan["jobs"][0] if plan["jobs"] else {}
|
|
97
|
+
check("it is the bespoke generator for navbar-tiers-405",
|
|
98
|
+
job.get("kind") == "bespoke" and job.get("generator") == GEN_405 and job.get("slug") == "navbar-tiers-405")
|
|
99
|
+
check("its folder is the evidence dir", job.get("dir") == "test/visual/evidence/navbar-tiers-405")
|
|
100
|
+
check("the pixel tier is in scope (sass changed)", plan["snapshots_in_scope"] is True)
|
|
101
|
+
check("no skip reason", plan["skip_reason"] is None)
|
|
102
|
+
check("the spec is recorded", plan["specs"] == ["test/visual/features/navbar-tiers.spec.js"])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_plan_loop_guard_and_budget() -> None:
|
|
106
|
+
print("plan — loop guard + budget")
|
|
107
|
+
changed = ["_sass/core/_navbar.scss", "test/visual/evidence/x/01.png"]
|
|
108
|
+
files = {"test/visual/evidence/x/01.png": ""}
|
|
109
|
+
plan = plan_for(changed, files, head_subject=f"test(visual): auto-generate evidence {vea.MARKER}")
|
|
110
|
+
check("an autogen head commit stops the lane", plan["needed"] is False and "loop guard" in plan["skip_reason"])
|
|
111
|
+
subjects = [f"x {vea.MARKER}"] * vea.MAX_COMMITS + ["feat: something"]
|
|
112
|
+
plan = plan_for(changed, files, subjects=subjects)
|
|
113
|
+
check(f"{vea.MAX_COMMITS} prior autogen commits exhaust the budget",
|
|
114
|
+
plan["needed"] is False and "budget" in plan["skip_reason"])
|
|
115
|
+
plan = plan_for(changed, files, subjects=[f"x {vea.MARKER}"] * (vea.MAX_COMMITS - 1))
|
|
116
|
+
check("one under the budget still runs", plan["needed"] is True)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_plan_generic_fallback() -> None:
|
|
120
|
+
print("plan — UI change with no evidence at all → generic generator")
|
|
121
|
+
plan = plan_for(["_layouts/home.html"], {})
|
|
122
|
+
check("one generic job named after the branch", len(plan["jobs"]) == 1 and plan["jobs"][0]["kind"] == "generic"
|
|
123
|
+
and plan["jobs"][0]["slug"] == "agent-issue-405" and plan["jobs"][0]["generator"] is None)
|
|
124
|
+
plan = plan_for(["_layouts/home.html"], {}, default_slug=None)
|
|
125
|
+
check("…but only when a default slug is known", plan["jobs"] == [] and plan["needed"] is True)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_plan_respects_author_evidence() -> None:
|
|
129
|
+
print("plan — author-produced evidence is never regenerated")
|
|
130
|
+
folder = "test/visual/evidence/hover-flicker"
|
|
131
|
+
changed = ["_sass/core/_navbar.scss", f"{folder}/01-box-shift.png", f"{folder}/metrics.json"]
|
|
132
|
+
files = {f"{folder}/01-box-shift.png": "", f"{folder}/metrics.json": "{}"}
|
|
133
|
+
plan = plan_for(changed, files)
|
|
134
|
+
check("proof present + no manifest → no job", plan["jobs"] == [])
|
|
135
|
+
check("…the pixel tier still gets verified", plan["needed"] is True and plan["snapshots_in_scope"] is True)
|
|
136
|
+
files[f"{folder}/{vea.MANIFEST}"] = '{"rendered_from": "0000000deadbeef"}'
|
|
137
|
+
plan = plan_for(changed + [f"{folder}/{vea.MANIFEST}"], files)
|
|
138
|
+
check("autogen evidence from an OLDER head is refreshed", len(plan["jobs"]) == 1 and "rendered from" in plan["jobs"][0]["why"])
|
|
139
|
+
files[f"{folder}/{vea.MANIFEST}"] = f'{{"rendered_from": "{SHA}"}}'
|
|
140
|
+
plan = plan_for(changed + [f"{folder}/{vea.MANIFEST}"], files)
|
|
141
|
+
check("autogen evidence from THIS head is left alone", plan["jobs"] == [])
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_plan_out_of_scope() -> None:
|
|
145
|
+
print("plan — nothing visual")
|
|
146
|
+
plan = plan_for(["docs/systems/x.md", "pages/_posts/2026-09-05-x.md", "_data/backlog.yml"], {})
|
|
147
|
+
check("docs/content/backlog → not needed", plan["needed"] is False and plan["jobs"] == []
|
|
148
|
+
and plan["snapshots_in_scope"] is False)
|
|
149
|
+
plan = plan_for(["_data/ui-text.yml"], {})
|
|
150
|
+
check("a chrome data file alone → pixel tier in scope, no evidence job",
|
|
151
|
+
plan["needed"] is True and plan["jobs"] == [] and plan["snapshots_in_scope"] is True)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_styling_matches_ci_filter() -> None:
|
|
155
|
+
print("contract — styling scope covers every chrome-affecting file lint-workflows.yml pins")
|
|
156
|
+
for f in ["_data/navigation/main.yml", "_data/ui-text.yml", "_data/i18n/en.yml", "_data/theme_skins.yml",
|
|
157
|
+
"_includes/components/theme-info.html", "_layouts/default.html", "_sass/main.scss",
|
|
158
|
+
"test/visual/features/x.spec.js", "test/playwright.config.js", "assets/js/x.js"]:
|
|
159
|
+
check(f"is_styling({f})", vea.is_styling(f))
|
|
160
|
+
check("a backlog edit is not styling", not vea.is_styling("_data/backlog.yml"))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_ui_prefixes_match_gate() -> None:
|
|
164
|
+
print("contract — UI paths agree with evidence-gate.yml")
|
|
165
|
+
gate = (REPO_ROOT / ".github/workflows/evidence-gate.yml").read_text(encoding="utf-8")
|
|
166
|
+
m = re.search(r"grep -E '\^\(([^)]+)\)'", gate)
|
|
167
|
+
gate_prefixes = tuple(m.group(1).split("|")) if m else ()
|
|
168
|
+
check("gate regex parsed", bool(gate_prefixes))
|
|
169
|
+
check("same prefix set", set(gate_prefixes) == set(vea.UI_PREFIXES))
|
|
170
|
+
check("gate requires generated proof (metrics.json)", "metrics.json" in gate)
|
|
171
|
+
check("gate points at the autogen", "visual_evidence_autogen.py" in gate)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_parse_results() -> None:
|
|
175
|
+
print("parse_playwright_results")
|
|
176
|
+
results = {"suites": [{"title": "appearance-snapshot.spec.js", "suites": [{"title": "Theme skins", "suites": [
|
|
177
|
+
{"title": "skin: sunrise", "specs": [{"title": "homepage visual snapshot", "tests": [{"results": [
|
|
178
|
+
{"status": "failed", "error": {"message": "\x1b[2mexpect(\x1b[22mpage\x1b[2m).\x1b[22mtoHaveScreenshot failed\n\n 1939 pixels (ratio 0.01 of all image pixels) are different."},
|
|
179
|
+
"attachments": [
|
|
180
|
+
{"name": "homepage-sunrise-expected", "path": "/work/test/visual-results/output/a/homepage-sunrise-expected.png"},
|
|
181
|
+
{"name": "homepage-sunrise-actual", "path": "/work/test/visual-results/output/a/homepage-sunrise-actual.png"},
|
|
182
|
+
{"name": "homepage-sunrise-diff", "path": "/work/test/visual-results/output/a/homepage-sunrise-diff.png"}]}]}]}]},
|
|
183
|
+
{"title": "skin: air", "specs": [{"title": "homepage visual snapshot", "tests": [{"results": [{"status": "passed"}]}]}]},
|
|
184
|
+
]}]}]}
|
|
185
|
+
parsed = vea.parse_playwright_results(results)
|
|
186
|
+
check("2 tests, 1 passed", parsed["total"] == 2 and parsed["passed"] == 1)
|
|
187
|
+
check("one failure", len(parsed["failures"]) == 1)
|
|
188
|
+
f = parsed["failures"][0] if parsed["failures"] else {}
|
|
189
|
+
check("skin taken from the describe title", f.get("skin") == "sunrise")
|
|
190
|
+
check("pixel count parsed through the ANSI noise", f.get("diff_px") == 1939)
|
|
191
|
+
check("container paths made repo-relative",
|
|
192
|
+
f.get("diff") == "test/visual-results/output/a/homepage-sunrise-diff.png")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_decide() -> None:
|
|
196
|
+
print("decide — only `intentional` on a failing verify may bless")
|
|
197
|
+
fail = {"snapshots": {"verified": "fail"}}
|
|
198
|
+
ok = {"snapshots": {"verified": "pass"}}
|
|
199
|
+
v = lambda kind, summary="looks right": {"schema": "visual-evidence-verdict/v1", "snapshots": {"verdict": kind, "summary": summary}}
|
|
200
|
+
check("fail + intentional → bless", vea.decide(fail, v("intentional"))[0] is True)
|
|
201
|
+
check("fail + regression → no", vea.decide(fail, v("regression"))[0] is False)
|
|
202
|
+
check("fail + unclear → no", vea.decide(fail, v("unclear"))[0] is False)
|
|
203
|
+
check("fail + no verdict → no (missing)", vea.decide(fail, None) == (False, "missing", vea.decide(fail, None)[2]))
|
|
204
|
+
check("fail + wrong schema → no", vea.decide(fail, {"snapshots": {"verdict": "intentional"}})[0] is False)
|
|
205
|
+
check("fail + invented verdict → no", vea.decide(fail, v("ship-it"))[0] is False)
|
|
206
|
+
check("pass + intentional → nothing to bless", vea.decide(ok, v("intentional")) [1] == "not-applicable")
|
|
207
|
+
check("error + intentional → nothing to bless", vea.decide({"snapshots": {"verified": "error"}}, v("intentional"))[0] is False)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_stage_and_messages() -> None:
|
|
211
|
+
print("stage / commit message / comment")
|
|
212
|
+
state = {"plan": "", "jobs": [{"slug": "navbar-tiers-405", "kind": "bespoke", "generator": GEN_405,
|
|
213
|
+
"dir": "test/visual/evidence/navbar-tiers-405", "why": "w",
|
|
214
|
+
"files": ["test/visual/evidence/navbar-tiers-405/01-x.png"], "has_proof": True},
|
|
215
|
+
{"slug": "nope", "kind": "generic", "generator": None,
|
|
216
|
+
"dir": "test/visual/evidence/nope", "why": "w", "files": [], "has_proof": False}],
|
|
217
|
+
"snapshots": {"in_scope": True, "verified": "fail", "total": 9, "passed": 0,
|
|
218
|
+
"failures": [{"skin": "air", "diff_px": 1939}], "montage": None},
|
|
219
|
+
"generators_status": "1"}
|
|
220
|
+
check("stage: only folders with proof, no snapshots when not blessed",
|
|
221
|
+
vea.stage_paths(state) == ["test/visual/evidence/navbar-tiers-405"])
|
|
222
|
+
state["blessed"] = {"baselines": ["test/visual/snapshots/x.png"], "evidence_home": "test/visual/evidence/navbar-tiers-405"}
|
|
223
|
+
state["decision"] = {"bless": True, "verdict": "intentional", "reason": "subtitle moved to the hero"}
|
|
224
|
+
check("stage: snapshots included once blessed", vea.SNAPSHOT_DIR in vea.stage_paths(state))
|
|
225
|
+
msg = vea.render_commit_message(state, "https://example/run/1")
|
|
226
|
+
check("commit subject carries the loop-guard marker", msg.splitlines()[0].endswith(vea.MARKER))
|
|
227
|
+
check("commit body carries the trailer", vea.TRAILER in msg and "baselines=yes" in msg)
|
|
228
|
+
plan = {"head_sha": SHA, "head_ref": "agent/issue-405", "base_ref": "origin/main"}
|
|
229
|
+
body = vea.render_comment(plan=plan, state=state, verdict=None, repo="bamr87/zer0-mistakes",
|
|
230
|
+
sha="abc1234def", run_url="https://example/run/1", pushed=True)
|
|
231
|
+
check("comment carries the sticky marker", body.startswith(vea.COMMENT_MARKER))
|
|
232
|
+
check("comment names the blessed verdict and the reviewer duty", "Verdict: intentional" in body and "Files tab" in body)
|
|
233
|
+
check("comment lists the evidence that could not be generated", "could not be generated" in body)
|
|
234
|
+
quiet = {"plan": "", "jobs": [], "snapshots": {"in_scope": True, "verified": "pass", "failures": []}}
|
|
235
|
+
check("nothing noteworthy → empty comment",
|
|
236
|
+
vea.render_comment(plan=plan, state=quiet, verdict=None, repo="r", sha=None, run_url="", pushed=False) == "")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_stage_command_output() -> None:
|
|
240
|
+
print("stage — the command's stdout is what the workflow `mapfile`s")
|
|
241
|
+
import argparse
|
|
242
|
+
import contextlib
|
|
243
|
+
import io
|
|
244
|
+
import json
|
|
245
|
+
import tempfile
|
|
246
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
247
|
+
empty = Path(tmp) / "empty.json"
|
|
248
|
+
empty.write_text(json.dumps({"jobs": [], "snapshots": {"verified": "pass"}}), encoding="utf-8")
|
|
249
|
+
buf = io.StringIO()
|
|
250
|
+
with contextlib.redirect_stdout(buf):
|
|
251
|
+
vea.cmd_stage(argparse.Namespace(state=str(empty)))
|
|
252
|
+
check("nothing to add → prints nothing, not even a newline (run 33992712400 died on `git add -- \"\"`)",
|
|
253
|
+
buf.getvalue() == "")
|
|
254
|
+
some = Path(tmp) / "some.json"
|
|
255
|
+
some.write_text(json.dumps({"jobs": [{"dir": "test/visual/evidence/x", "has_proof": True}]}), encoding="utf-8")
|
|
256
|
+
buf = io.StringIO()
|
|
257
|
+
with contextlib.redirect_stdout(buf):
|
|
258
|
+
vea.cmd_stage(argparse.Namespace(state=str(some)))
|
|
259
|
+
check("one path per line when there is something to add", buf.getvalue() == "test/visual/evidence/x\n")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_lane_tooling_contract() -> None:
|
|
263
|
+
"""The regression that killed the lane's first real run on a real PR.
|
|
264
|
+
|
|
265
|
+
visual-evidence-autogen.yml checks out the PR's branch, but the orchestrator
|
|
266
|
+
lives on main. On #454 — a branch cut before the lane shipped — the job died
|
|
267
|
+
in 20 seconds with "can't open file scripts/ci/visual_evidence_autogen.py",
|
|
268
|
+
on the very PR the lane was built to unstick. Every open PR on the day this
|
|
269
|
+
ships has the same shape, so the fix (restore the lane's tooling from the
|
|
270
|
+
base branch) is pinned here rather than left to review.
|
|
271
|
+
"""
|
|
272
|
+
print("contract — the lane's tooling comes from the BASE branch")
|
|
273
|
+
wf = (REPO_ROOT / ".github/workflows/visual-evidence-autogen.yml").read_text(encoding="utf-8")
|
|
274
|
+
|
|
275
|
+
check("the orchestrator itself is restored (the #454 crash)",
|
|
276
|
+
"scripts/ci/visual_evidence_autogen.py" in vea.LANE_TOOLING)
|
|
277
|
+
check("update-snapshots.sh is restored (an older copy lacks the hooks and silently no-ops)",
|
|
278
|
+
"test/update-snapshots.sh" in vea.LANE_TOOLING)
|
|
279
|
+
check("both shared generators are restored",
|
|
280
|
+
"test/visual/pr-evidence.mjs" in vea.LANE_TOOLING
|
|
281
|
+
and "test/visual/snapshot-diff-montage.mjs" in vea.LANE_TOOLING)
|
|
282
|
+
check("the PR's OWN evidence spec is NOT overwritten",
|
|
283
|
+
not any(p.endswith("-evidence.mjs") and "pr-evidence" not in p for p in vea.LANE_TOOLING))
|
|
284
|
+
check("evidence-kit.mjs is left to the PR (a shared library it may extend)",
|
|
285
|
+
"test/visual/evidence-kit.mjs" not in vea.LANE_TOOLING)
|
|
286
|
+
|
|
287
|
+
# The workflow's env list and the module constant must not drift apart.
|
|
288
|
+
m = re.search(r"LANE_TOOLING: >-\n((?:[ ]{8}\S+\n)+)", wf)
|
|
289
|
+
check("the workflow declares LANE_TOOLING", bool(m))
|
|
290
|
+
if m:
|
|
291
|
+
declared = tuple(m.group(1).split())
|
|
292
|
+
check("workflow LANE_TOOLING == the module's constant (single source of truth)",
|
|
293
|
+
declared == vea.LANE_TOOLING)
|
|
294
|
+
|
|
295
|
+
check("it restores from the base ref, not the head",
|
|
296
|
+
'git checkout "origin/${BASE_REF}" -- $LANE_TOOLING' in wf)
|
|
297
|
+
check("…and unstages at once, so restored files can never be committed",
|
|
298
|
+
'git reset --quiet HEAD -- $LANE_TOOLING' in wf)
|
|
299
|
+
# Ordering: the restore must happen before anything invokes the orchestrator.
|
|
300
|
+
restore_at = wf.find('git checkout "origin/${BASE_REF}" -- $LANE_TOOLING')
|
|
301
|
+
first_use = wf.find("python3 scripts/ci/visual_evidence_autogen.py plan")
|
|
302
|
+
check("the restore runs BEFORE the first orchestrator call",
|
|
303
|
+
restore_at != -1 and first_use != -1 and restore_at < first_use)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def test_workflow_wiring() -> None:
|
|
307
|
+
print("contract — the workflow, the hooks, the hand-offs")
|
|
308
|
+
wf = REPO_ROOT / ".github/workflows/visual-evidence-autogen.yml"
|
|
309
|
+
check("workflow exists", wf.exists())
|
|
310
|
+
text = wf.read_text(encoding="utf-8") if wf.exists() else ""
|
|
311
|
+
for sub in ("plan", "generate", "decide", "bless", "stage", "commit-message", "comment", "teardown"):
|
|
312
|
+
check(f"workflow calls `{sub}`", f"visual_evidence_autogen.py {sub}" in text)
|
|
313
|
+
check("same-repo guard", "head.repo.full_name == github.repository" in text)
|
|
314
|
+
check("kill switch", "VISUAL_EVIDENCE_AUTOGEN_ENABLED" in text)
|
|
315
|
+
check("evidence-gate opt-out labels honoured", "skip-evidence" in text and "no-visual-change" in text)
|
|
316
|
+
check("the reviewer agent is the one invoked", "agent: visual-evidence-reviewer" in text)
|
|
317
|
+
check("never `git add -A`", not re.search(r"^\s*git add -A", text, re.M) and 'git add -- "${paths[@]}"' in text)
|
|
318
|
+
check("reviewer agent file exists", (REPO_ROOT / ".claude/agents/visual-evidence-reviewer.md").exists())
|
|
319
|
+
us = (REPO_ROOT / "test/update-snapshots.sh").read_text(encoding="utf-8")
|
|
320
|
+
for hook in ("PRE_TEST_SCRIPT", "POST_TEST_SCRIPT", "SKIP_PLAYWRIGHT"):
|
|
321
|
+
check(f"update-snapshots.sh exposes {hook}", hook in us)
|
|
322
|
+
for f in (vea.GENERIC_GENERATOR, vea.DIFF_MONTAGE_SCRIPT):
|
|
323
|
+
check(f"{f} exists", (REPO_ROOT / f).exists())
|
|
324
|
+
repair = (REPO_ROOT / ".github/workflows/ci-self-repair.yml").read_text(encoding="utf-8")
|
|
325
|
+
check("ci-self-repair hands a red Visual Snapshots job to this lane", "Visual Snapshots" in repair and "visual-evidence-autogen" in repair)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def main() -> int:
|
|
329
|
+
for t in (test_detect_slug, test_plan_pr_454_shape, test_plan_loop_guard_and_budget, test_plan_generic_fallback,
|
|
330
|
+
test_plan_respects_author_evidence, test_plan_out_of_scope, test_styling_matches_ci_filter,
|
|
331
|
+
test_ui_prefixes_match_gate, test_parse_results, test_decide, test_stage_and_messages,
|
|
332
|
+
test_stage_command_output, test_lane_tooling_contract, test_workflow_wiring):
|
|
333
|
+
t()
|
|
334
|
+
print(f"\n{PASSED} passed, {len(FAILURES)} failed")
|
|
335
|
+
for f in FAILURES:
|
|
336
|
+
print(f" FAILED: {f}")
|
|
337
|
+
return 1 if FAILURES else 0
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
if __name__ == "__main__":
|
|
341
|
+
sys.exit(main())
|