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.
@@ -1,6 +1,12 @@
1
+ require "time"
2
+
1
3
  module BrowserReviewGate
2
- # What the gate did on recent pull requests, read back from its own comments and labels.
4
+ # What the gate did on recent pull requests, read back from its own comments and labels. Only what
5
+ # the comments record is counted: each PR keeps its latest assessment and its merged report, so
6
+ # earlier assessments of the same PR and their token usage are not visible any more.
3
7
  class Stats
8
+ COUNTS = %w[pull_requests assessed required verified waived owed found_a_failure runs failed_runs].freeze
9
+
4
10
  def initialize(github:, config: Config.new)
5
11
  @github = github
6
12
  @config = config
@@ -8,27 +14,105 @@ module BrowserReviewGate
8
14
 
9
15
  def to_h(since:)
10
16
  counts = Hash.new(0)
11
- @github.pull_requests_updated_since(since).each { |pull_request| count(pull_request, counts) }
12
- %w[pull_requests assessed required verified waived owed found_a_failure].to_h { |key| [ key, counts[key] ] }
17
+ failing = Hash.new(0)
18
+ hours = []
19
+ usage = { "assessments_with_usage" => 0, "input_tokens" => 0, "output_tokens" => 0, "assessments_with_cost" => 0, "cost_usd" => 0.0 }
20
+ @github.pull_requests_updated_since(since).each { |pull_request| count(pull_request, counts, failing, hours, usage) }
21
+
22
+ result = COUNTS.to_h { |key| [ key, counts[key] ] }
23
+ result["failing_scenarios"] = failing.sort_by { |id, times| [ -times, id ] }.first(10).to_h
24
+ result["hours_to_verification"] = hours.empty? ? nil : { "median" => median(hours).round(1), "measured" => hours.size }
25
+ usage["cost_usd"] = usage["cost_usd"].round(4)
26
+ result["assessment_usage"] = usage
27
+ result
28
+ end
29
+
30
+ def self.text(result, days)
31
+ lines = [ "Pull requests touched in the last #{days} days: #{result["pull_requests"]}",
32
+ " assessed by the gate: #{result["assessed"]}",
33
+ " needed a browser run: #{result["required"]}",
34
+ " verified by a published run: #{result["verified"]}",
35
+ " waived: #{result["waived"]}",
36
+ " open and still owing a run: #{result["owed"]}",
37
+ " a run found a failing case: #{result["found_a_failure"]}",
38
+ " browser runs published: #{result["runs"]} (#{result["failed_runs"]} with a failing case)" ]
39
+ if (time = result["hours_to_verification"])
40
+ lines << " PR opened to verified run: median #{time["median"]} h over #{time["measured"]} PRs"
41
+ end
42
+ result["failing_scenarios"].each_with_index do |(id, times), index|
43
+ lines << " #{index.zero? ? "failing scenarios:" : ""}".ljust(34) + "#{id} (#{times})"
44
+ end
45
+ usage = result["assessment_usage"]
46
+ if usage["assessments_with_usage"].positive?
47
+ lines << " latest assessments, tokens: #{usage["input_tokens"]} in, #{usage["output_tokens"]} out " \
48
+ "(#{usage["assessments_with_usage"]} reported)"
49
+ end
50
+ if usage["assessments_with_cost"].positive?
51
+ lines << " latest assessments, cost: $#{usage["cost_usd"]} (#{usage["assessments_with_cost"]} reported by the provider)"
52
+ end
53
+ lines.join("\n")
13
54
  end
14
55
 
15
56
  private
16
57
 
17
- def count(pull_request, counts)
58
+ def count(pull_request, counts, failing, hours, usage)
18
59
  counts["pull_requests"] += 1
19
- comments = @github.trusted_comments(pull_request.fetch("number"))
60
+ number = pull_request.fetch("number")
61
+ comments = @github.trusted_comments(number)
20
62
  assessment = Assessment.latest(comments)
21
63
  return unless assessment
22
64
 
23
65
  counts["assessed"] += 1
66
+ add_usage(assessment.usage, usage)
24
67
  return unless assessment.required?
25
68
 
26
69
  labels = Array(pull_request["labels"]).map { |label| label["name"] }
70
+ report = Report.latest(comments, config: @config)
27
71
  counts["required"] += 1
28
72
  counts["verified"] += 1 if labels.include?(@config.label)
29
73
  counts["waived"] += 1 if labels.include?(@config.waiver_label)
30
74
  counts["owed"] += 1 if pull_request["state"] == "open" && !labels.intersect?([ @config.label, @config.waiver_label ])
