regresso 0.1.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 +7 -0
- data/.yardopts +3 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +258 -0
- data/Rakefile +12 -0
- data/assets/logo-header.svg +28 -0
- data/lib/generators/regresso/install_generator.rb +29 -0
- data/lib/generators/regresso/templates/regresso.rb +18 -0
- data/lib/regresso/adapters/.gitkeep +0 -0
- data/lib/regresso/adapters/base.rb +30 -0
- data/lib/regresso/adapters/csv.rb +43 -0
- data/lib/regresso/adapters/database.rb +53 -0
- data/lib/regresso/adapters/database_snapshot.rb +49 -0
- data/lib/regresso/adapters/graphql.rb +75 -0
- data/lib/regresso/adapters/graphql_batch.rb +42 -0
- data/lib/regresso/adapters/http.rb +70 -0
- data/lib/regresso/adapters/json_file.rb +30 -0
- data/lib/regresso/adapters/proc.rb +30 -0
- data/lib/regresso/ci/github_annotation_formatter.rb +46 -0
- data/lib/regresso/ci/github_pr_commenter.rb +55 -0
- data/lib/regresso/ci/junit_xml_formatter.rb +42 -0
- data/lib/regresso/ci/reporter.rb +49 -0
- data/lib/regresso/ci.rb +6 -0
- data/lib/regresso/comparator.rb +45 -0
- data/lib/regresso/configuration.rb +92 -0
- data/lib/regresso/differ.rb +129 -0
- data/lib/regresso/difference.rb +45 -0
- data/lib/regresso/history/entry.rb +70 -0
- data/lib/regresso/history/file_backend.rb +51 -0
- data/lib/regresso/history/statistics.rb +65 -0
- data/lib/regresso/history/store.rb +122 -0
- data/lib/regresso/history/trend_reporter.rb +68 -0
- data/lib/regresso/history.rb +7 -0
- data/lib/regresso/json_path.rb +55 -0
- data/lib/regresso/minitest.rb +107 -0
- data/lib/regresso/notifiers/base.rb +33 -0
- data/lib/regresso/notifiers/microsoft_teams.rb +60 -0
- data/lib/regresso/notifiers/slack.rb +72 -0
- data/lib/regresso/notifiers.rb +5 -0
- data/lib/regresso/parallel/comparison_result.rb +51 -0
- data/lib/regresso/parallel/parallel_result.rb +68 -0
- data/lib/regresso/parallel/result_aggregator.rb +25 -0
- data/lib/regresso/parallel/runner.rb +71 -0
- data/lib/regresso/parallel.rb +6 -0
- data/lib/regresso/reporter.rb +68 -0
- data/lib/regresso/result.rb +70 -0
- data/lib/regresso/rspec/.gitkeep +0 -0
- data/lib/regresso/rspec/shared_examples.rb +42 -0
- data/lib/regresso/rspec.rb +142 -0
- data/lib/regresso/snapshot_manager.rb +57 -0
- data/lib/regresso/tasks/ci.rake +51 -0
- data/lib/regresso/tasks/history.rake +36 -0
- data/lib/regresso/templates/.gitkeep +0 -0
- data/lib/regresso/templates/report.html.erb +44 -0
- data/lib/regresso/version.rb +6 -0
- data/lib/regresso/web_ui/diff_formatter.rb +46 -0
- data/lib/regresso/web_ui/public/css/app.css +239 -0
- data/lib/regresso/web_ui/public/js/app.js +117 -0
- data/lib/regresso/web_ui/result_store.rb +60 -0
- data/lib/regresso/web_ui/server.rb +70 -0
- data/lib/regresso/web_ui/views/index.erb +58 -0
- data/lib/regresso/web_ui/views/layout.erb +13 -0
- data/lib/regresso/web_ui.rb +5 -0
- data/lib/regresso.rb +46 -0
- data/sig/regresso.rbs +4 -0
- data/site/Gemfile +7 -0
- data/site/assets/logo-header.png +0 -0
- data/site/assets/site.css +458 -0
- data/site/content/index.md +6 -0
- data/site/craze.yml +24 -0
- data/site/dist/assets/logo-header.svg +28 -0
- data/site/dist/assets/site.css +458 -0
- data/site/dist/index.html +232 -0
- data/site/dist/search.json +9 -0
- data/site/dist/sitemap.xml +7 -0
- data/site/templates/index.erb +232 -0
- data/site/templates/layout.erb +17 -0
- metadata +190 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "regresso/rspec"
|
|
4
|
+
|
|
5
|
+
::RSpec.shared_examples "regresso:api_regression" do |config|
|
|
6
|
+
config[:patterns].each do |pattern|
|
|
7
|
+
context(pattern[:name] || pattern[:endpoint]) do
|
|
8
|
+
it "has no regression" do
|
|
9
|
+
old_source = Regresso::Adapters::Http.new(
|
|
10
|
+
base_url: config[:old_base_url],
|
|
11
|
+
endpoint: pattern[:endpoint],
|
|
12
|
+
params: pattern[:params] || {},
|
|
13
|
+
headers: pattern[:headers] || {}
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
new_source = Regresso::Adapters::Http.new(
|
|
17
|
+
base_url: config[:new_base_url],
|
|
18
|
+
endpoint: pattern[:endpoint],
|
|
19
|
+
params: pattern[:params] || {},
|
|
20
|
+
headers: pattern[:headers] || {}
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
expect(new_source)
|
|
24
|
+
.to have_no_regression_from(old_source)
|
|
25
|
+
.with_tolerance(config[:tolerance] || 0.0)
|
|
26
|
+
.ignoring(*(config[:ignore_paths] || []))
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
::RSpec.shared_examples "regresso:csv_regression" do |config|
|
|
33
|
+
it "CSV export has no regression" do
|
|
34
|
+
old_csv = config[:old_csv_source].call
|
|
35
|
+
new_csv = config[:new_csv_source].call
|
|
36
|
+
|
|
37
|
+
expect(new_csv)
|
|
38
|
+
.to have_no_regression_from(old_csv)
|
|
39
|
+
.with_tolerance(config[:tolerance] || 0.0)
|
|
40
|
+
.ignoring(*(config[:ignore_columns] || []))
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "regresso"
|
|
4
|
+
require "regresso/snapshot_manager"
|
|
5
|
+
|
|
6
|
+
module Regresso
|
|
7
|
+
# RSpec integration for Regresso.
|
|
8
|
+
module RSpec
|
|
9
|
+
# Custom matchers for regression and snapshot comparisons.
|
|
10
|
+
module Matchers
|
|
11
|
+
extend ::RSpec::Matchers::DSL
|
|
12
|
+
|
|
13
|
+
matcher :have_no_regression_from do |source_a|
|
|
14
|
+
match do |source_b|
|
|
15
|
+
@config = build_config
|
|
16
|
+
@comparator = Regresso::Comparator.new(
|
|
17
|
+
source_a: normalize_source(source_a),
|
|
18
|
+
source_b: normalize_source(source_b),
|
|
19
|
+
config: @config
|
|
20
|
+
)
|
|
21
|
+
@result = @comparator.compare
|
|
22
|
+
@result.passed?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
chain :with_tolerance do |tolerance|
|
|
26
|
+
@tolerance = tolerance
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
chain :ignoring do |*paths|
|
|
30
|
+
@ignore_paths = paths.flatten
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
chain :with_path_tolerance do |path, tolerance|
|
|
34
|
+
@path_tolerances ||= {}
|
|
35
|
+
@path_tolerances[path] = tolerance
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
chain :order_insensitive do
|
|
39
|
+
@order_insensitive = true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
failure_message do
|
|
43
|
+
message = "Expected no regression, but found #{@result.meaningful_diffs.size} difference(s):\n\n"
|
|
44
|
+
@result.meaningful_diffs.first(10).each do |diff|
|
|
45
|
+
message += " - #{diff}\n"
|
|
46
|
+
end
|
|
47
|
+
if @result.meaningful_diffs.size > 10
|
|
48
|
+
message += " ... and #{@result.meaningful_diffs.size - 10} more\n"
|
|
49
|
+
end
|
|
50
|
+
message
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
failure_message_when_negated do
|
|
54
|
+
"Expected regression, but none found"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def build_config
|
|
60
|
+
Regresso::Configuration.new.tap do |config|
|
|
61
|
+
config.default_tolerance = @tolerance if @tolerance
|
|
62
|
+
config.ignore_paths = @ignore_paths if @ignore_paths
|
|
63
|
+
config.tolerance_overrides = @path_tolerances if @path_tolerances
|
|
64
|
+
config.array_order_sensitive = !@order_insensitive
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def normalize_source(source)
|
|
69
|
+
case source
|
|
70
|
+
when Regresso::Adapters::Base
|
|
71
|
+
source
|
|
72
|
+
when Hash
|
|
73
|
+
if source[:url] || source[:base_url]
|
|
74
|
+
base_url = source[:base_url] || source[:url]
|
|
75
|
+
Regresso::Adapters::Http.new(**source.merge(base_url: base_url).reject { |k, _| k == :url })
|
|
76
|
+
elsif source[:path] && source[:path].to_s.end_with?(".csv")
|
|
77
|
+
Regresso::Adapters::Csv.new(**source)
|
|
78
|
+
elsif source[:path]
|
|
79
|
+
Regresso::Adapters::JsonFile.new(**source)
|
|
80
|
+
else
|
|
81
|
+
Regresso::Adapters::Proc.new { source }
|
|
82
|
+
end
|
|
83
|
+
when String
|
|
84
|
+
if source.end_with?(".csv")
|
|
85
|
+
Regresso::Adapters::Csv.new(path: source)
|
|
86
|
+
else
|
|
87
|
+
Regresso::Adapters::JsonFile.new(path: source)
|
|
88
|
+
end
|
|
89
|
+
when ::Proc
|
|
90
|
+
Regresso::Adapters::Proc.new(source)
|
|
91
|
+
else
|
|
92
|
+
Regresso::Adapters::Proc.new { source }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
matcher :match_snapshot do |snapshot_name|
|
|
98
|
+
match do |actual|
|
|
99
|
+
@snapshot_manager = Regresso::SnapshotManager.new
|
|
100
|
+
@snapshot_path = @snapshot_manager.path_for(snapshot_name)
|
|
101
|
+
|
|
102
|
+
if ENV["UPDATE_SNAPSHOTS"] || !File.exist?(@snapshot_path)
|
|
103
|
+
@snapshot_manager.save(snapshot_name, actual)
|
|
104
|
+
true
|
|
105
|
+
else
|
|
106
|
+
@config = build_snapshot_config
|
|
107
|
+
expected = @snapshot_manager.load(snapshot_name)
|
|
108
|
+
differ = Regresso::Differ.new(@config)
|
|
109
|
+
@diffs = differ.diff(expected, actual)
|
|
110
|
+
@diffs.empty?
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
chain :with_tolerance do |tolerance|
|
|
115
|
+
@tolerance = tolerance
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
chain :ignoring do |*paths|
|
|
119
|
+
@ignore_paths = paths.flatten
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
failure_message do
|
|
123
|
+
"Snapshot mismatch for '#{snapshot_name}':\n" +
|
|
124
|
+
@diffs.first(10).map { |diff| " - #{diff}" }.join("\n")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def build_snapshot_config
|
|
130
|
+
Regresso::Configuration.new.tap do |config|
|
|
131
|
+
config.default_tolerance = @tolerance || 0.0
|
|
132
|
+
config.ignore_paths = @ignore_paths || []
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
::RSpec.configure do |config|
|
|
141
|
+
config.include Regresso::RSpec::Matchers
|
|
142
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Regresso
|
|
7
|
+
# Manages JSON snapshots on disk.
|
|
8
|
+
class SnapshotManager
|
|
9
|
+
# Default directory for stored snapshots.
|
|
10
|
+
DEFAULT_DIR = "spec/snapshots/regresso"
|
|
11
|
+
|
|
12
|
+
# @param base_dir [String,nil]
|
|
13
|
+
def initialize(base_dir: nil)
|
|
14
|
+
@base_dir = base_dir || DEFAULT_DIR
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param name [String]
|
|
18
|
+
# @return [String]
|
|
19
|
+
def path_for(name)
|
|
20
|
+
File.join(@base_dir, "#{name}.json")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param name [String]
|
|
24
|
+
# @param data [Object]
|
|
25
|
+
# @return [void]
|
|
26
|
+
def save(name, data)
|
|
27
|
+
path = path_for(name)
|
|
28
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
29
|
+
File.write(path, JSON.pretty_generate(data))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param name [String]
|
|
33
|
+
# @return [Object]
|
|
34
|
+
def load(name)
|
|
35
|
+
JSON.parse(File.read(path_for(name)))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param name [String]
|
|
39
|
+
# @return [Boolean]
|
|
40
|
+
def exists?(name)
|
|
41
|
+
File.exist?(path_for(name))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param name [String]
|
|
45
|
+
# @return [void]
|
|
46
|
+
def delete(name)
|
|
47
|
+
FileUtils.rm_f(path_for(name))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Array<String>]
|
|
51
|
+
def list
|
|
52
|
+
Dir.glob(File.join(@base_dir, "*.json")).map do |path|
|
|
53
|
+
File.basename(path, ".json")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "regresso/parallel"
|
|
5
|
+
require "regresso/ci"
|
|
6
|
+
|
|
7
|
+
namespace :regresso do
|
|
8
|
+
desc "Run regression comparisons and write JUnit XML"
|
|
9
|
+
task :run, [:comparisons_file, :output] do |_task, args|
|
|
10
|
+
file = args[:comparisons_file] || ENV["REGRESSO_COMPARISONS"]
|
|
11
|
+
raise "comparisons file is required" unless file
|
|
12
|
+
raise "comparisons file not found: #{file}" unless File.exist?(file)
|
|
13
|
+
|
|
14
|
+
load file
|
|
15
|
+
comparisons = Object.const_get("REGRESSO_COMPARISONS")
|
|
16
|
+
|
|
17
|
+
runner = Regresso::Parallel::Runner.new(comparisons: comparisons)
|
|
18
|
+
result = runner.run
|
|
19
|
+
reporter = Regresso::CI::Reporter.new(result)
|
|
20
|
+
|
|
21
|
+
output = args[:output] || ENV["REGRESSO_JUNIT_XML"] || "tmp/regresso-junit.xml"
|
|
22
|
+
FileUtils.mkdir_p(File.dirname(output))
|
|
23
|
+
reporter.write_junit_xml(output)
|
|
24
|
+
|
|
25
|
+
puts "Regresso result: #{result.summary}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
desc "Post Regresso report as a GitHub PR comment"
|
|
29
|
+
task :comment_pr, [:comparisons_file] do |_task, args|
|
|
30
|
+
token = ENV["GITHUB_TOKEN"]
|
|
31
|
+
repo = ENV["GITHUB_REPOSITORY"]
|
|
32
|
+
pr_number = ENV["GITHUB_PR_NUMBER"]&.to_i
|
|
33
|
+
|
|
34
|
+
raise "GITHUB_TOKEN is required" unless token
|
|
35
|
+
raise "GITHUB_REPOSITORY is required" unless repo
|
|
36
|
+
raise "GITHUB_PR_NUMBER is required" unless pr_number
|
|
37
|
+
|
|
38
|
+
file = args[:comparisons_file] || ENV["REGRESSO_COMPARISONS"]
|
|
39
|
+
raise "comparisons file is required" unless file
|
|
40
|
+
raise "comparisons file not found: #{file}" unless File.exist?(file)
|
|
41
|
+
|
|
42
|
+
load file
|
|
43
|
+
comparisons = Object.const_get("REGRESSO_COMPARISONS")
|
|
44
|
+
|
|
45
|
+
runner = Regresso::Parallel::Runner.new(comparisons: comparisons)
|
|
46
|
+
result = runner.run
|
|
47
|
+
|
|
48
|
+
commenter = Regresso::CI::GitHubPRCommenter.new(token: token, repo: repo, pr_number: pr_number)
|
|
49
|
+
commenter.post_comment(result)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "regresso/history"
|
|
5
|
+
|
|
6
|
+
namespace :regresso do
|
|
7
|
+
namespace :history do
|
|
8
|
+
desc "Print history statistics"
|
|
9
|
+
task :stats, [:period] do |_task, args|
|
|
10
|
+
store = Regresso::History::Store.new
|
|
11
|
+
stats = store.statistics(period: (args[:period] || :week).to_sym)
|
|
12
|
+
puts stats
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
desc "Generate history trend report"
|
|
16
|
+
task :report, [:output, :period] do |_task, args|
|
|
17
|
+
store = Regresso::History::Store.new
|
|
18
|
+
reporter = Regresso::History::TrendReporter.new(store)
|
|
19
|
+
output = args[:output] || "tmp/regresso_trends.html"
|
|
20
|
+
FileUtils.mkdir_p(File.dirname(output))
|
|
21
|
+
File.write(output, reporter.generate(period: (args[:period] || :week).to_sym))
|
|
22
|
+
puts "Wrote #{output}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
desc "Cleanup history entries older than given days"
|
|
26
|
+
task :cleanup, [:days] do |_task, args|
|
|
27
|
+
days = (args[:days] || 30).to_i
|
|
28
|
+
cutoff = Time.now - (60 * 60 * 24 * days)
|
|
29
|
+
|
|
30
|
+
backend = Regresso::History::FileBackend.new
|
|
31
|
+
backend.all.each do |entry|
|
|
32
|
+
backend.delete(entry.id) if entry.timestamp < cutoff
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Regresso Report</title>
|
|
6
|
+
<style>
|
|
7
|
+
body { font-family: Arial, sans-serif; margin: 24px; }
|
|
8
|
+
table { border-collapse: collapse; width: 100%; margin-top: 16px; }
|
|
9
|
+
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
|
10
|
+
th { background: #f4f4f4; }
|
|
11
|
+
.diff-added { color: #0a7d00; }
|
|
12
|
+
.diff-removed { color: #a40000; }
|
|
13
|
+
.diff-changed { color: #0a4aa8; }
|
|
14
|
+
</style>
|
|
15
|
+
</head>
|
|
16
|
+
<body>
|
|
17
|
+
<h1>Regresso Report</h1>
|
|
18
|
+
|
|
19
|
+
<p><strong>Passed:</strong> <%= @result.passed? %></p>
|
|
20
|
+
<p><strong>Total diffs:</strong> <%= @result.diffs.size %></p>
|
|
21
|
+
<p><strong>Meaningful diffs:</strong> <%= @result.meaningful_diffs.size %></p>
|
|
22
|
+
|
|
23
|
+
<table>
|
|
24
|
+
<thead>
|
|
25
|
+
<tr>
|
|
26
|
+
<th>Path</th>
|
|
27
|
+
<th>Old</th>
|
|
28
|
+
<th>New</th>
|
|
29
|
+
<th>Type</th>
|
|
30
|
+
</tr>
|
|
31
|
+
</thead>
|
|
32
|
+
<tbody>
|
|
33
|
+
<% @result.meaningful_diffs.each do |diff| %>
|
|
34
|
+
<tr>
|
|
35
|
+
<td><%= diff.path.to_s %></td>
|
|
36
|
+
<td><%= diff.old_value.inspect %></td>
|
|
37
|
+
<td><%= diff.new_value.inspect %></td>
|
|
38
|
+
<td class="diff-<%= diff.type %>"><%= diff.type %></td>
|
|
39
|
+
</tr>
|
|
40
|
+
<% end %>
|
|
41
|
+
</tbody>
|
|
42
|
+
</table>
|
|
43
|
+
</body>
|
|
44
|
+
</html>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module Regresso
|
|
6
|
+
module WebUI
|
|
7
|
+
# Formats diffs for Web UI rendering.
|
|
8
|
+
class DiffFormatter
|
|
9
|
+
# @param diffs [Array<Hash>]
|
|
10
|
+
# @param mode [Symbol] :html or :plain
|
|
11
|
+
# @return [String]
|
|
12
|
+
def format(diffs, mode: :html)
|
|
13
|
+
case mode
|
|
14
|
+
when :html
|
|
15
|
+
diffs.map { |diff| format_html(diff) }.join("\n")
|
|
16
|
+
when :plain
|
|
17
|
+
diffs.map { |diff| format_plain(diff) }.join("\n")
|
|
18
|
+
else
|
|
19
|
+
raise ArgumentError, "Unknown format: #{mode}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def format_html(diff)
|
|
26
|
+
type = CGI.escapeHTML(diff["type"].to_s)
|
|
27
|
+
path = CGI.escapeHTML(diff["path"].to_s)
|
|
28
|
+
old_value = CGI.escapeHTML(diff["old_value"].inspect)
|
|
29
|
+
new_value = CGI.escapeHTML(diff["new_value"].inspect)
|
|
30
|
+
|
|
31
|
+
<<~HTML.strip
|
|
32
|
+
<div class="diff-row diff-#{type}">
|
|
33
|
+
<span class="diff-path">#{path}</span>
|
|
34
|
+
<span class="diff-old">#{old_value}</span>
|
|
35
|
+
<span class="diff-new">#{new_value}</span>
|
|
36
|
+
<span class="diff-type">#{type}</span>
|
|
37
|
+
</div>
|
|
38
|
+
HTML
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def format_plain(diff)
|
|
42
|
+
"#{diff['path']}: #{diff['old_value'].inspect} -> #{diff['new_value'].inspect}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #f4f1ea;
|
|
3
|
+
--ink: #1b1b1b;
|
|
4
|
+
--muted: #6c6c6c;
|
|
5
|
+
--accent: #d74b23;
|
|
6
|
+
--accent-2: #1d4d9f;
|
|
7
|
+
--surface: #ffffff;
|
|
8
|
+
--shadow: rgba(22, 22, 22, 0.15);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
* {
|
|
12
|
+
box-sizing: border-box;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
body {
|
|
16
|
+
margin: 0;
|
|
17
|
+
font-family: "Space Grotesk", "Avenir Next", "Futura", sans-serif;
|
|
18
|
+
color: var(--ink);
|
|
19
|
+
background: radial-gradient(circle at top left, #fbe7d5, transparent 55%),
|
|
20
|
+
radial-gradient(circle at 20% 80%, #d2e2ff, transparent 50%),
|
|
21
|
+
var(--bg);
|
|
22
|
+
min-height: 100vh;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.app-shell {
|
|
26
|
+
padding: 32px clamp(16px, 4vw, 48px) 48px;
|
|
27
|
+
animation: fadeIn 0.6s ease-out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.app-header {
|
|
31
|
+
display: flex;
|
|
32
|
+
flex-wrap: wrap;
|
|
33
|
+
gap: 24px;
|
|
34
|
+
align-items: flex-end;
|
|
35
|
+
justify-content: space-between;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.kicker {
|
|
39
|
+
text-transform: uppercase;
|
|
40
|
+
letter-spacing: 0.12em;
|
|
41
|
+
font-size: 12px;
|
|
42
|
+
margin: 0 0 6px;
|
|
43
|
+
color: var(--accent-2);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
h1 {
|
|
47
|
+
margin: 0;
|
|
48
|
+
font-size: clamp(32px, 4vw, 52px);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.subtitle {
|
|
52
|
+
margin: 8px 0 0;
|
|
53
|
+
color: var(--muted);
|
|
54
|
+
max-width: 520px;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.stats {
|
|
58
|
+
display: grid;
|
|
59
|
+
grid-template-columns: repeat(3, minmax(80px, 1fr));
|
|
60
|
+
gap: 12px;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.stat {
|
|
64
|
+
background: rgba(255, 255, 255, 0.75);
|
|
65
|
+
border-radius: 16px;
|
|
66
|
+
padding: 12px 16px;
|
|
67
|
+
box-shadow: 0 10px 24px var(--shadow);
|
|
68
|
+
backdrop-filter: blur(6px);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.stat-label {
|
|
72
|
+
font-size: 12px;
|
|
73
|
+
color: var(--muted);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.stat-value {
|
|
77
|
+
display: block;
|
|
78
|
+
font-size: 20px;
|
|
79
|
+
font-weight: 600;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.app-main {
|
|
83
|
+
display: grid;
|
|
84
|
+
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
|
|
85
|
+
gap: 24px;
|
|
86
|
+
margin-top: 32px;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.panel {
|
|
90
|
+
background: var(--surface);
|
|
91
|
+
border-radius: 20px;
|
|
92
|
+
padding: 20px;
|
|
93
|
+
box-shadow: 0 18px 40px var(--shadow);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.panel-header {
|
|
97
|
+
display: flex;
|
|
98
|
+
align-items: center;
|
|
99
|
+
justify-content: space-between;
|
|
100
|
+
gap: 12px;
|
|
101
|
+
margin-bottom: 16px;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.button {
|
|
105
|
+
border: none;
|
|
106
|
+
background: var(--accent);
|
|
107
|
+
color: white;
|
|
108
|
+
padding: 10px 16px;
|
|
109
|
+
border-radius: 999px;
|
|
110
|
+
font-weight: 600;
|
|
111
|
+
cursor: pointer;
|
|
112
|
+
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.button:hover {
|
|
116
|
+
transform: translateY(-2px);
|
|
117
|
+
box-shadow: 0 8px 16px rgba(215, 75, 35, 0.35);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.list {
|
|
121
|
+
display: grid;
|
|
122
|
+
gap: 12px;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.list-item {
|
|
126
|
+
border: 1px solid #e5e5e5;
|
|
127
|
+
border-radius: 16px;
|
|
128
|
+
padding: 14px;
|
|
129
|
+
cursor: pointer;
|
|
130
|
+
transition: border 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
|
131
|
+
background: #fff8f1;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.list-item:hover {
|
|
135
|
+
border-color: var(--accent);
|
|
136
|
+
transform: translateY(-2px);
|
|
137
|
+
box-shadow: 0 10px 18px rgba(215, 75, 35, 0.2);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.list-item.active {
|
|
141
|
+
border-color: var(--accent-2);
|
|
142
|
+
background: #eef4ff;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.detail-grid {
|
|
146
|
+
display: grid;
|
|
147
|
+
gap: 16px;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.summary {
|
|
151
|
+
display: grid;
|
|
152
|
+
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
|
153
|
+
gap: 12px;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.summary-card {
|
|
157
|
+
padding: 12px 14px;
|
|
158
|
+
border-radius: 14px;
|
|
159
|
+
background: #f7f7f7;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.summary-card strong {
|
|
163
|
+
display: block;
|
|
164
|
+
font-size: 18px;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.diffs {
|
|
168
|
+
min-height: 240px;
|
|
169
|
+
border-radius: 16px;
|
|
170
|
+
background: #fdfdfd;
|
|
171
|
+
border: 1px solid #e9e9e9;
|
|
172
|
+
padding: 16px;
|
|
173
|
+
display: grid;
|
|
174
|
+
gap: 8px;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.diff-row {
|
|
178
|
+
display: grid;
|
|
179
|
+
grid-template-columns: 1fr 1fr 1fr auto;
|
|
180
|
+
gap: 12px;
|
|
181
|
+
padding: 8px 10px;
|
|
182
|
+
border-radius: 10px;
|
|
183
|
+
background: #ffffff;
|
|
184
|
+
border: 1px solid #f0f0f0;
|
|
185
|
+
font-size: 13px;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.diff-added {
|
|
189
|
+
border-left: 4px solid #2c9f45;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.diff-removed {
|
|
193
|
+
border-left: 4px solid #d35454;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.diff-changed {
|
|
197
|
+
border-left: 4px solid #3c6fd1;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.diff-path {
|
|
201
|
+
font-weight: 600;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.detail-meta {
|
|
205
|
+
color: var(--muted);
|
|
206
|
+
margin: 6px 0 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.filters {
|
|
210
|
+
display: flex;
|
|
211
|
+
gap: 10px;
|
|
212
|
+
align-items: center;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.input,
|
|
216
|
+
.select {
|
|
217
|
+
border-radius: 999px;
|
|
218
|
+
border: 1px solid #ddd;
|
|
219
|
+
padding: 8px 14px;
|
|
220
|
+
font-size: 14px;
|
|
221
|
+
background: #fff;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.empty {
|
|
225
|
+
color: var(--muted);
|
|
226
|
+
text-align: center;
|
|
227
|
+
margin-top: 40px;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
@media (max-width: 900px) {
|
|
231
|
+
.app-main {
|
|
232
|
+
grid-template-columns: 1fr;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
@keyframes fadeIn {
|
|
237
|
+
from { opacity: 0; transform: translateY(10px); }
|
|
238
|
+
to { opacity: 1; transform: translateY(0); }
|
|
239
|
+
}
|