From e202360fd01cee2155d9555798c756824e561f83 Mon Sep 17 00:00:00 2001 From: Paul Keen <125715+pftg@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:58:29 +0200 Subject: [PATCH 01/16] feat: the HTML report writes snap_diff_manifest.json beside it Lists each changed and new screenshot with the repo path a reviewer would commit it to and its file relative to the report directory, so a page reading the uploaded report artifact can accept it without re-running the suite. --- lib/snap_diff/reporters/html.rb | 24 +++++- .../reporters/templates/report.html.erb | 2 +- test/unit/reporters/html_reporter_test.rb | 85 ++++++++++++++++++- 3 files changed, 107 insertions(+), 4 deletions(-) diff --git a/lib/snap_diff/reporters/html.rb b/lib/snap_diff/reporters/html.rb index 3f4e4105..514f4e5a 100644 --- a/lib/snap_diff/reporters/html.rb +++ b/lib/snap_diff/reporters/html.rb @@ -165,7 +165,8 @@ def compared_entry_for(name, compare) { name: name, original: resolve_image(compare.base_image_path), - new: resolve_image(compare.image_path) + new: resolve_image(compare.image_path), + accept: accept_paths(compare.image_path) }.merge(failure_details(compare)).compact end @@ -228,7 +229,8 @@ def new_screenshot_entry(name, path) image = resolve_image(path) return entry unless image - entry.merge(new: image, git_add: "git add -- #{Shellwords.escape(display_path(path))}") + entry.merge(new: image, accept: accept_paths(path), + git_add: "git add -- #{Shellwords.escape(display_path(path))}") end # Relative to the project root, where the user runs `git add` -- the @@ -239,6 +241,16 @@ def display_path(path) relative.start_with?("..") ? path.to_s : relative end + # Raw paths for snap_diff_manifest.json (the template leaves them out): + # the capture is both what is committed (`path`, the baseline's own + # path, which the failing run already overwrote) and what the report + # dir holds (`file`). String keys: entries pass through JSON in dump_state. + def accept_paths(capture) + return unless capture && File.exist?(capture) + + {"path" => display_path(capture), "file" => Pathname(capture).expand_path.relative_path_from(output_path.dirname.expand_path).to_s} + end + def resolve_image(path) return unless path @@ -257,6 +269,14 @@ def data_uri(pathname) def write_report FileUtils.mkdir_p(output_path.dirname) File.write(output_path, render) + write_manifest + end + + # What a reviewer page may accept: the captures that differ or are new. + def write_manifest + accepted = entries.select { |entry| %w[changed new].include?(entry[:status]) && entry[:accept] } + manifest = accepted.map { |entry| {"name" => entry[:name], "status" => entry[:status]}.merge(entry[:accept]) } + File.write(output_path.dirname / "snap_diff_manifest.json", JSON.pretty_generate("version" => 1, "entries" => manifest)) end end end diff --git a/lib/snap_diff/reporters/templates/report.html.erb b/lib/snap_diff/reporters/templates/report.html.erb index 9237553c..f3565b8b 100644 --- a/lib/snap_diff/reporters/templates/report.html.erb +++ b/lib/snap_diff/reporters/templates/report.html.erb @@ -244,7 +244,7 @@ + + From 0f9b141598cc366b0b2de0f5bedda3de62e50e31 Mon Sep 17 00:00:00 2001 From: Paul Keen <125715+pftg@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:34:35 +0200 Subject: [PATCH 03/16] test: drive the review page against a fake GitHub API Six browser scenarios: accept/reject commits the exact zip bytes and comments; a link whose artifact or PR does not belong to the run is refused before download; a moved branch is not overwritten; a manifest cannot write non-images or files outside screenshot paths; a bad token asks again. A unit test keeps the page's CSP script hash in step with its script. --- test/integration/review_page_test.rb | 156 +++++++++++++++++++++++++++ test/support/fake_github_api.rb | 98 +++++++++++++++++ test/unit/review_page_csp_test.rb | 22 ++++ 3 files changed, 276 insertions(+) create mode 100644 test/integration/review_page_test.rb create mode 100644 test/support/fake_github_api.rb create mode 100644 test/unit/review_page_csp_test.rb diff --git a/test/integration/review_page_test.rb b/test/integration/review_page_test.rb new file mode 100644 index 00000000..693ad9d6 --- /dev/null +++ b/test/integration/review_page_test.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +require "test_helper" +require "base64" +require "support/setup_capybara_drivers" +require "support/fake_github_api" + +# Drives lib/snap_diff/review/index.html in a real browser against a fake +# GitHub API. The page is a black box: only what it shows and what it sends. +class ReviewPageTest < Minitest::Test + TOKEN = "ghp_test_token" + HOMEPAGE = "doc/screenshots/linux/homepage.png" + SIGNUP = "doc/screenshots/linux/signup.png" + + def setup + @homepage_png = File.binread(TEST_IMAGES_DIR / "a.png") + @signup_png = File.binread(TEST_IMAGES_DIR / "c.png") + @baseline_png = File.binread(TEST_IMAGES_DIR / "b.png") + end + + def teardown + @session&.quit + end + + def test_accept_one_reject_one_commits_exact_bytes_and_comments + review do |fake| + fake.baselines[HOMEPAGE] = @baseline_png + end + page.find("#list button", text: "homepage").click + page.click_button "Accept", exact: true + page.find("#list button", text: "signup").click + page.click_button "Reject", exact: true + submit + + page.assert_text "Done. 1 accepted, 1 rejected." + sent = fake.requests + blob = only(sent, "POST", "/repos/o/r/git/blobs").json + assert_equal "base64", blob[:encoding] + assert_equal @homepage_png, Base64.strict_decode64(blob[:content]) + + tree = only(sent, "POST", "/repos/o/r/git/trees").json + assert_equal [HOMEPAGE], tree[:tree].map { _1[:path] } + assert_equal fake.git_sha("tree of head"), tree[:base_tree] + + commit = only(sent, "POST", "/repos/o/r/git/commits").json + assert_equal [FakeGithub::HEAD_SHA], commit[:parents] + + patch = only(sent, "PATCH", "/repos/o/r/git/refs/heads/feature").json + assert_equal false, patch[:force] + assert_equal fake.git_sha(commit.to_json), patch[:sha] + + comment = only(sent, "POST", "/repos/o/r/issues/7/comments").json[:body] + assert_includes comment, "homepage" + assert_includes comment, "signup" + + assert_equal ["Bearer #{TOKEN}"], sent.map(&:authorization).uniq + page.assert_selector :link, "Commit #{patch[:sha][0, 7]}", href: "https://github.com/o/r/commit/#{patch[:sha]}" + end + + def test_artifact_from_another_run_is_refused_before_download + review { |fake| fake.artifact[:workflow_run][:id] = 99 } + + page.assert_text "was not made by CI run 11" + refute_includes fake.requests.map(&:path), "/repos/o/r/actions/artifacts/22/zip" + assert_empty fake.writes + end + + def test_pr_head_that_moved_since_the_run_is_refused_before_download + review { |fake| fake.pull[:head][:sha] = "b2" * 20 } + + page.assert_text "has new commits since CI run 11" + refute_includes fake.requests.map(&:path), "/repos/o/r/actions/artifacts/22/zip" + assert_empty fake.writes + end + + def test_branch_that_moved_between_load_and_submit_is_reported_and_not_commented + review do |fake| + fake.baselines[HOMEPAGE] = @baseline_png + fake.patch_status = 422 + fake.moved_ref_sha = "c3" * 20 + end + page.find("#list button", text: "homepage").click + page.click_button "Accept", exact: true + submit + + page.assert_text "This PR has new commits since CI run 11" + assert_equal 1, fake.requests.count { _1.method == "PATCH" } + assert_empty fake.requests.select { _1.path.end_with?("/comments") } + end + + def test_malicious_manifest_entries_cannot_be_accepted_or_written + entries = [ + {name: "rake", status: "new", path: "Rakefile", file: "good.png"}, + {name: "workflow", status: "new", path: ".github/x.png", file: "good.png"}, + {name: "fake", status: "new", path: "doc/fake.png", file: "fake.png"}, + {name: "fine", status: "new", path: "doc/fine.png", file: "good.png"} + ] + review(entries: entries, files: {"good.png" => @signup_png, "fake.png" => "not a png at all"}) + + page.assert_text "Left out 3 screenshot(s)" + page.assert_text "refusing path Rakefile" + page.assert_text "refusing path .github/x.png" + page.assert_text "fake.png is not a real .png image" + page.assert_selector "#list button", count: 1 + page.click_button "Accept", exact: true + submit + + page.assert_text "Done. 1 accepted, 0 rejected." + tree = only(fake.requests, "POST", "/repos/o/r/git/trees").json + assert_equal ["doc/fine.png"], tree[:tree].map { _1[:path] } + end + + def test_bad_token_asks_for_the_token_again_and_requests_nothing_else + review { |fake| fake.runs_status = 401 } + + page.assert_text "The token is invalid or expired" + page.assert_selector "#token-form" + assert_equal ["/repos/o/r/actions/runs/11"], fake.requests.map(&:path) + end + + private + + attr_reader :fake + + def page = @session + + def review(entries: nil, files: nil) + entries ||= [ + {name: "homepage", status: "changed", path: HOMEPAGE, file: "linux/homepage.png"}, + {name: "signup", status: "new", path: SIGNUP, file: "linux/signup.png"} + ] + files ||= {"linux/homepage.png" => @homepage_png, "linux/signup.png" => @signup_png} + zip = FakeGithub.zip({"snap_diff_manifest.json" => JSON.generate(version: 1, entries: entries)}.merge(files)) + @fake = FakeGithub.new(zip: zip) + yield @fake if block_given? + # the zip size is read when the fake is built; keep the artifact record honest + @fake.artifact[:size_in_bytes] = zip.bytesize + + @session = Capybara::Session.new(Capybara.javascript_driver, @fake) + @session.config.default_max_wait_time = 10 + @session.visit "/?repo=o/r&pr=7&run=11&artifact=22" + @session.fill_in "token", with: TOKEN + @session.click_button "Load screenshots" + end + + def submit + page.click_button "Submit review" + page.click_button "Yes, submit" + end + + def only(requests, verb, path) + found = requests.select { _1.method == verb && _1.path == path } + assert_equal 1, found.size, "expected exactly one #{verb} #{path}, saw #{requests.map { "#{_1.method} #{_1.path}" }}" + found.first + end +end diff --git a/test/support/fake_github_api.rb b/test/support/fake_github_api.rb new file mode 100644 index 00000000..041bbb0d --- /dev/null +++ b/test/support/fake_github_api.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "digest" +require "json" +require "rack" +require "zlib" + +# Serves the review page (rewritten to talk to itself) and a fake GitHub REST +# API from one Rack app, and records every API request it sees. +class FakeGithub + PAGE = Pathname.new(File.expand_path("../../lib/snap_diff/review/index.html", __dir__)) + HEAD_SHA = "a1" * 20 + Request = Struct.new(:method, :path, :query, :authorization, :json) + + # The exact text between ; the CSP hashes it. + def self.script_sha256(html) + "sha256-#{[Digest::SHA256.digest(html[%r{}m, 1])].pack("m0")}" + end + + # A stored-or-deflated zip with just what the page's reader needs. + def self.zip(files) + out = +"".b + central = +"".b + files.each do |name, bytes| + bytes = bytes.b + deflater = Zlib::Deflate.new(Zlib::DEFAULT_COMPRESSION, -Zlib::MAX_WBITS) + packed = deflater.deflate(bytes, Zlib::FINISH) + crc = Zlib.crc32(bytes) + fields = [20, 0, 8, 0, 0, crc, packed.bytesize, bytes.bytesize, name.bytesize, 0] + central << [0x02014b50, 20].pack("Vv") << fields.pack("vvvvvVVVvv") << [0, 0, 0, 0, out.bytesize].pack("vvvVV") << name.b + out << [0x04034b50].pack("V") << fields.pack("vvvvvVVVvv") << name.b << packed + end + out << central << [0x06054b50, 0, 0, files.size, files.size, central.bytesize, out.bytesize - central.bytesize, 0].pack("VvvvvVVv") + end + + attr_reader :requests + attr_accessor :run, :artifact, :pull, :zip, :baselines, :runs_status, :patch_status, :moved_ref_sha + + def initialize(zip:, baselines: {}) + @zip = zip + @baselines = baselines + @requests = [] + @run = {id: 11, head_sha: HEAD_SHA, head_branch: "feature", event: "pull_request", head_repository: {full_name: "o/r"}} + @artifact = {id: 22, expired: false, size_in_bytes: zip.bytesize, workflow_run: {id: 11}} + @pull = {state: "open", head: {sha: HEAD_SHA, ref: "feature", repo: {full_name: "o/r"}}, base: {ref: "main", repo: {default_branch: "main"}}} + end + + def writes = requests.reject { _1.method == "GET" } + + def git_sha(text) = Digest::SHA1.hexdigest(text) + + def call(env) + req = Rack::Request.new(env) + return page(req) if req.path_info == "/" + + body = req.body.read + json = body.empty? ? nil : JSON.parse(body, symbolize_names: true) + requests << Request.new(req.request_method, req.path_info, req.query_string, env["HTTP_AUTHORIZATION"], json) + route(req.request_method, req.path_info, json) + end + + private + + def route(verb, path, json) + case "#{verb} #{path}" + when "GET /user" then reply(200, login: "reviewer") + when %r{\AGET /repos/o/r/actions/runs/\d+\z} then runs_status ? reply(runs_status, message: "Bad credentials") : reply(200, run) + when %r{\AGET /repos/o/r/actions/artifacts/\d+\z} then reply(200, artifact) + when %r{\AGET /repos/o/r/actions/artifacts/\d+/zip\z} then [303, {"location" => "/blob/artifact.zip"}, []] + when "GET /blob/artifact.zip" then [200, {"content-type" => "application/zip"}, [zip]] + when %r{\AGET /repos/o/r/pulls/\d+\z} then reply(200, pull) + when %r{\AGET /repos/o/r/contents/(.+)\z} + (bytes = baselines[$1]) ? [200, {"content-type" => "application/vnd.github.raw"}, [bytes]] : reply(404, message: "Not Found") + when "POST /repos/o/r/git/blobs" then reply(201, sha: git_sha(json[:content])) + when %r{\AGET /repos/o/r/git/commits/\h{40}\z} then reply(200, sha: HEAD_SHA, tree: {sha: git_sha("tree of head")}) + when "POST /repos/o/r/git/trees" then reply(201, sha: git_sha(json.to_json)) + when "POST /repos/o/r/git/commits" then reply(201, sha: git_sha(json.to_json), html_url: "https://github.com/o/r/commit/#{git_sha(json.to_json)}") + when %r{\APATCH /repos/o/r/git/refs/heads/.+\z} + patch_status ? reply(patch_status, message: "Update is not a fast forward") : reply(200, object: {sha: json[:sha]}) + when %r{\AGET /repos/o/r/git/refs?/heads/.+\z} then reply(200, object: {sha: moved_ref_sha || HEAD_SHA}) + when %r{\APOST /repos/o/r/issues/\d+/comments\z} then reply(201, html_url: "https://github.com/o/r/pull/7#issuecomment-1") + else reply(404, message: "fake has no route for #{verb} #{path}") + end + end + + def reply(status, hash) = [status, {"content-type" => "application/json"}, [JSON.generate(hash)]] + + # The page under test with its two hooks used: the API origin and the CSP. + # Changing the script changes its hash, so the hash is recomputed here. + def page(req) + origin = "#{req.scheme}://#{req.host_with_port}" + html = PAGE.read + html = html.sub('const API = "https://api.github.com";', %(const API = "#{origin}";)) + html = html.sub("connect-src https://api.github.com", "connect-src #{origin}") + html = html.sub(/'sha256-[^']+'/, "'#{self.class.script_sha256(html)}'") + [200, {"content-type" => "text/html"}, [html]] + end +end diff --git a/test/unit/review_page_csp_test.rb b/test/unit/review_page_csp_test.rb new file mode 100644 index 00000000..4de6bd1c --- /dev/null +++ b/test/unit/review_page_csp_test.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "test_helper" +require "support/fake_github_api" + +class ReviewPageCspTest < Minitest::Test + def test_csp_script_hash_matches_the_inline_script + html = FakeGithub::PAGE.read + + assert_equal 1, html.scan(/ and and run + ruby -rdigest -e 'puts "sha256-" + [Digest::SHA256.digest(File.read("lib/snap_diff/review/index.html")[%r{}m, 1])].pack("m0")' + then paste the result into the meta's script-src. + MSG + end +end From d98318ec50e4ef88dd5473999698a0f55fc3f8b9 Mon Sep 17 00:00:00 2001 From: Paul Keen <125715+pftg@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:36:54 +0200 Subject: [PATCH 04/16] fix: the review page no longer shows "null" for a new screenshot replaceChildren renders a null child as the text "null"; the parts a new screenshot lacks (the base/new toggle, the differences heading) are now left out instead. --- lib/snap_diff/review/index.html | 7 ++++--- test/integration/review_page_test.rb | 2 ++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/snap_diff/review/index.html b/lib/snap_diff/review/index.html index bb18bc61..0502804f 100644 --- a/lib/snap_diff/review/index.html +++ b/lib/snap_diff/review/index.html @@ -4,7 +4,7 @@ - + snap_diff review - - -
-