31
- counts["found_a_failure"] += 1 if Report.latest(comments)&.failed_runs.to_i.positive?
75
+ return unless report
76
+
77
+ counts["runs"] += report.runs
78
+ counts["failed_runs"] += report.failed_runs
79
+ counts["found_a_failure"] += 1 if report.failed_runs.positive?
80
+ report.cases.each { |test_case| failing[test_case["id"]] += 1 if report.status_of(test_case["id"]) == "failed" }
81
+ measure(pull_request, comments, labels, hours)
82
+ end
83
+
84
+ # From the PR being opened to the report comment's last update, for PRs that are verified now.
85
+ def measure(pull_request, comments, labels, hours)
86
+ return unless labels.include?(@config.label)
87
+
88
+ comment = Report.latest_comment(comments)
89
+ opened = pull_request["created_at"]
90
+ published = comment && (comment["updated_at"] || comment["created_at"])
91
+ return unless opened && published
92
+
93
+ hours << (Time.iso8601(published) - Time.iso8601(opened)) / 3600.0
94
+ rescue ArgumentError
95
+ nil
96
+ end
97
+
98
+ def add_usage(found, usage)
99
+ return unless found
100
+
101
+ if found["input_tokens"] || found["output_tokens"]
102
+ usage["assessments_with_usage"] += 1
103
+ usage["input_tokens"] += found["input_tokens"].to_i
104
+ usage["output_tokens"] += found["output_tokens"].to_i
105
+ end
106
+ return unless found["cost_usd"]
107
+
108
+ usage["assessments_with_cost"] += 1
109
+ usage["cost_usd"] += found["cost_usd"]
110
+ end
111
+
112
+ def median(values)
113
+ sorted = values.sort
114
+ middle = sorted.size / 2
115
+ sorted.size.odd? ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2.0
32
116
  end
33
117
  end
34
118
  end
@@ -3,35 +3,71 @@ require "openssl"
3
3
  require "uri"
4
4
 
5
5
  module BrowserReviewGate
6
- # What an agent needs to know before a browser run: the decision for the PR head and the next step.
6
+ # What an agent needs to know before a browser run: the decision for the PR head, the scenarios still
7
+ # owed with their status, the environment to use, the policy, and the next step.
7
8
  class Status
8
9
  RUN_STEPS = %w[run_and_publish run_and_save].freeze
9
10
 
10
- # `probe` takes the app URL and says whether something answers there.
11
+ # `probe` takes a URL and says whether something answers there.
11
12
  def initialize(github:, config: Config.new, shell: Shell.new, probe: nil, prompts: Prompts.new)
12
13
  @prompts = prompts
13
14
  @github = github
14
15
  @config = config
15
16
  @shell = shell
16
- @probe = probe || method(:answers?)
17
+ @probe = probe || Status.method(:answers?)
17
18
  end
18
19
 
19
- def to_h(number = nil)
20
+ def to_h(number = nil, environment: nil)
20
21
  result = facts(number)
21
22
  result["project_rules"] = Prompts::RULES if @prompts.rules
22
- result["app"] = app
23
+ result["environment"] = environment_details(environment)
24
+ result["policy"] = @config.policy
25
+ result["report"] = report_instructions
23
26
  # The agent never starts the app itself: a person decides what runs on their machine.
24
- result["next"] = "ask_to_start_app" if RUN_STEPS.include?(result["next"]) && result["app"]["running"] == false
27
+ result["next"] = "ask_to_start_app" if RUN_STEPS.include?(result["next"]) && result["environment"]["reachable"] == false
25
28
  result
26
29
  end
27
30
 
31
+ # Any HTTP answer counts, redirects are not followed, and only http(s) is tried. A local
32
+ # certificate is not checked. The URL comes from the project settings.
33
+ def self.answers?(url)
34
+ uri = URI(url)
35
+ return false unless %w[http https].include?(uri.scheme) && uri.host
36
+
37
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", verify_mode: OpenSSL::SSL::VERIFY_NONE,
38
+ open_timeout: 2, read_timeout: 3) { |http| http.head(uri.request_uri) }
39
+ true
40
+ rescue StandardError
41
+ false
42
+ end
43
+
44
+ # The value of `header` in the answer at `url`, or nil. Same limits as answers?.
45
+ def self.header_at(url, header)
46
+ uri = URI(url)
47
+ return unless %w[http https].include?(uri.scheme) && uri.host
48
+
49
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", verify_mode: OpenSSL::SSL::VERIFY_NONE,
50
+ open_timeout: 2, read_timeout: 3) { |http| http.head(uri.request_uri)[header] }
51
+ rescue StandardError
52
+ nil
53
+ end
54
+
55
+ # The URL a health check asks: an absolute `healthcheck`, or its path on the environment URL.
56
+ def self.healthcheck_url(environment)
57
+ check = environment["healthcheck"]
58
+ return environment["url"] if check.nil?
59
+ return check if check.start_with?("http://", "https://")
60
+
61
+ URI.join(environment["url"], check).to_s
62
+ end
63
+
28
64
  private
29
65
 
30
66
  def facts(number)
31
67
  local_sha = @shell.call("git", "rev-parse", "HEAD").strip
