browser_review_gate 0.2.2 → 0.4.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 +48 -0
- data/README.md +152 -55
- data/lib/browser_review_gate/assessment.rb +74 -33
- data/lib/browser_review_gate/assessor.rb +38 -10
- data/lib/browser_review_gate/cli.rb +44 -19
- data/lib/browser_review_gate/config.rb +241 -10
- data/lib/browser_review_gate/doctor.rb +252 -0
- data/lib/browser_review_gate/evidence.rb +183 -0
- data/lib/browser_review_gate/gate.rb +8 -5
- data/lib/browser_review_gate/github.rb +7 -0
- data/lib/browser_review_gate/installer.rb +2 -1
- data/lib/browser_review_gate/model_api.rb +16 -0
- data/lib/browser_review_gate/model_client.rb +10 -0
- data/lib/browser_review_gate/prompts.rb +10 -5
- data/lib/browser_review_gate/publisher.rb +32 -9
- data/lib/browser_review_gate/report.rb +247 -47
- data/lib/browser_review_gate/revision_header.rb +72 -0
- data/lib/browser_review_gate/saved_report.rb +15 -1
- data/lib/browser_review_gate/stats.rb +90 -6
- data/lib/browser_review_gate/status.rb +63 -21
- data/lib/browser_review_gate/templates/agents_block.md.erb +1 -1
- data/lib/browser_review_gate/templates/config.yml.erb +74 -5
- data/lib/browser_review_gate/templates/playbook.md.erb +65 -25
- data/lib/browser_review_gate/templates/rules.md.erb +4 -1
- data/lib/browser_review_gate/trace_check.rb +119 -0
- data/lib/browser_review_gate/verify.rb +95 -0
- data/lib/browser_review_gate/version.rb +1 -1
- data/lib/browser_review_gate/zip_reader.rb +103 -0
- data/lib/browser_review_gate.rb +6 -0
- metadata +7 -1
|
@@ -27,6 +27,7 @@ module BrowserReviewGate
|
|
|
27
27
|
saved_report = SavedReport.new(sha: local_sha, shell: @shell)
|
|
28
28
|
data = load_report(saved_report)
|
|
29
29
|
ensure_report_sha!(data, local_sha)
|
|
30
|
+
ensure_environment!(data)
|
|
30
31
|
actor = @github.login
|
|
31
32
|
data["verified_by"] = "AI browser agent (run by #{actor})"
|
|
32
33
|
|
|
@@ -37,9 +38,10 @@ module BrowserReviewGate
|
|
|
37
38
|
complete = report.passed?
|
|
38
39
|
labelled = pull_request.fetch("labels").any? { |label| label["name"] == @config.label }
|
|
39
40
|
@github.remove_label(number, @config.label) if labelled && !complete
|
|
40
|
-
write_comment(number, comments, actor, body(report, complete))
|
|
41
|
+
write_comment(number, comments, actor, body(report, complete, @github.pull_request_commits(number)))
|
|
41
42
|
@github.add_label(number, @config.label) if complete
|
|
42
43
|
saved_report.delete unless @report_path
|
|
44
|
+
SavedReport.prune(shell: @shell, days: @config.evidence["retention_days"])
|
|
43
45
|
complete
|
|
44
46
|
rescue KeyError => error
|
|
45
47
|
raise Error, "Browser verification data is incomplete: #{error.message}"
|
|
@@ -53,11 +55,13 @@ module BrowserReviewGate
|
|
|
53
55
|
ensure_clean_worktree!
|
|
54
56
|
data = read_report_file
|
|
55
57
|
ensure_report_sha!(data, local_sha)
|
|
56
|
-
|
|
58
|
+
ensure_environment!(data)
|
|
59
|
+
report = Report.new(data.merge("verified_by" => "pending"), config: @config)
|
|
57
60
|
ensure_acceptable!(report)
|
|
58
61
|
|
|
59
62
|
saved_report = SavedReport.new(sha: local_sha, shell: @shell)
|
|
60
63
|
saved_report.write(data)
|
|
64
|
+
SavedReport.prune(shell: @shell, days: @config.evidence["retention_days"])
|
|
61
65
|
saved_report.path
|
|
62
66
|
rescue JSON::ParserError => error
|
|
63
67
|
raise Error, "Browser verification data is not valid JSON: #{error.message}"
|
|
@@ -89,14 +93,28 @@ module BrowserReviewGate
|
|
|
89
93
|
raise Error, "No saved browser verification report for the current commit"
|
|
90
94
|
end
|
|
91
95
|
|
|
96
|
+
# Evidence files are read here, next to the report, and kept in the git directory by digest.
|
|
92
97
|
def read_report_file
|
|
93
98
|
raise Error, "A report file is required" unless @report_path
|
|
94
99
|
|
|
95
|
-
JSON.parse(File.read(@report_path))
|
|
100
|
+
data = JSON.parse(File.read(@report_path))
|
|
101
|
+
evidence = Evidence.new(base_dir: File.dirname(File.expand_path(@report_path)), config: @config)
|
|
102
|
+
processed = evidence.process(data)
|
|
103
|
+
evidence.keep(SavedReport.evidence_dir(shell: @shell))
|
|
104
|
+
processed
|
|
96
105
|
rescue SystemCallError => error
|
|
97
106
|
raise Error, "Cannot read report file #{@report_path}: #{error.message}"
|
|
98
107
|
end
|
|
99
108
|
|
|
109
|
+
# A run names the environment it used; with environments configured it must be one of them.
|
|
110
|
+
def ensure_environment!(data)
|
|
111
|
+
name = data["environment"]
|
|
112
|
+
return if @config.environments.empty? ? Config::NAME_PATTERN.match?(name.to_s) : @config.environments.key?(name)
|
|
113
|
+
|
|
114
|
+
known = @config.environments.keys
|
|
115
|
+
raise Error, "Report environment must be #{known.empty? ? "a short lowercase name" : "one of #{known.join(", ")}"}, not #{name.inspect}"
|
|
116
|
+
end
|
|
117
|
+
|
|
100
118
|
# A report made before the PR existed may arrive before the gate has assessed the commit.
|
|
101
119
|
def ensure_browser_run_wanted!(number, comments, tested_sha)
|
|
102
120
|
assessment = Assessment.latest(comments)
|
|
@@ -106,11 +124,16 @@ module BrowserReviewGate
|
|
|
106
124
|
end
|
|
107
125
|
|
|
108
126
|
def merged_report(data, comments)
|
|
109
|
-
submitted = Report.new(data)
|
|
127
|
+
submitted = Report.new(data, config: @config)
|
|
110
128
|
ensure_acceptable!(submitted)
|
|
111
129
|
|
|
112
130
|
prior = Report.latest_comment(comments)
|
|
113
|
-
|
|
131
|
+
prior_data = begin
|
|
132
|
+
prior ? Report.data_from(prior["body"]) : {}
|
|
133
|
+
rescue Error
|
|
134
|
+
{}
|
|
135
|
+
end
|
|
136
|
+
report = Report.merge(prior_data, submitted.to_h, config: @config)
|
|
114
137
|
raise Error, report.errors.join("; ") unless report.valid?
|
|
115
138
|
|
|
116
139
|
report
|
|
@@ -118,15 +141,15 @@ module BrowserReviewGate
|
|
|
118
141
|
|
|
119
142
|
# A run is taken only with the evidence of each case: the page and what was observed there.
|
|
120
143
|
def ensure_acceptable!(report)
|
|
121
|
-
problems = report.valid? ? report.
|
|
144
|
+
problems = report.valid? ? report.submission_errors : report.errors
|
|
122
145
|
raise Error, problems.join("; ") if problems.any?
|
|
123
146
|
end
|
|
124
147
|
|
|
125
|
-
def body(report, complete)
|
|
126
|
-
return report.markdown if complete
|
|
148
|
+
def body(report, complete, commits)
|
|
149
|
+
return report.markdown(commits: commits) if complete
|
|
127
150
|
|
|
128
151
|
[ "**Not verified.** Complete every required browser scenario and pass all reported cases before human review.",
|
|
129
|
-
"", report.markdown ].join("\n")
|
|
152
|
+
"", report.markdown(commits: commits) ].join("\n")
|
|
130
153
|
end
|
|
131
154
|
|
|
132
155
|
# Only the author's own earlier report comment can be edited in place.
|
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
require "digest"
|
|
2
2
|
require "json"
|
|
3
|
+
require "time"
|
|
3
4
|
|
|
4
5
|
module BrowserReviewGate
|
|
5
|
-
# The browser cases an agent
|
|
6
|
+
# The browser cases an agent ran, with their outcomes and evidence. Published as one PR comment.
|
|
7
|
+
#
|
|
8
|
+
# Everything in a report is the agent's account except the file digests, which the publisher
|
|
9
|
+
# computed from files it read. The report is validated strictly: an unknown key, a malformed value
|
|
10
|
+
# or a missing field makes it invalid, and an invalid report counts for nothing.
|
|
6
11
|
class Report
|
|
7
12
|
MARKER = /<!--\s*browser-verification-report:([A-Za-z0-9_-]+)\s*-->/
|
|
8
|
-
|
|
13
|
+
SCHEMA_VERSION = 2
|
|
14
|
+
FULL_SHA = /\A\h{40}\z/
|
|
9
15
|
CASE_ID_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9_.-]{0,63}\z/
|
|
10
|
-
RESULTS = %w[pass fail].freeze
|
|
16
|
+
RESULTS = %w[pass fail skipped blocked].freeze
|
|
17
|
+
CAPABILITIES = %w[screenshots trace console network].freeze
|
|
11
18
|
URL_PATTERN = %r{\A(?:https?://|/)\S*\z}
|
|
19
|
+
TOP_KEYS = %w[schema_version tested_sha verified_by environment tool started_at finished_at coverage_complete
|
|
20
|
+
cases excluded runs failed_runs].freeze
|
|
21
|
+
CASE_KEYS = %w[id name result url details reason evidence tested_sha verified_by environment tool].freeze
|
|
22
|
+
EVIDENCE_KEYS = %w[files console_errors network_errors links trace_check].freeze
|
|
23
|
+
FILE_KEYS = %w[kind name media_type bytes sha256].freeze
|
|
12
24
|
|
|
13
25
|
attr_reader :errors
|
|
14
26
|
|
|
@@ -24,34 +36,29 @@ module BrowserReviewGate
|
|
|
24
36
|
end
|
|
25
37
|
|
|
26
38
|
# The newest report on the PR when it is well formed, or nil.
|
|
27
|
-
def self.latest(comments)
|
|
39
|
+
def self.latest(comments, config: Config.new)
|
|
28
40
|
comment = latest_comment(comments)
|
|
29
|
-
report = comment && new(data_from(comment["body"]))
|
|
41
|
+
report = comment && new(data_from(comment["body"]), config: config)
|
|
30
42
|
report if report&.valid?
|
|
31
43
|
rescue Error
|
|
32
44
|
nil
|
|
33
45
|
end
|
|
34
46
|
|
|
35
|
-
# The newest report on the PR when it passes, or nil.
|
|
36
|
-
def self.latest_passing(comments)
|
|
37
|
-
report = latest(comments)
|
|
38
|
-
report if report&.passed?
|
|
39
|
-
end
|
|
40
|
-
|
|
41
47
|
# Later results replace earlier ones case by case; cases that were not run again are kept. The
|
|
42
48
|
# merged report counts the runs published on the PR and how many of them had a failing case.
|
|
43
|
-
def self.merge(previous_data, latest_data)
|
|
44
|
-
previous = new(previous_data)
|
|
45
|
-
latest = new(latest_data)
|
|
49
|
+
def self.merge(previous_data, latest_data, config: Config.new)
|
|
50
|
+
previous = new(previous_data, config: config)
|
|
51
|
+
latest = new(latest_data, config: config)
|
|
46
52
|
return latest unless latest.valid?
|
|
47
53
|
|
|
48
54
|
combined = previous.valid? ? previous.cases.to_h { |test_case| [ test_case["id"], test_case ] } : {}
|
|
49
55
|
latest.cases.each { |test_case| combined[test_case["id"]] = test_case }
|
|
50
|
-
failed = latest.cases.
|
|
51
|
-
new(latest.to_h.merge("cases" => combined.values, "runs" => previous.runs + 1, "failed_runs" => previous.failed_runs + failed))
|
|
56
|
+
failed = latest.cases.any? { |test_case| test_case["result"] == "fail" } ? 1 : 0
|
|
57
|
+
new(latest.to_h.merge("cases" => combined.values, "runs" => previous.runs + 1, "failed_runs" => previous.failed_runs + failed), config: config)
|
|
52
58
|
end
|
|
53
59
|
|
|
54
|
-
def initialize(data)
|
|
60
|
+
def initialize(data, config: Config.new)
|
|
61
|
+
@config = config
|
|
55
62
|
@errors = []
|
|
56
63
|
@data = normalize(data.is_a?(Hash) ? data : {})
|
|
57
64
|
attach_defaults_to_cases
|
|
@@ -61,49 +68,151 @@ module BrowserReviewGate
|
|
|
61
68
|
def valid? = errors.empty?
|
|
62
69
|
def tested_sha = @data["tested_sha"]
|
|
63
70
|
def verified_by = @data["verified_by"]
|
|
71
|
+
def environment = @data["environment"]
|
|
72
|
+
def tool = @data["tool"].is_a?(Hash) ? @data["tool"] : {}
|
|
64
73
|
def cases = @data["cases"].is_a?(Array) ? @data["cases"] : []
|
|
65
74
|
def excluded = @data["excluded"].is_a?(Array) ? @data["excluded"] : []
|
|
66
75
|
def runs = @data["runs"].is_a?(Integer) ? @data["runs"] : 0
|
|
67
76
|
def failed_runs = @data["failed_runs"].is_a?(Integer) ? @data["failed_runs"] : 0
|
|
77
|
+
def coverage_complete? = @data["coverage_complete"] == true
|
|
68
78
|
def to_h = JSON.parse(JSON.generate(@data))
|
|
69
|
-
|
|
79
|
+
|
|
80
|
+
# Seconds the run took, when the agent gave both times.
|
|
81
|
+
def duration
|
|
82
|
+
return unless @data["started_at"] && @data["finished_at"]
|
|
83
|
+
|
|
84
|
+
Time.iso8601(@data["finished_at"]) - Time.iso8601(@data["started_at"])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Ids of the cases that count as passed under the project's policy, limited to cases tested on
|
|
88
|
+
# `commits` when that list is given (the commits of the PR: a case from elsewhere proves nothing).
|
|
89
|
+
def passed_ids(commits: nil)
|
|
90
|
+
cases.select { |test_case| counts?(test_case) && (commits.nil? || commits.include?(test_case["tested_sha"])) }
|
|
91
|
+
.map { |test_case| test_case["id"] }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# passed, failed, skipped, blocked, or missing when no case has the id. A case that says pass but
|
|
95
|
+
# breaks a policy (console errors, missing evidence) is failed.
|
|
96
|
+
def status_of(id, commits: nil)
|
|
97
|
+
test_case = cases.find { |entry| entry["id"] == id }
|
|
98
|
+
return "missing" unless test_case
|
|
99
|
+
return "stale" if commits && !commits.include?(test_case["tested_sha"])
|
|
100
|
+
return "passed" if counts?(test_case)
|
|
101
|
+
|
|
102
|
+
test_case["result"] == "pass" ? "failed" : { "fail" => "failed" }.fetch(test_case["result"], test_case["result"])
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Why a case that says pass does not count, or nil.
|
|
106
|
+
def policy_problem(test_case)
|
|
107
|
+
evidence = test_case["evidence"].is_a?(Hash) ? test_case["evidence"] : {}
|
|
108
|
+
if @config.verification["fail_on_console_errors"] && Array(evidence["console_errors"]).any?
|
|
109
|
+
return "console errors were reported"
|
|
110
|
+
end
|
|
111
|
+
if @config.verification["fail_on_network_errors"] && Array(evidence["network_errors"]).any?
|
|
112
|
+
return "failed network requests were reported"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
missing_evidence(test_case, evidence) || trace_problem(evidence)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def failed? = cases.any? { |test_case| status_of(test_case["id"]) == "failed" }
|
|
119
|
+
|
|
120
|
+
# Complete, and nothing failed when failures block review.
|
|
121
|
+
def passed?
|
|
122
|
+
valid? && coverage_complete? && (!@config.gate["block_on_failure"] || !failed?)
|
|
123
|
+
end
|
|
70
124
|
|
|
71
125
|
# Changes whenever a case is added, re-run or changes its outcome.
|
|
72
126
|
def fingerprint
|
|
73
127
|
Digest::SHA256.hexdigest(JSON.generate(cases.map { |test_case| test_case.values_at("id", "result", "tested_sha") }))[0, 16]
|
|
74
128
|
end
|
|
75
129
|
|
|
76
|
-
# What a newly submitted run must say
|
|
77
|
-
#
|
|
78
|
-
def
|
|
130
|
+
# What a newly submitted run must say beyond the schema: where each case ran and what was seen,
|
|
131
|
+
# and the evidence the project requires.
|
|
132
|
+
def submission_errors
|
|
79
133
|
cases.each_with_index.flat_map do |test_case, index|
|
|
80
|
-
|
|
81
|
-
|
|
134
|
+
position = index + 1
|
|
82
135
|
found = []
|
|
83
|
-
|
|
84
|
-
|
|
136
|
+
if %w[pass fail].include?(test_case["result"])
|
|
137
|
+
found << "case #{position} url must be the page that was exercised: an http(s) URL or a path starting with /" unless url?(test_case["url"])
|
|
138
|
+
else
|
|
139
|
+
found << "case #{position} is #{test_case["result"]} and needs a reason" unless text?(test_case["reason"], 300)
|
|
140
|
+
end
|
|
141
|
+
found << "case #{position} details must say what was observed" unless text?(test_case["details"], 500)
|
|
142
|
+
evidence = test_case["evidence"].is_a?(Hash) ? test_case["evidence"] : {}
|
|
143
|
+
problem = test_case["result"] == "pass" ? (missing_evidence(test_case, evidence) || trace_problem(evidence)) : nil
|
|
144
|
+
found << "case #{position} #{problem}" if problem
|
|
85
145
|
found
|
|
86
146
|
end
|
|
87
147
|
end
|
|
88
148
|
|
|
89
|
-
def
|
|
90
|
-
valid? && @data["coverage_complete"] && cases.all? { |test_case| test_case["result"] == "pass" }
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def markdown
|
|
149
|
+
def markdown(scenarios: nil, commits: nil)
|
|
94
150
|
return "Browser verification report is invalid: #{errors.map { |error| Markdown.escape(error) }.join("; ")}" unless valid?
|
|
95
151
|
|
|
96
|
-
coverage =
|
|
152
|
+
coverage = coverage_complete? ? "Complete scenario run." : "Scenario run is incomplete."
|
|
153
|
+
tool_name = [ tool["name"], tool["version"] ].compact.map { |part| Markdown.escape(part) }.join(" ")
|
|
97
154
|
lines = [ "## Browser verification", "",
|
|
98
|
-
"Verified by **#{Markdown.escape(verified_by)}** for commit `#{tested_sha}
|
|
99
|
-
|
|
155
|
+
"Verified by **#{Markdown.escape(verified_by)}** for commit `#{tested_sha}` in the `#{environment}` environment " \
|
|
156
|
+
"with #{tool_name}#{timing}. #{coverage}", "",
|
|
157
|
+
"| Case | Result | Page | Commit | Evidence |", "|---|---|---|---|---|" ]
|
|
158
|
+
lines.concat(cases.map { |test_case| markdown_case(test_case, commits) })
|
|
159
|
+
details = cases.select { |test_case| test_case["details"] || test_case["reason"] }
|
|
160
|
+
lines.concat([ "", "<details><summary>What the agent observed</summary>", "" ] +
|
|
161
|
+
details.map { |test_case| "- `#{test_case["id"]}`: #{Markdown.escape([ test_case["details"], test_case["reason"] ].compact.join(" "))}" } +
|
|
162
|
+
[ "", "</details>" ]) if details.any?
|
|
100
163
|
lines.concat([ "", "Not exercised in the browser, by the author's decision:" ] + excluded.map { |entry| markdown_excluded(entry) }) if excluded.any?
|
|
164
|
+
lines.concat([ "", "Observations, pages and errors are reported by the agent. File digests were computed by the publisher " \
|
|
165
|
+
"from files it read; they show the files existed, not that a browser made them." ])
|
|
101
166
|
payload = Marker.encode(JSON.generate(to_h))
|
|
102
167
|
lines.concat([ "", "<!-- browser-verification-report:#{payload} -->" ]).join("\n")
|
|
103
168
|
end
|
|
104
169
|
|
|
105
170
|
private
|
|
106
171
|
|
|
172
|
+
def counts?(test_case)
|
|
173
|
+
test_case["result"] == "pass" && policy_problem(test_case).nil?
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The evidence the project asks for and the tool could produce. A tool that does not declare a
|
|
177
|
+
# capability is not asked for it; that gap is visible in the PR.
|
|
178
|
+
def missing_evidence(test_case, evidence)
|
|
179
|
+
return unless @config.gate["require_evidence"]
|
|
180
|
+
|
|
181
|
+
capabilities = Array(tool_of(test_case)["capabilities"])
|
|
182
|
+
files = Array(evidence["files"])
|
|
183
|
+
kinds = { "screenshots" => "screenshot", "trace" => "trace" }
|
|
184
|
+
kinds.each do |setting, kind|
|
|
185
|
+
mode = @config.evidence[setting]
|
|
186
|
+
wanted = mode == "always" || (mode == "on_failure" && test_case["result"] == "fail")
|
|
187
|
+
next unless wanted && capabilities.include?(setting)
|
|
188
|
+
return "has no #{kind} although evidence.#{setting} is #{mode}" if files.none? { |file| file["kind"] == kind }
|
|
189
|
+
end
|
|
190
|
+
{ "console_logs" => [ "console", "console_errors" ], "network_logs" => [ "network", "network_errors" ] }.each do |setting, (capability, key)|
|
|
191
|
+
next unless @config.evidence[setting] && capabilities.include?(capability)
|
|
192
|
+
return "does not say whether there were #{key.tr("_", " ")} (evidence.#{key})" unless evidence.key?(key)
|
|
193
|
+
end
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# With provenance.trace require, a passing case counts only when its trace showed the run.
|
|
198
|
+
def trace_problem(evidence)
|
|
199
|
+
return unless @config.provenance["trace"] == "require"
|
|
200
|
+
|
|
201
|
+
check = evidence["trace_check"]
|
|
202
|
+
return "has no Playwright trace, which provenance.trace requires" unless check.is_a?(Hash)
|
|
203
|
+
return if check["status"] == "verified"
|
|
204
|
+
|
|
205
|
+
"trace does not show the run: #{Array(check["findings"]).first || "the check failed"}"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# A carried-over case keeps the tool of the run that produced it.
|
|
209
|
+
def tool_of(test_case) = test_case["tool"].is_a?(Hash) ? test_case["tool"] : tool
|
|
210
|
+
|
|
211
|
+
def timing
|
|
212
|
+
seconds = duration
|
|
213
|
+
seconds ? " in #{seconds.round} s" : ""
|
|
214
|
+
end
|
|
215
|
+
|
|
107
216
|
def normalize(value)
|
|
108
217
|
case value
|
|
109
218
|
when Hash then value.to_h { |key, nested| [ key.to_s, normalize(nested) ] }
|
|
@@ -116,21 +225,56 @@ module BrowserReviewGate
|
|
|
116
225
|
cases.each do |test_case|
|
|
117
226
|
next unless test_case.is_a?(Hash)
|
|
118
227
|
|
|
119
|
-
|
|
120
|
-
test_case["verified_by"] ||= @data["verified_by"]
|
|
228
|
+
%w[tested_sha verified_by environment tool].each { |key| test_case[key] ||= @data[key] }
|
|
121
229
|
end
|
|
122
230
|
end
|
|
123
231
|
|
|
124
232
|
def validate
|
|
125
|
-
|
|
233
|
+
unknown = @data.keys - TOP_KEYS
|
|
234
|
+
errors << "unknown report keys: #{unknown.join(", ")}" if unknown.any?
|
|
235
|
+
errors << "schema_version must be #{SCHEMA_VERSION}" unless @data["schema_version"] == SCHEMA_VERSION
|
|
236
|
+
errors << "tested_sha must be a full 40-character commit SHA" unless sha?(@data["tested_sha"])
|
|
126
237
|
errors << "verified_by must be a non-empty string of at most 100 characters" unless text?(@data["verified_by"], 100)
|
|
238
|
+
errors << "environment must name an environment from the settings" unless name?(@data["environment"])
|
|
127
239
|
errors << "coverage_complete must be true or false" unless [ true, false ].include?(@data["coverage_complete"])
|
|
240
|
+
validate_tool
|
|
241
|
+
validate_times
|
|
128
242
|
validate_cases
|
|
129
243
|
validate_excluded
|
|
244
|
+
%w[runs failed_runs].each do |key|
|
|
245
|
+
errors << "#{key} must be a whole number" unless @data[key].nil? || (@data[key].is_a?(Integer) && @data[key] >= 0)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def validate_tool
|
|
250
|
+
tool_data = @data["tool"]
|
|
251
|
+
return errors << "tool must say which browser tool ran the cases: {\"name\": ..., \"capabilities\": [...]}" unless tool_data.is_a?(Hash)
|
|
252
|
+
|
|
253
|
+
unknown = tool_data.keys - %w[name version capabilities]
|
|
254
|
+
errors << "unknown tool keys: #{unknown.join(", ")}" if unknown.any?
|
|
255
|
+
errors << "tool name must be a non-empty string of at most 60 characters" unless text?(tool_data["name"], 60)
|
|
256
|
+
errors << "tool version must be a string of at most 40 characters" unless tool_data["version"].nil? || text?(tool_data["version"], 40)
|
|
257
|
+
capabilities = tool_data["capabilities"]
|
|
258
|
+
return if capabilities.is_a?(Array) && (capabilities - CAPABILITIES).empty? && capabilities.uniq.size == capabilities.size
|
|
259
|
+
|
|
260
|
+
errors << "tool capabilities must be a list drawn from #{CAPABILITIES.join(", ")}"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def validate_times
|
|
264
|
+
times = %w[started_at finished_at].to_h do |key|
|
|
265
|
+
[ key, Time.iso8601(@data[key].to_s) ]
|
|
266
|
+
rescue ArgumentError
|
|
267
|
+
errors << "#{key} must be an ISO 8601 time"
|
|
268
|
+
[ key, nil ]
|
|
269
|
+
end
|
|
270
|
+
return unless times.values.all?
|
|
271
|
+
|
|
272
|
+
errors << "finished_at must not be before started_at" if times["finished_at"] < times["started_at"]
|
|
130
273
|
end
|
|
131
274
|
|
|
132
275
|
def validate_cases
|
|
133
276
|
return errors << "cases must be a non-empty array" unless @data["cases"].is_a?(Array) && cases.any?
|
|
277
|
+
return errors << "cases must have at most 60 entries" if cases.size > 60
|
|
134
278
|
|
|
135
279
|
seen_ids = []
|
|
136
280
|
cases.each_with_index do |test_case, index|
|
|
@@ -141,6 +285,8 @@ module BrowserReviewGate
|
|
|
141
285
|
end
|
|
142
286
|
|
|
143
287
|
def validate_case(test_case, position, seen_ids)
|
|
288
|
+
unknown = test_case.keys - CASE_KEYS
|
|
289
|
+
errors << "case #{position} has unknown keys: #{unknown.join(", ")}" if unknown.any?
|
|
144
290
|
id = test_case["id"]
|
|
145
291
|
if !(id.is_a?(String) && CASE_ID_PATTERN.match?(id))
|
|
146
292
|
errors << "case #{position} id must use letters, numbers, dots, underscores, or hyphens"
|
|
@@ -150,12 +296,49 @@ module BrowserReviewGate
|
|
|
150
296
|
seen_ids << id
|
|
151
297
|
end
|
|
152
298
|
errors << "case #{position} name must be a non-empty string of at most 200 characters" unless text?(test_case["name"], 200)
|
|
153
|
-
errors << "case #{position} result must be
|
|
154
|
-
errors << "case #{position} tested_sha must be a
|
|
299
|
+
errors << "case #{position} result must be one of #{RESULTS.join(", ")}" unless RESULTS.include?(test_case["result"])
|
|
300
|
+
errors << "case #{position} tested_sha must be a full commit SHA" unless sha?(test_case["tested_sha"])
|
|
155
301
|
errors << "case #{position} verified_by must be a non-empty string of at most 100 characters" unless text?(test_case["verified_by"], 100)
|
|
302
|
+
errors << "case #{position} environment must be a short lowercase name" unless name?(test_case["environment"])
|
|
156
303
|
errors << "case #{position} url must be a string of at most 300 characters" unless test_case["url"].nil? || url?(test_case["url"])
|
|
157
|
-
details
|
|
158
|
-
errors << "case #{position}
|
|
304
|
+
errors << "case #{position} details must be a string of at most 500 characters" unless test_case["details"].nil? || text?(test_case["details"], 500)
|
|
305
|
+
errors << "case #{position} reason must be a string of at most 300 characters" unless test_case["reason"].nil? || text?(test_case["reason"], 300)
|
|
306
|
+
errors << "case #{position} tool must name the browser tool" unless test_case["tool"].is_a?(Hash) && text?(test_case["tool"]["name"], 60)
|
|
307
|
+
validate_evidence(test_case["evidence"], position) unless test_case["evidence"].nil?
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# The published form of evidence; the submitted form is turned into this by Evidence.
|
|
311
|
+
def validate_evidence(evidence, position)
|
|
312
|
+
return errors << "case #{position} evidence must be an object" unless evidence.is_a?(Hash)
|
|
313
|
+
|
|
314
|
+
unknown = evidence.keys - EVIDENCE_KEYS
|
|
315
|
+
errors << "case #{position} evidence has unknown keys: #{unknown.join(", ")}" if unknown.any?
|
|
316
|
+
files = evidence["files"]
|
|
317
|
+
unless files.nil? || (files.is_a?(Array) && files.size <= Evidence::MAX_FILES && files.all? { |file| file?(file) })
|
|
318
|
+
errors << "case #{position} evidence files are malformed"
|
|
319
|
+
end
|
|
320
|
+
validate_trace_check(evidence["trace_check"], position) unless evidence["trace_check"].nil?
|
|
321
|
+
links = Array(evidence["links"])
|
|
322
|
+
errors << "case #{position} evidence links must be https URLs" unless links.all? { |link| link.to_s.match?(%r{\Ahttps://[^\s()<>]+\z}) }
|
|
323
|
+
%w[console_errors network_errors links].each do |key|
|
|
324
|
+
value = evidence[key]
|
|
325
|
+
next if value.nil? || (value.is_a?(Array) && value.size <= Evidence::MAX_ENTRIES && value.all? { |entry| entry.is_a?(String) && entry.length <= Evidence::MAX_ENTRY })
|
|
326
|
+
|
|
327
|
+
errors << "case #{position} evidence #{key} must be a list of short strings"
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def validate_trace_check(check, position)
|
|
332
|
+
valid = check.is_a?(Hash) && (check.keys - %w[status revision requests findings]).empty? &&
|
|
333
|
+
%w[verified failed].include?(check["status"]) && (check["revision"].nil? || check["revision"].to_s.match?(/\A\h{7,12}\z/)) &&
|
|
334
|
+
check["requests"].is_a?(Integer) && check["findings"].is_a?(Array) && check["findings"].size <= 5 &&
|
|
335
|
+
check["findings"].all? { |finding| finding.is_a?(String) && finding.length <= 300 }
|
|
336
|
+
errors << "case #{position} evidence trace_check is malformed" unless valid
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def file?(file)
|
|
340
|
+
file.is_a?(Hash) && (file.keys - FILE_KEYS).empty? && Evidence::KINDS.value?(file["kind"]) && text?(file["name"], 100) &&
|
|
341
|
+
file["bytes"].is_a?(Integer) && file["sha256"].to_s.match?(/\A\h{64}\z/) && Evidence::TYPES.values.map(&:first).include?(file["media_type"])
|
|
159
342
|
end
|
|
160
343
|
|
|
161
344
|
# Scenarios the author decided not to run locally, e.g. a sign-in through an external provider.
|
|
@@ -164,21 +347,38 @@ module BrowserReviewGate
|
|
|
164
347
|
return errors << "excluded must be an array" unless @data["excluded"].is_a?(Array)
|
|
165
348
|
|
|
166
349
|
excluded.each_with_index do |entry, index|
|
|
167
|
-
next if entry.is_a?(Hash) && text?(entry["name"], 200) && text?(entry["reason"], 500)
|
|
350
|
+
next if entry.is_a?(Hash) && (entry.keys - %w[id name reason]).empty? && text?(entry["name"], 200) && text?(entry["reason"], 500) &&
|
|
351
|
+
(entry["id"].nil? || CASE_ID_PATTERN.match?(entry["id"].to_s))
|
|
168
352
|
|
|
169
353
|
errors << "excluded #{index + 1} needs a name and a reason"
|
|
170
354
|
end
|
|
171
355
|
end
|
|
172
356
|
|
|
173
|
-
def sha?(value) = value.is_a?(String) &&
|
|
357
|
+
def sha?(value) = value.is_a?(String) && FULL_SHA.match?(value)
|
|
358
|
+
def name?(value) = value.is_a?(String) && Config::NAME_PATTERN.match?(value)
|
|
174
359
|
def text?(value, limit) = value.is_a?(String) && !value.strip.empty? && value.length <= limit
|
|
175
360
|
def url?(value) = value.is_a?(String) && value.length <= 300 && URL_PATTERN.match?(value)
|
|
176
361
|
|
|
177
|
-
def markdown_case(test_case)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
362
|
+
def markdown_case(test_case, commits)
|
|
363
|
+
status = status_of(test_case["id"], commits: commits)
|
|
364
|
+
problem = test_case["result"] == "pass" && status == "failed" ? " (#{policy_problem(test_case)})" : ""
|
|
365
|
+
page = test_case["url"].to_s.empty? ? "" : Markdown.escape(test_case["url"])
|
|
366
|
+
"| `#{test_case["id"]}` #{Markdown.escape(test_case["name"])} | #{status.upcase}#{Markdown.escape(problem)} | #{page} | " \
|
|
367
|
+
"`#{test_case["tested_sha"][0, 7]}` | #{markdown_evidence(test_case["evidence"])} |"
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def markdown_evidence(evidence)
|
|
371
|
+
return "none" unless evidence.is_a?(Hash)
|
|
372
|
+
|
|
373
|
+
parts = Array(evidence["files"]).map { |file| "#{file["kind"]} `#{Markdown.escape(file["name"])}` (#{file["bytes"]} B, sha256 `#{file["sha256"][0, 12]}`)" }
|
|
374
|
+
parts << "#{evidence["console_errors"].size} console errors" if evidence["console_errors"]
|
|
375
|
+
parts << "#{evidence["network_errors"].size} failed requests" if evidence["network_errors"]
|
|
376
|
+
parts.concat(Array(evidence["links"]).each_with_index.map { |link, index| "[link #{index + 1}](#{link})" })
|
|
377
|
+
if (check = evidence["trace_check"])
|
|
378
|
+
parts << (check["status"] == "verified" ? "trace verified: revision `#{check["revision"]}`, #{check["requests"]} requests" :
|
|
379
|
+
"trace check failed: #{Markdown.escape(check["findings"].first)}")
|
|
380
|
+
end
|
|
381
|
+
parts.empty? ? "none" : parts.join("<br>")
|
|
182
382
|
end
|
|
183
383
|
|
|
184
384
|
def markdown_excluded(entry)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module BrowserReviewGate
|
|
2
|
+
# Rack middleware that names the commit the app is serving in a response header, so a Playwright
|
|
3
|
+
# trace shows which code a browser run exercised (`provenance.trace`). Meant for development and
|
|
4
|
+
# test environments only: it reads the checkout's HEAD, and a production app has no business telling
|
|
5
|
+
# visitors its commit.
|
|
6
|
+
#
|
|
7
|
+
# # config/environments/development.rb
|
|
8
|
+
# config.middleware.use BrowserReviewGate::RevisionHeader
|
|
9
|
+
#
|
|
10
|
+
# HEAD is read again whenever .git/HEAD or the branch it points to changes, so a new commit shows up
|
|
11
|
+
# without a restart. A worktree with uncommitted changes is still reported as HEAD: publishing a run
|
|
12
|
+
# requires a clean worktree anyway.
|
|
13
|
+
class RevisionHeader
|
|
14
|
+
def initialize(app, header: "X-App-Revision", root: Dir.pwd)
|
|
15
|
+
@app = app
|
|
16
|
+
@header = header.downcase # Rack 3 wants lowercase header names
|
|
17
|
+
@git_dir = git_dir(root)
|
|
18
|
+
@cached = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(env)
|
|
22
|
+
status, headers, body = @app.call(env)
|
|
23
|
+
revision = current_revision
|
|
24
|
+
headers[@header] = revision if revision
|
|
25
|
+
[ status, headers, body ]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def current_revision
|
|
31
|
+
return unless @git_dir
|
|
32
|
+
|
|
33
|
+
head_path = File.join(@git_dir, "HEAD")
|
|
34
|
+
head = File.read(head_path).strip
|
|
35
|
+
ref_path = head.start_with?("ref: ") ? File.join(common_dir, head.delete_prefix("ref: ")) : nil
|
|
36
|
+
stamp = [ head, File.mtime(head_path), ref_path && File.exist?(ref_path) ? File.mtime(ref_path) : nil ]
|
|
37
|
+
return @cached.last if @cached&.first == stamp
|
|
38
|
+
|
|
39
|
+
@cached = [ stamp, resolve(head, ref_path) ]
|
|
40
|
+
@cached.last
|
|
41
|
+
rescue SystemCallError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def resolve(head, ref_path)
|
|
46
|
+
return head[/\A\h{40}\z/] unless ref_path
|
|
47
|
+
return File.read(ref_path).strip[/\A\h{40}\z/] if File.exist?(ref_path)
|
|
48
|
+
|
|
49
|
+
# A ref that was packed: look it up in packed-refs.
|
|
50
|
+
packed = File.join(common_dir, "packed-refs")
|
|
51
|
+
ref = head.delete_prefix("ref: ")
|
|
52
|
+
File.exist?(packed) ? File.foreach(packed).find { |line| line.strip.end_with?(" #{ref}") }.to_s[/\A\h{40}/] : nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A worktree's .git is a file pointing at its git directory; branches live in the common one.
|
|
56
|
+
def git_dir(root)
|
|
57
|
+
dot_git = File.join(root, ".git")
|
|
58
|
+
return dot_git if File.directory?(dot_git)
|
|
59
|
+
return unless File.file?(dot_git)
|
|
60
|
+
|
|
61
|
+
path = File.read(dot_git)[/\Agitdir: (.+)$/, 1]
|
|
62
|
+
path && File.expand_path(path.strip, root)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def common_dir
|
|
66
|
+
@common_dir ||= begin
|
|
67
|
+
pointer = File.join(@git_dir, "commondir")
|
|
68
|
+
File.exist?(pointer) ? File.expand_path(File.read(pointer).strip, @git_dir) : @git_dir
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -5,13 +5,27 @@ module BrowserReviewGate
|
|
|
5
5
|
# Results of a browser run made before the PR exists, kept inside the git directory for one commit.
|
|
6
6
|
# They are published once a PR for that commit is opened.
|
|
7
7
|
class SavedReport
|
|
8
|
+
DIR = "browser-verification"
|
|
9
|
+
|
|
10
|
+
# Where evidence files are kept, by digest, for runs saved or published from this checkout.
|
|
11
|
+
def self.evidence_dir(shell: Shell.new)
|
|
12
|
+
shell.call("git", "rev-parse", "--git-path", "#{DIR}/evidence").strip
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Removes saved runs and kept evidence older than `days`.
|
|
16
|
+
def self.prune(shell: Shell.new, days: 14)
|
|
17
|
+
dir = shell.call("git", "rev-parse", "--git-path", DIR).strip
|
|
18
|
+
cutoff = Time.now - days * 86_400
|
|
19
|
+
Dir.glob(File.join(dir, "{*.json,evidence/*}")).each { |path| FileUtils.rm_f(path) if File.file?(path) && File.mtime(path) < cutoff }
|
|
20
|
+
end
|
|
21
|
+
|
|
8
22
|
def initialize(sha:, shell: Shell.new)
|
|
9
23
|
@sha = sha
|
|
10
24
|
@shell = shell
|
|
11
25
|
end
|
|
12
26
|
|
|
13
27
|
def path
|
|
14
|
-
@path ||= @shell.call("git", "rev-parse", "--git-path", "
|
|
28
|
+
@path ||= @shell.call("git", "rev-parse", "--git-path", "#{DIR}/#{@sha}.json").strip
|
|
15
29
|
end
|
|
16
30
|
|
|
17
31
|
def exist? = File.exist?(path)
|