snap_diff review

- - - -
-
- -
- -
-
- - - - diff --git a/test/integration/review_page_test.rb b/test/integration/review_page_test.rb deleted file mode 100644 index 404ed9aa..00000000 --- a/test/integration/review_page_test.rb +++ /dev/null @@ -1,156 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" -require "base64" -require "support/setup_capybara_drivers" -require "support/fake_github_api" - -# Drives lib/snap_diff/review/index.html in a real browser against a fake -# GitHub API. The page is a black box: only what it shows and what it sends. -class ReviewPageTest < Minitest::Test - TOKEN = "ghp_test_token" - HOMEPAGE = "doc/screenshots/linux/homepage.png" - SIGNUP = "doc/screenshots/linux/signup.png" - - def setup - @homepage_png = File.binread(TEST_IMAGES_DIR / "a.png") - @signup_png = File.binread(TEST_IMAGES_DIR / "c.png") - @baseline_png = File.binread(TEST_IMAGES_DIR / "b.png") - end - - def teardown - @session&.quit - end - - def test_accept_one_reject_one_commits_exact_bytes_and_comments - review do |fake| - fake.baselines[HOMEPAGE] = @baseline_png - end - page.find("#list button", text: "homepage").click - page.click_button "Accept", exact: true - page.find("#list button", text: "signup").click - page.assert_text "New capture" - refute_match(/\bnull\b/, page.text) - page.click_button "Reject", exact: true - submit - - page.assert_text "Done. 1 accepted, 1 rejected." - sent = fake.requests - blob = only(sent, "POST", "/repos/o/r/git/blobs").json - assert_equal "base64", blob[:encoding] - assert_equal @homepage_png, Base64.strict_decode64(blob[:content]) - - tree = only(sent, "POST", "/repos/o/r/git/trees").json - assert_equal [HOMEPAGE], tree[:tree].map { _1[:path] } - assert_equal fake.git_sha("tree of head"), tree[:base_tree] - - commit = only(sent, "POST", "/repos/o/r/git/commits").json - assert_equal [FakeGithub::HEAD_SHA], commit[:parents] - - patch = only(sent, "PATCH", "/repos/o/r/git/refs/heads/feature").json - assert_equal false, patch[:force] - assert_equal fake.git_sha(commit.to_json), patch[:sha] - - comment = only(sent, "POST", "/repos/o/r/issues/7/comments").json[:body] - assert_includes comment, "homepage" - assert_includes comment, "signup" - - assert_equal ["Bearer #{TOKEN}"], sent.map(&:authorization).uniq - page.assert_selector :link, "Commit #{patch[:sha][0, 7]}", href: "https://github.com/o/r/commit/#{patch[:sha]}" - end - - def test_artifact_from_another_run_is_refused_before_download - review { |fake| fake.artifact[:workflow_run][:id] = 99 } - - page.assert_text "was not made by CI run 11" - refute_includes fake.requests.map(&:path), "/repos/o/r/actions/artifacts/22/zip" - assert_empty fake.writes - end - - def test_pr_head_that_moved_since_the_run_is_refused_before_download - review { |fake| fake.pull[:head][:sha] = "b2" * 20 } - - page.assert_text "has new commits since CI run 11" - refute_includes fake.requests.map(&:path), "/repos/o/r/actions/artifacts/22/zip" - assert_empty fake.writes - end - - def test_branch_that_moved_between_load_and_submit_is_reported_and_not_commented - review do |fake| - fake.baselines[HOMEPAGE] = @baseline_png - fake.patch_status = 422 - fake.moved_ref_sha = "c3" * 20 - end - page.find("#list button", text: "homepage").click - page.click_button "Accept", exact: true - submit - - page.assert_text "This PR has new commits since CI run 11" - assert_equal 1, fake.requests.count { _1.method == "PATCH" } - assert_empty fake.requests.select { _1.path.end_with?("/comments") } - end - - def test_malicious_manifest_entries_cannot_be_accepted_or_written - entries = [ - {name: "rake", status: "new", path: "Rakefile", file: "good.png"}, - {name: "workflow", status: "new", path: ".github/x.png", file: "good.png"}, - {name: "fake", status: "new", path: "doc/fake.png", file: "fake.png"}, - {name: "fine", status: "new", path: "doc/fine.png", file: "good.png"} - ] - review(entries: entries, files: {"good.png" => @signup_png, "fake.png" => "not a png at all"}) - - page.assert_text "Left out 3 screenshot(s)" - page.assert_text "refusing path Rakefile" - page.assert_text "refusing path .github/x.png" - page.assert_text "fake.png is not a real .png image" - page.assert_selector "#list button", count: 1 - page.click_button "Accept", exact: true - submit - - page.assert_text "Done. 1 accepted, 0 rejected." - tree = only(fake.requests, "POST", "/repos/o/r/git/trees").json - assert_equal ["doc/fine.png"], tree[:tree].map { _1[:path] } - end - - def test_bad_token_asks_for_the_token_again_and_requests_nothing_else - review { |fake| fake.runs_status = 401 } - - page.assert_text "The token is invalid or expired" - page.assert_selector "#token-form" - assert_equal ["/repos/o/r/actions/runs/11"], fake.requests.map(&:path) - end - - private - - attr_reader :fake - - def page = @session - - def review(entries: nil, files: nil) - entries ||= [ - {name: "homepage", status: "changed", path: HOMEPAGE, file: "linux/homepage.png"}, - {name: "signup", status: "new", path: SIGNUP, file: "linux/signup.png"} - ] - files ||= {"linux/homepage.png" => @homepage_png, "linux/signup.png" => @signup_png} - zip = FakeGithub.zip({"snap_diff_manifest.json" => JSON.generate(version: 1, entries: entries)}.merge(files)) - @fake = FakeGithub.new(zip: zip) - yield @fake if block_given? - - @session = Capybara::Session.new(Capybara.javascript_driver, @fake) - @session.config.default_max_wait_time = 10 - @session.visit "/?repo=o/r&pr=7&run=11&artifact=22" - @session.fill_in "token", with: TOKEN - @session.click_button "Load screenshots" - end - - def submit - page.click_button "Submit review" - page.click_button "Yes, submit" - end - - def only(requests, verb, path) - found = requests.select { _1.method == verb && _1.path == path } - assert_equal 1, found.size, "expected exactly one #{verb} #{path}, saw #{requests.map { "#{_1.method} #{_1.path}" }}" - found.first - end -end diff --git a/test/support/fake_github_api.rb b/test/support/fake_github_api.rb deleted file mode 100644 index 041bbb0d..00000000 --- a/test/support/fake_github_api.rb +++ /dev/null @@ -1,98 +0,0 @@ -# frozen_string_literal: true - -require "digest" -require "json" -require "rack" -require "zlib" - -# Serves the review page (rewritten to talk to itself) and a fake GitHub REST -# API from one Rack app, and records every API request it sees. -class FakeGithub - PAGE = Pathname.new(File.expand_path("../../lib/snap_diff/review/index.html", __dir__)) - HEAD_SHA = "a1" * 20 - Request = Struct.new(:method, :path, :query, :authorization, :json) - - # The exact text between ; the CSP hashes it. - def self.script_sha256(html) - "sha256-#{[Digest::SHA256.digest(html[%r{}m, 1])].pack("m0")}" - end - - # A stored-or-deflated zip with just what the page's reader needs. - def self.zip(files) - out = +"".b - central = +"".b - files.each do |name, bytes| - bytes = bytes.b - deflater = Zlib::Deflate.new(Zlib::DEFAULT_COMPRESSION, -Zlib::MAX_WBITS) - packed = deflater.deflate(bytes, Zlib::FINISH) - crc = Zlib.crc32(bytes) - fields = [20, 0, 8, 0, 0, crc, packed.bytesize, bytes.bytesize, name.bytesize, 0] - central << [0x02014b50, 20].pack("Vv") << fields.pack("vvvvvVVVvv") << [0, 0, 0, 0, out.bytesize].pack("vvvVV") << name.b - out << [0x04034b50].pack("V") << fields.pack("vvvvvVVVvv") << name.b << packed - end - out << central << [0x06054b50, 0, 0, files.size, files.size, central.bytesize, out.bytesize - central.bytesize, 0].pack("VvvvvVVv") - end - - attr_reader :requests - attr_accessor :run, :artifact, :pull, :zip, :baselines, :runs_status, :patch_status, :moved_ref_sha - - def initialize(zip:, baselines: {}) - @zip = zip - @baselines = baselines - @requests = [] - @run = {id: 11, head_sha: HEAD_SHA, head_branch: "feature", event: "pull_request", head_repository: {full_name: "o/r"}} - @artifact = {id: 22, expired: false, size_in_bytes: zip.bytesize, workflow_run: {id: 11}} - @pull = {state: "open", head: {sha: HEAD_SHA, ref: "feature", repo: {full_name: "o/r"}}, base: {ref: "main", repo: {default_branch: "main"}}} - end - - def writes = requests.reject { _1.method == "GET" } - - def git_sha(text) = Digest::SHA1.hexdigest(text) - - def call(env) - req = Rack::Request.new(env) - return page(req) if req.path_info == "/" - - body = req.body.read - json = body.empty? ? nil : JSON.parse(body, symbolize_names: true) - requests << Request.new(req.request_method, req.path_info, req.query_string, env["HTTP_AUTHORIZATION"], json) - route(req.request_method, req.path_info, json) - end - - private - - def route(verb, path, json) - case "#{verb} #{path}" - when "GET /user" then reply(200, login: "reviewer") - when %r{\AGET /repos/o/r/actions/runs/\d+\z} then runs_status ? reply(runs_status, message: "Bad credentials") : reply(200, run) - when %r{\AGET /repos/o/r/actions/artifacts/\d+\z} then reply(200, artifact) - when %r{\AGET /repos/o/r/actions/artifacts/\d+/zip\z} then [303, {"location" => "/blob/artifact.zip"}, []] - when "GET /blob/artifact.zip" then [200, {"content-type" => "application/zip"}, [zip]] - when %r{\AGET /repos/o/r/pulls/\d+\z} then reply(200, pull) - when %r{\AGET /repos/o/r/contents/(.+)\z} - (bytes = baselines[$1]) ? [200, {"content-type" => "application/vnd.github.raw"}, [bytes]] : reply(404, message: "Not Found") - when "POST /repos/o/r/git/blobs" then reply(201, sha: git_sha(json[:content])) - when %r{\AGET /repos/o/r/git/commits/\h{40}\z} then reply(200, sha: HEAD_SHA, tree: {sha: git_sha("tree of head")}) - when "POST /repos/o/r/git/trees" then reply(201, sha: git_sha(json.to_json)) - when "POST /repos/o/r/git/commits" then reply(201, sha: git_sha(json.to_json), html_url: "https://github.com/o/r/commit/#{git_sha(json.to_json)}") - when %r{\APATCH /repos/o/r/git/refs/heads/.+\z} - patch_status ? reply(patch_status, message: "Update is not a fast forward") : reply(200, object: {sha: json[:sha]}) - when %r{\AGET /repos/o/r/git/refs?/heads/.+\z} then reply(200, object: {sha: moved_ref_sha || HEAD_SHA}) - when %r{\APOST /repos/o/r/issues/\d+/comments\z} then reply(201, html_url: "https://github.com/o/r/pull/7#issuecomment-1") - else reply(404, message: "fake has no route for #{verb} #{path}") - end - end - - def reply(status, hash) = [status, {"content-type" => "application/json"}, [JSON.generate(hash)]] - - # The page under test with its two hooks used: the API origin and the CSP. - # Changing the script changes its hash, so the hash is recomputed here. - def page(req) - origin = "#{req.scheme}://#{req.host_with_port}" - html = PAGE.read - html = html.sub('const API = "https://api.github.com";', %(const API = "#{origin}";)) - html = html.sub("connect-src https://api.github.com", "connect-src #{origin}") - html = html.sub(/'sha256-[^']+'/, "'#{self.class.script_sha256(html)}'") - [200, {"content-type" => "text/html"}, [html]] - end -end diff --git a/test/unit/review_page_csp_test.rb b/test/unit/review_page_csp_test.rb deleted file mode 100644 index 4de6bd1c..00000000 --- a/test/unit/review_page_csp_test.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" -require "support/fake_github_api" - -class ReviewPageCspTest < Minitest::Test - def test_csp_script_hash_matches_the_inline_script - html = FakeGithub::PAGE.read - - assert_equal 1, html.scan(/ and and run - ruby -rdigest -e 'puts "sha256-" + [Digest::SHA256.digest(File.read("lib/snap_diff/review/index.html")[%r{}m, 1])].pack("m0")' - then paste the result into the meta's script-src. - MSG - end -end From d19dbf84def85d715bb3918d0aec3c2f42d593ad Mon Sep 17 00:00:00 2001 From: Paul Keen <125715+pftg@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:42:13 +0200 Subject: [PATCH 12/16] feat: Accept and Reject in the CI report build the PR comment that accepts In a CI run (GITHUB_RUN_ID set) each screenshot the manifest lists gets Accept and Reject (Y / N), and Copy review command copies /snap-diff accept run= plus a reject line, shown in a read-only field too. Local reports are unchanged. --- lib/snap_diff/reporters/html.rb | 30 ++++++- .../reporters/templates/report.html.erb | 87 ++++++++++++++++++- test/integration/review_controls_test.rb | 75 ++++++++++++++++ test/unit/reporters/html_reporter_test.rb | 56 +++++++++++- 4 files changed, 241 insertions(+), 7 deletions(-) create mode 100644 test/integration/review_controls_test.rb diff --git a/lib/snap_diff/reporters/html.rb b/lib/snap_diff/reporters/html.rb index c0ae359d..e0412d73 100644 --- a/lib/snap_diff/reporters/html.rb +++ b/lib/snap_diff/reporters/html.rb @@ -130,6 +130,25 @@ def render ERB.new(File.read(self.class.template_path)).result(binding) end + # Review controls (Accept/Reject, the PR comment command) exist only in + # a CI run, where the run id names the artifact the bot reads. + def review + run = ENV["GITHUB_RUN_ID"].to_s + return if run.empty? + + {run: run, files: manifest_entries.map { |entry| entry[:accept]["file"] }} + end + + # The entries the page shows. In a CI run those a reviewer may accept + # carry their manifest `file`; the rest of :accept stays internal. + def report_data + entries.map do |entry| + data = entry.except(:accept) + data[:file] = entry[:accept]["file"] if review && manifest_entry?(entry) + data + end + end + def self.template_path File.expand_path("templates/report.html.erb", __dir__) end @@ -279,11 +298,16 @@ def write_report write_manifest end + def manifest_entry?(entry) = %w[changed new].include?(entry[:status]) && entry[:accept] + + # One file, one entry: the last capture. + def manifest_entries + entries.select { |entry| manifest_entry?(entry) }.reverse.uniq { |entry| entry[:accept]["path"] }.reverse + end + # What a reviewer page may accept: the captures that differ or are new. def write_manifest - accepted = entries.select { |entry| %w[changed new].include?(entry[:status]) && entry[:accept] } - accepted = accepted.reverse.uniq { |entry| entry[:accept]["path"] }.reverse # one file, one entry: the last capture - manifest = accepted.map { |entry| {"name" => entry[:name], "status" => entry[:status]}.merge(entry[:accept]) } + manifest = manifest_entries.map { |entry| {"name" => entry[:name], "status" => entry[:status]}.merge(entry[:accept]) } File.write(output_path.dirname / "snap_diff_manifest.json", JSON.pretty_generate("version" => 1, "entries" => manifest)) end end diff --git a/lib/snap_diff/reporters/templates/report.html.erb b/lib/snap_diff/reporters/templates/report.html.erb index f3565b8b..da519c91 100644 --- a/lib/snap_diff/reporters/templates/report.html.erb +++ b/lib/snap_diff/reporters/templates/report.html.erb @@ -153,6 +153,18 @@ .zoom-fit { font-size: .625rem; font-family: var(--font); font-weight: 500; background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text2); padding: .2rem .5rem; cursor: pointer; transition: all .1s; } .zoom-fit:hover { background: var(--bg4); color: var(--text); } +<% if review %> + /* Review controls (CI runs only) */ + .review-btn { padding: .3rem .625rem; font-size: .625rem; font-family: var(--font); font-weight: 500; background: var(--bg); color: var(--text3); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; white-space: nowrap; } + .review-btn:hover { color: var(--text2); border-color: var(--text3); } + .review-btn.accept.active { background: var(--green-dim); color: var(--green); border-color: var(--green); } + .review-btn.reject.active { background: var(--red-dim); color: var(--red); border-color: var(--red); } + #review-panel { display: flex; align-items: flex-start; gap: .5rem; padding: .375rem .75rem; background: var(--bg2); border-top: 1px solid var(--border); flex-shrink: 0; } + #review-side { display: flex; flex-direction: column; gap: .25rem; align-items: flex-start; } + #review-count, #review-hint { font-size: .625rem; color: var(--text2); } + #review-command { flex: 1; min-width: 0; height: 2.75rem; resize: none; font-family: var(--font-mono); font-size: .625rem; background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .25rem .4rem; } +<% end %> + /* Scrollbar */ ::-webkit-scrollbar { width: 5px; height: 5px; } ::-webkit-scrollbar-track { background: transparent; } @@ -224,6 +236,12 @@ +<% if review %> +
+ Review + + +<% end %>
@@ -232,6 +250,17 @@
+<% if review %> +
+
+ + + Paste this as a comment on the pull request. +
+ +
+<% end %> +
100% @@ -244,7 +273,10 @@