32
68
  saved = SavedReport.new(sha: local_sha, shell: @shell).exist?
33
69
  number ||= @github.pull_request_number_for_branch
34
- return { "pull_request" => nil, "local_head" => local_sha, "saved_run" => saved, "next" => saved ? "open_pull_request" : "run_and_save" } unless number
70
+ return { "pull_request" => nil, "local_head" => local_sha, "saved_run" => saved, "scenarios" => [], "next" => saved ? "open_pull_request" : "run_and_save" } unless number
35
71
 
36
72
  pull_request = @github.pull_request(number)
37
73
  comments = @github.trusted_comments(number)
@@ -39,17 +75,25 @@ module BrowserReviewGate
39
75
  assessment = Assessment.latest(comments)
40
76
  assessment = nil unless assessment&.for?(head_sha)
41
77
  labels = pull_request.fetch("labels").map { |label| label["name"] }
42
- report = Report.latest(comments)
43
- verified = labels.include?(@config.label) && (assessment ? assessment.covered_by?(report) : report&.passed? == true)
78
+ report = Report.latest(comments, config: @config)
79
+ commits = @github.pull_request_commits(number)
80
+ reuse = @config.scenarios["reuse_passing"]
81
+ verified = labels.include?(@config.label) && assessment&.covered_by?(report, commits: commits, reuse_passing: reuse) == true
44
82
  waived = labels.include?(@config.waiver_label)
45
83
 
46
84
  { "pull_request" => number, "pr_head" => head_sha, "local_head" => local_sha,
47
85
  "decision" => assessment&.decision, "reason" => assessment&.reason,
48
86
  # What the run must exercise; each id is the `id` of the case that reports it.
49
- "scenarios" => assessment ? assessment.outstanding(report) : [], "verified" => verified, "waived" => waived,
87
+ "scenarios" => assessment ? scenarios(assessment, report, commits, reuse) : [], "verified" => verified, "waived" => waived,
50
88
  "saved_run" => saved, "next" => next_step(assessment, verified || waived, saved, head_sha == local_sha) }
51
89
  end
52
90
 
91
+ def scenarios(assessment, report, commits, reuse)
92
+ assessment.outstanding(report, commits: commits, reuse_passing: reuse).map do |scenario|
93
+ scenario.merge("status" => report ? report.status_of(scenario["id"], commits: commits) : "missing")
94
+ end
95
+ end
96
+
53
97
  def next_step(assessment, cleared, saved, on_pr_head)
54
98
  return "nothing" if cleared || (assessment && !assessment.required?)
55
99
  return "check_out_pr_head" unless on_pr_head
@@ -59,20 +103,18 @@ module BrowserReviewGate
59
103
  "run_and_publish"
60
104
  end
61
105
 
62
- def app
63
- details = @config.app
64
- details["running"] = @probe.call(@config.url) if @config.url
106
+ def environment_details(name)
107
+ environment = @config.environment(name)
108
+ return { "name" => nil, "reachable" => nil, "note" => "No environment is configured in #{Config::PATH}." } unless environment
109
+
110
+ details = environment.slice("name", "url", "healthcheck", "start_command", "sign_in")
111
+ details["reachable"] = environment["url"] ? @probe.call(Status.healthcheck_url(environment)) : nil
65
112
  details
66
113
  end
67
114
 
68
- # Any HTTP answer counts; a local certificate is not checked.
69
- def answers?(url)
70
- uri = URI(url)
71
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", verify_mode: OpenSSL::SSL::VERIFY_NONE,
72
- open_timeout: 2, read_timeout: 3) { |http| http.head(uri.path.empty? ? "/" : uri.path) }
73
- true
74
- rescue StandardError
75
- false
115
+ def report_instructions
116
+ { "schema_version" => Report::SCHEMA_VERSION, "results" => Report::RESULTS, "capabilities" => Report::CAPABILITIES,
117
+ "evidence_paths" => "relative to the directory of the report file, outside the repository" }
76
118
  end
77
119
  end
78
120
  end
@@ -1,5 +1,5 @@
1
1
  <!-- browser_review_gate:start -->
2
2
  ## Browser verification before human review
3
3
 
4
- A PR that changes browser-visible behavior needs a browser run before a human reviews it; CI takes back reviewer requests until one is published. When asked to run `<%= command %>` (with or without a PR number), or to verify a branch or PR in the browser, read `<%= playbook_path %>` and follow it exactly. Right after opening a PR with `gh pr create`, run `browser-review-gate publish` if `browser-review-gate status` says `publish_saved`.
4
+ A PR that changes browser-visible behavior needs a browser run before a human reviews it; CI takes back reviewer requests until one is published. When asked to run `<%= command %>` (with or without a PR number), or to verify a branch or PR in the browser, read `<%= playbook_path %>` and follow it exactly. Right after opening a PR with `gh pr create`, run `browser-review-gate verify`: it publishes a run saved for this commit.
5
5
  <!-- browser_review_gate:end -->
@@ -1,10 +1,79 @@
1
1
  # Settings for browser_review_gate. Every key is optional.
2
+ #
3
+ # [enforced] settings are checked by the gem: it refuses a report or keeps review on hold.
4
+ # [agent] settings are instructions the AI coding agent receives; the gem cannot make a browser
5
+ # tool take a screenshot, it can only refuse a run without one when `gate.require_evidence` is on.
2
6
 
3
- # Where the running app answers, and how the person starts it. The agent never starts it itself.
4
- start_command:<%= start_command ? " #{start_command.inspect}" : "" %>
5
- url:<%= url ? " #{url.inspect}" : "" %>
6
- # How to sign in locally, e.g. "use the seed account from db/seeds.rb". No real credentials here.
7
- sign_in:
7
+ # Where the app runs for a browser run. The agent opens `url`; `healthcheck` (a path on `url`, or a
8
+ # full URL) is what `verify`, `status` and `doctor` ask to see whether the app answers. Nobody starts
9
+ # the app automatically: `start_command` is what the person runs. `sign_in` is a hint such as "use the
10
+ # seed account from db/seeds.rb"; never put real credentials here, this file is in the repository.
11
+ # A cloud agent cannot reach localhost: give it an environment with a URL it can reach (staging, a
12
+ # preview deployment) and select it with `--environment`.
13
+ environments:
14
+ local:
15
+ url:<%= url ? " #{url.inspect}" : "" %>
16
+ healthcheck:<%= url ? ' "/up"' : "" %>
17
+ start_command:<%= start_command ? " #{start_command.inspect}" : "" %>
18
+ sign_in:
19
+ # staging:
20
+ # url: "https://staging.example.com"
21
+ # healthcheck: "/up"
22
+
23
+ verification:
24
+ # The environment used when none is named with --environment.
25
+ environment: local
26
+ # [enforced] A case that passes but reports console errors / failed network requests counts as failed.
27
+ fail_on_console_errors: false
28
+ fail_on_network_errors: false
29
+
30
+ evidence:
31
+ # [agent] never, on_failure or always. [enforced] with gate.require_evidence, for tools that can.
32
+ screenshots: on_failure
33
+ trace: never
34
+ # [agent] Record console errors and failed requests per case. [enforced] with gate.require_evidence.
35
+ console_logs: true
36
+ network_logs: true
37
+ # [enforced] Saved runs and kept evidence files in the git directory are removed after this many days.
38
+ retention_days: 14
39
+ # [enforced] Hosts that evidence links may point to, e.g. your artifact bucket. Empty: no links.
40
+ link_hosts: []
41
+
42
+ scenarios:
43
+ # [enforced] The most scenarios the assessing model may ask for (1-30). Mandatory ones come on top.
44
+ max_count: 12
45
+ # [enforced] A passing case from an earlier commit of the PR keeps counting after unrelated commits.
46
+ # false: every scenario must pass on the newest commit.
47
+ reuse_passing: true
48
+ # [enforced] Scenarios added to every assessment that needs a run, whatever the model proposes. With
49
+ # `paths`, only when the change touches them, and then a run is needed whatever the model decides.
50
+ # `matrix: true` repeats the scenario for every combination below (at most 24 cases in total).
51
+ mandatory: []
52
+ # - id: SIGN-IN
53
+ # name: Sign in and land on the dashboard
54
+ # why: Every change to authentication must keep sign-in working.
55
+ # paths: ["app/controllers/sessions_controller.rb", "app/views/sessions/**"]
56
+ # matrix: true
57
+ matrix: {}
58
+ # role: [admin, member]
59
+ # viewport: [desktop, mobile]
60
+
61
+ gate:
62
+ # [enforced] Refuse runs whose passing cases lack the evidence asked for above.
63
+ require_evidence: false
64
+ # [enforced] Any failing case keeps review on hold, not only failing required scenarios.
65
+ block_on_failure: true
66
+
67
+ provenance:
68
+ # [enforced] Check each case's Playwright trace against the tested commit. off: no check. warn: the
69
+ # result is shown in the PR. require: a passing case counts only when its trace shows a response
70
+ # from the tested commit (via revision_header), a request to the case's page, and request times
71
+ # inside the run. The app must send the header: add BrowserReviewGate::RevisionHeader to the
72
+ # development middleware. With warn or require the agent is asked for a trace of every case.
73
+ trace: "off"
74
+ revision_header: "X-App-Revision"
75
+ check_pages: true
76
+ clock_skew_seconds: 300
8
77
 
9
78
  # Who assesses a PR in CI. Unset: the Claude Code CLI with a CLAUDE_CODE_OAUTH_TOKEN or
10
79
  # ANTHROPIC_API_KEY secret; with only an OPENAI_API_KEY or GEMINI_API_KEY secret, that provider's API.
@@ -1,56 +1,96 @@
1
1
  <!-- browser_review_gate:managed <%= BrowserReviewGate::VERSION %> — re-run `browser-review-gate install` to update. -->
2
2
  # Browser verification before human review
3
3
 
4
- A pull request that changes what a person sees or does in a browser must be exercised in a browser before a human reviews it. CI decides whether a PR needs that and writes the decision in one "Browser verification" comment. Until a passing run is published, reviewer requests on such a PR are taken back.
4
+ A pull request that changes what a person sees or does in a browser must be exercised in a browser before a human reviews it. CI decides whether a PR needs that and writes the decision in one "Browser verification" comment, with the scenarios a run must pass. Until a run covering them is published, reviewer requests on such a PR are taken back.
5
5
 
6
6
  This file is the playbook for the AI coding agent that does the run. The person starts it with `<%= command %> NUMBER` for an open PR, or `<%= command %>` on a branch that has no PR yet.
7
7
 
8
+ **Who does what.** You, the agent, drive the browser with your browser tool (Playwright, Chrome DevTools MCP or similar) and write down what you saw. `browser-review-gate` does the bookkeeping: which scenarios are owed, which environment to use, whether the app answers, and checking and publishing your report. It never drives a browser, and it cannot see what you did: your report is your account, so it must be accurate.
9
+
8
10
  ## Steps
9
11
 
10
- 1. Run `browser-review-gate status` (add `--pr NUMBER` when a number was given). It prints JSON; follow `next`:
11
- - `nothing`: no run is needed, or the PR is already verified. Say that review can be requested.
12
+ 1. Run `browser-review-gate verify --json` (add `--pr NUMBER` when a number was given, `--environment NAME` when the person named one). Follow `outcome`:
13
+ - `nothing`: no run is needed, or the PR is already verified or waived. Say that review can be requested.
12
14
  - `check_out_pr_head`: check out the PR head with a clean worktree, then start again.
13
- - `request_assessment`: run `browser-review-gate request-assessment NUMBER`, wait about a minute, then start again.
14
- - `publish_saved`: a run saved for this commit exists. Run `browser-review-gate publish` and stop; nothing runs again.
15
+ - `assessment_requested`: CI was asked to assess the PR. Wait about a minute, then start again.
16
+ - `published` / `not_verified`: a run saved for this commit was published. Report the result and stop.
15
17
  - `open_pull_request`: a run is saved and waits for the PR. Tell the person to open it.
16
- - `ask_to_start_app`: the app is not running. Do not start it yourself: ask the person to start it (`app.start_command` says how) and stop until they have.
18
+ - `ask_to_start_app`: the environment does not answer. Do not start it yourself: ask the person to start it (`environment.start_command` says how) and stop until they have.
17
19
  - `run_and_publish` or `run_and_save`: continue with step 2.
18
20
  2. Decide what to run.
19
- - `scenarios` in the status output is the list CI wrote for this PR. Run every one of them, and give each case the `id` of its scenario. You may add cases of your own; you may not drop or rename one from the list.
20
- - With no PR yet, or an empty list, choose the scenarios yourself: read the diff against the base branch, then `<%= rules_path %>`, which says what this project always checks and which scenarios each area needs. Its rules are mandatory. CI compares your cases with its own list once the PR is open and asks for what is missing.
21
- - Do not re-run behavior an earlier passing case still covers.
22
- 3. Open the running app in a browser tool. `app` in the status output gives the URL and a sign-in hint. Never start, restart or stop the app yourself: what runs on the person's machine is their decision. Use local test data and do not destroy existing data. If the app, the data, or a browser tool is missing, report the blocker. Reading code and running unit tests does not replace a browser run.
23
- 4. Run every scenario end to end. After key actions check the page state, console errors, and failed requests. For each case record the page it was exercised on (`url`) and what you observed there, including console errors and failed requests (`details`). A case without both is refused. A skipped or blocked scenario is not a pass.
24
- 5. Write the report as JSON outside the repository (see the shape below). Set `coverage_complete` to true only when every scenario from step 2 ran. CI checks the report against its list by case `id`: a missing scenario takes the label away again.
25
- 6. Publish or save, from the checkout that was tested, with everything committed and pushed:
26
- - PR exists: `browser-review-gate publish --report PATH` (add `--pr NUMBER` when the branch has several). It posts the cases with their outcomes. Only a complete all-pass report adds the `<%= label %>` label.
27
- - No PR yet: `browser-review-gate save --report PATH`. The run is published when the PR for this commit is opened. A new commit makes it stale.
28
- 7. Check the command result and the PR. A non-zero exit can still mean a report was posted: a failing or incomplete report is visible in the PR and review stays closed.
29
- 8. Request a reviewer only when the person named one and only after the run passed. Reviewers whose requests CI took back earlier are asked again automatically. Otherwise say that review can be requested.
21
+ - `scenarios` is the list CI wrote for this PR, with each one's `status` (`missing`, `failed`, `blocked`, `skipped`, `stale`). Run every one of them and give each case the `id` of its scenario. Scenarios marked `critical` or in the `mandatory` category come from the project settings; they cannot be left out. You may add cases of your own; you may not drop or rename one from the list.
22
+ - With no PR yet, or an empty list, choose the scenarios yourself: read the diff against the base branch, then `<%= rules_path %>`, which says what this project always checks. Its rules are mandatory. CI compares your cases with its own list once the PR is open and asks for what is missing.
23
+ - Do not re-run a scenario whose earlier case still counts; `verify` lists only what is owed.
24
+ 3. Open `environment.url` in your browser tool. `environment.sign_in` is a sign-in hint. Never start, restart or stop the app yourself. Use test data and do not destroy existing data. If the app, the data or a browser tool is missing, report the blocker. Reading code and running unit tests does not replace a browser run.
25
+ 4. Run every scenario end to end. For each case record:
26
+ - `url`: the page it was exercised on; `details`: what you observed;
27
+ - evidence, as `policy.agent` in the `verify` output asks: screenshots (`never`, `on_failure`, `always`), a trace, and the console errors and failed requests you saw (`[]` when there were none). Save files in a directory outside the repository, next to the report.
28
+ - When `policy.enforced.trace_check` is `warn` or `require`, record a Playwright trace (`.zip`) or a HAR network log (`.har`, which Playwright and Chrome DevTools can save) for every case, one per case, and list it as `evidence.trace`. If your browser tool can save neither, stop and tell the person: with `require`, every passing case would be refused. The gem opens it and checks that the app answered with the tested commit, that the case's page was requested, and that the requests fall between `started_at` and `finished_at`. With `require`, a passing case without such a trace does not count. Record the trace while running the case; a trace from another run will not match.
29
+ - A scenario you could not run is `blocked` (something prevented it) or `skipped` (deliberately not run), with a `reason`. Neither is a pass.
30
+ 5. Write the report as JSON next to the evidence (see the shape below). Set `coverage_complete` to true only when every scenario from step 2 ran.
31
+ 6. Publish, from the checkout that was tested, with everything committed and pushed: `browser-review-gate verify --report PATH` (add `--pr NUMBER` when the branch has several PRs). With a PR it is published; without one it is saved and published when the PR for this commit is opened.
32
+ 7. Check the result. Exit status 0 means review can proceed; 7 means the report was published but review stays on hold (a scenario is missing, failed, or broke a project policy); 1 means the report was refused, with the reason. A refused report is not published: fix it and publish again.
33
+ 8. Request a reviewer only when the person named one and only after the run passed. Reviewers whose requests CI took back earlier are asked again automatically.
34
+
35
+ ## What the gem checks, and what it cannot
36
+
37
+ - It refuses a report with an unknown key, a missing field, an evidence path outside the report directory, a file that is not the image or archive its name says, a link to a host the project did not list, or a commit other than the checked-out one.
38
+ - With `gate.require_evidence`, a passing case without the screenshots, trace or logs the project asks for does not count, unless your tool lacks that capability (say so in `tool.capabilities`).
39
+ - With `verification.fail_on_console_errors` / `fail_on_network_errors`, a passing case that lists such errors counts as failed.
40
+ - Only cases tested on a commit of this PR count.
41
+ - With `provenance.trace`, it reads each case's Playwright trace and checks it against the tested commit and the case's page.
42
+ - It cannot tell whether a browser really did what you wrote. File digests show that files existed when the run was published, not who made them.
30
43
 
31
44
  ## When a scenario cannot be run
32
45
 
33
- Some scenarios cannot be exercised locally, for example a sign-in through an external provider. Do not mark the run complete and do not drop the scenario silently. Tell the person. They can list it under `excluded` in the report, or someone other than the author can put the `<%= waiver_label %>` label on the PR to let review proceed without a run.
46
+ Some scenarios cannot be exercised locally, for example a sign-in through an external provider. Report it as `blocked` with the reason, or, when the person decided so, list it under `excluded`. Neither counts as a pass, so review stays on hold for that scenario: tell the person. Someone other than the author can put the `<%= waiver_label %>` label on the PR to let review proceed without a run.
34
47
 
35
48
  ## Report shape
36
49
 
37
50
  ```json
38
51
  {
39
- "tested_sha": "<full commit SHA that was tested>",
52
+ "schema_version": 2,
53
+ "tested_sha": "<full 40-character SHA of the commit that was tested>",
54
+ "environment": "local",
55
+ "tool": { "name": "Playwright MCP", "version": "0.0.40", "capabilities": ["screenshots", "trace", "console", "network"] },
56
+ "started_at": "2026-11-11T10:00:00Z",
57
+ "finished_at": "2026-11-11T10:06:30Z",
40
58
  "coverage_complete": true,
41
59
  "cases": [
42
- { "id": "BROWSER-1", "name": "The changed interaction completes", "result": "pass", "url": "/the/page/exercised", "details": "What was observed, with console errors and failed requests if any." }
60
+ {
61
+ "id": "BROWSER-1",
62
+ "name": "Submit the sign-up form and land on the dashboard",
63
+ "result": "pass",
64
+ "url": "/signup",
65
+ "details": "The form submitted; the dashboard showed the new account's name.",
66
+ "evidence": {
67
+ "screenshots": ["artifacts/signup-done.png"],
68
+ "trace": "artifacts/signup-trace.zip",
69
+ "console_errors": [],
70
+ "network_errors": []
71
+ }
72
+ },
73
+ {
74
+ "id": "SIGN-IN.member.mobile",
75
+ "name": "Sign in as a member at phone width",
76
+ "result": "blocked",
77
+ "reason": "The member seed account is missing from the local database.",
78
+ "details": "The sign-in form rejected the seed credentials."
79
+ }
43
80
  ],
44
81
  "excluded": [
45
- { "name": "Sign-in through an external provider", "reason": "Needs a real provider account; covered by integration tests." }
82
+ { "id": "OAUTH-1", "name": "Sign-in through an external provider", "reason": "Needs a real provider account; covered by integration tests." }
46
83
  ]
47
84
  }
48
85
  ```
49
86
 
50
- `result` is `pass` or `fail`. `url` is an http(s) URL or a path starting with `/`. `excluded` is optional: list a scenario there only when the person decided it stays outside the local run. Never exclude a scenario on your own to make the run complete. Keep customer and real company names out of the report.
87
+ - `result`: `pass`, `fail`, `blocked` or `skipped`. `url` is an http(s) URL or a path starting with `/`; required for `pass` and `fail`. `reason` is required for `blocked` and `skipped`.
88
+ - `tool.capabilities`: what your browser tool could record, from `screenshots`, `trace`, `console`, `network`. Claim only what it really can.
89
+ - `evidence` paths are relative to the report file's directory. Accepted: `.png`, `.jpg`, `.jpeg`, `.webp` screenshots and a `.zip` Playwright trace or `.har` network log, at most 10 files of 20 MB each. `network_errors` entries may be strings or `{ "method", "url", "status" }`; query strings are dropped. `links` may point to evidence kept elsewhere, on hosts listed in `evidence.link_hosts`.
90
+ - Everything you write is scrubbed of values that look like credentials, but do not rely on it: never put cookies, tokens, passwords or customer data into a report, a screenshot or a trace. Keep customer and real company names out of the report.
51
91
 
52
92
  ## Rules
53
93
 
54
- - Run the awkward path in the browser, or mark coverage incomplete and leave review closed. Do not declare a run done from tests and code reading.
55
- - Keep the report file and browser artifacts outside the repository. The publisher refuses a checkout with modified or untracked files and lists them.
56
- - After an unrelated commit, do not run everything again: use the PR comment and the earlier report to pick only what is new.
94
+ - Run the awkward path in the browser, or report it as blocked and leave review on hold. Do not declare a run done from tests and code reading.
95
+ - Keep the report file and evidence outside the repository. The publisher refuses a checkout with modified or untracked files and lists them.
96
+ - After an unrelated commit, do not run everything again: `verify` lists only what is new or no longer counts.
@@ -16,7 +16,10 @@ one is ignored. The built-in rules stay in force; these are added on top and win
16
16
  ## Always check
17
17
 
18
18
  <!-- Example: Run every scenario once as an administrator and once as a regular member.
19
- Example: Repeat the main scenario at a phone-sized window. -->
19
+ Example: Repeat the main scenario at a phone-sized window.
20
+ These are read by the model and the agent. To make a scenario or a role/viewport combination
21
+ impossible to leave out, declare it under `scenarios.mandatory` and `scenarios.matrix` in
22
+ .github/browser-review-gate.yml: those are added by the gem and checked case by case. -->
20
23
 
21
24
  ## Scenarios by area
22
25
 
@@ -0,0 +1,119 @@
1
+ require "json"
2
+ require "time"
3
+ require "uri"
4
+
5
+ module BrowserReviewGate
6
+ # Reads a Playwright trace (trace.zip) or a HAR network log (.har, which Playwright, Chrome DevTools
7
+ # and other tools write) and checks that it shows the case really ran against the
8
+ # tested code: a response carried the app's revision header with the tested commit, the case's page
9
+ # was requested, and the requests fall inside the run's time window.
10
+ #
11
+ # This catches an agent that reports a run it did not do. It does not stop someone who builds a
12
+ # trace by hand; a trace is a file like any other.
13
+ class TraceCheck
14
+ NETWORK_ENTRY = /(?:\A|\/)(?:\d+-)?trace\.network\z/
15
+
16
+ def initialize(config)
17
+ @settings = config.provenance
18
+ end
19
+
20
+ # Returns { "status" => "verified" | "failed", "revision" => ..., "requests" => n, "findings" => [...] }.
21
+ def call(path, tested_sha:, url:, started_at:, finished_at:)
22
+ requests = read(path)
23
+ findings = []
24
+ revisions = requests.filter_map { |request| request[:revision] }.uniq
25
+ matched = revisions.find { |revision| tested_sha.start_with?(revision) }
26
+ if revisions.empty?
27
+ findings << "no response carried the #{@settings["revision_header"]} header"
28
+ elsif matched.nil?
29
+ findings << "the app answered with revision #{revisions.first[0, 12]}, not the tested commit"
30
+ end
31
+ findings << "the case's page #{url} was not requested" if @settings["check_pages"] && url && !page_requested?(requests, url)
32
+ findings.concat(time_findings(requests, started_at, finished_at))
33
+ findings << "the trace has no network requests" if requests.empty?
34
+
35
+ { "status" => findings.empty? ? "verified" : "failed", "revision" => matched && tested_sha[0, 12],
36
+ "requests" => requests.size, "findings" => findings.first(5) }.compact
37
+ end
38
+
39
+ private
40
+
41
+ def read(path)
42
+ return read_har(path) if File.extname(path).casecmp?(".har")
43
+
44
+ requests = []
45
+ ZipReader.new(path).each(->(name) { NETWORK_ENTRY.match?(name) }) do |_name, content|
46
+ content.each_line do |line|
47
+ request = parse(line)
48
+ requests << request if request
49
+ end
50
+ end
51
+ requests
52
+ end
53
+
54
+ def read_har(path)
55
+ text = File.binread(path, ZipReader::MAX_ENTRY_BYTES + 1).to_s.force_encoding(Encoding::UTF_8).delete_prefix("\uFEFF")
56
+ log = JSON.parse(text)
57
+ entries = log.is_a?(Hash) ? log.dig("log", "entries") : nil
58
+ raise Error, "the HAR file has no log.entries" unless entries.is_a?(Array)
59
+
60
+ entries.filter_map { |entry| request(entry) }
61
+ rescue JSON::ParserError
62
+ raise Error, "the HAR file is not valid JSON"
63
+ end
64
+
65
+ # One HAR-like resource snapshot per line; anything else is skipped.
66
+ def parse(line)
67
+ event = JSON.parse(line)
68
+ event.is_a?(Hash) && event["type"] == "resource-snapshot" ? request(event["snapshot"]) : nil
69
+ rescue JSON::ParserError
70
+ nil
71
+ end
72
+
73
+ # A HAR entry, which is also what a Playwright resource snapshot is.
74
+ def request(snapshot)
75
+ return unless snapshot.is_a?(Hash) && snapshot["request"].is_a?(Hash)
76
+
77
+ headers = snapshot.dig("response", "headers")
78
+ header = Array(headers).find { |entry| entry.is_a?(Hash) && entry["name"].to_s.casecmp?(@settings["revision_header"]) }
79
+ { url: snapshot["request"]["url"].to_s, time: time(snapshot["startedDateTime"]),
80
+ revision: header && header["value"].to_s.strip.downcase[/\A\h{7,40}\z/] }
81
+ end
82
+
83
+ def time(value)
84
+ Time.iso8601(value.to_s)
85
+ rescue ArgumentError
86
+ nil
87
+ end
88
+
89
+ # The case's page counts as requested when a request has the same path, and the same host when
90
+ # the case names a full URL.
91
+ def page_requested?(requests, url)
92
+ wanted = URI.parse(url)
93
+ requests.any? do |request|
94
+ seen = URI.parse(request[:url])
95
+ seen.path.to_s.chomp("/") == wanted.path.to_s.chomp("/") && (wanted.host.nil? || wanted.host == seen.host)
96
+ rescue URI::InvalidURIError
97
+ false
98
+ end
99
+ rescue URI::InvalidURIError
100
+ false
101
+ end
102
+
103
+ def time_findings(requests, started_at, finished_at)
104
+ return [] unless started_at && finished_at
105
+
106
+ skew = @settings["clock_skew_seconds"]
107
+ from = Time.iso8601(started_at) - skew
108
+ to = Time.iso8601(finished_at) + skew
109
+ times = requests.filter_map { |request| request[:time] }
110
+ return [ "the trace has no request times" ] if times.empty? && requests.any?
111
+ return [] if times.empty?
112
+
113
+ outside = times.count { |seen| seen < from || seen > to }
114
+ outside.positive? ? [ "#{outside} of #{times.size} requests fall outside the run's started_at..finished_at" ] : []
115
+ rescue ArgumentError
116
+ []
117
+ end
118
+ end
119
+ end