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,117 @@
|
|
|
1
|
+
const listEl = document.getElementById("results-list");
|
|
2
|
+
const refreshBtn = document.getElementById("refresh");
|
|
3
|
+
const detailTitle = document.getElementById("detail-title");
|
|
4
|
+
const detailMeta = document.getElementById("detail-meta");
|
|
5
|
+
const detailSummary = document.getElementById("detail-summary");
|
|
6
|
+
const detailDiffs = document.getElementById("detail-diffs");
|
|
7
|
+
const filterPath = document.getElementById("filter-path");
|
|
8
|
+
const filterType = document.getElementById("filter-type");
|
|
9
|
+
const statTotal = document.getElementById("stat-total");
|
|
10
|
+
const statPassed = document.getElementById("stat-passed");
|
|
11
|
+
const statFailed = document.getElementById("stat-failed");
|
|
12
|
+
|
|
13
|
+
let currentId = null;
|
|
14
|
+
|
|
15
|
+
async function fetchJson(url) {
|
|
16
|
+
const response = await fetch(url);
|
|
17
|
+
if (!response.ok) {
|
|
18
|
+
throw new Error(`Request failed: ${response.status}`);
|
|
19
|
+
}
|
|
20
|
+
return response.json();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatDate(dateString) {
|
|
24
|
+
const date = new Date(dateString);
|
|
25
|
+
return date.toLocaleString();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function renderList(results) {
|
|
29
|
+
listEl.innerHTML = "";
|
|
30
|
+
if (!results.length) {
|
|
31
|
+
listEl.innerHTML = '<div class="empty">No results yet.</div>';
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
results.forEach((result) => {
|
|
36
|
+
const item = document.createElement("div");
|
|
37
|
+
item.className = "list-item" + (result.id === currentId ? " active" : "");
|
|
38
|
+
item.innerHTML = `
|
|
39
|
+
<strong>${result.name || "Run"}</strong>
|
|
40
|
+
<div>${formatDate(result.created_at)}</div>
|
|
41
|
+
<div>${result.summary ? `diffs: ${result.summary.meaningful_diffs}` : ""}</div>
|
|
42
|
+
`;
|
|
43
|
+
item.addEventListener("click", () => selectResult(result.id));
|
|
44
|
+
listEl.appendChild(item);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function renderSummary(summary) {
|
|
49
|
+
if (!summary) {
|
|
50
|
+
detailSummary.innerHTML = "";
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
detailSummary.innerHTML = `
|
|
55
|
+
<div class="summary-card"><span>Total diffs</span><strong>${summary.total_diffs}</strong></div>
|
|
56
|
+
<div class="summary-card"><span>Meaningful</span><strong>${summary.meaningful_diffs}</strong></div>
|
|
57
|
+
<div class="summary-card"><span>Ignored</span><strong>${summary.ignored_diffs}</strong></div>
|
|
58
|
+
<div class="summary-card"><span>Passed</span><strong>${summary.passed}</strong></div>
|
|
59
|
+
`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function renderDiffs(id) {
|
|
63
|
+
const params = new URLSearchParams();
|
|
64
|
+
if (filterPath.value) params.set("path", filterPath.value);
|
|
65
|
+
if (filterType.value) params.set("type", filterType.value);
|
|
66
|
+
params.set("format", "html");
|
|
67
|
+
|
|
68
|
+
const data = await fetchJson(`/api/results/${id}/diffs?${params.toString()}`);
|
|
69
|
+
if (!data.diffs.length) {
|
|
70
|
+
detailDiffs.innerHTML = '<div class="empty">No matching diffs.</div>';
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
detailDiffs.innerHTML = data.formatted;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function selectResult(id) {
|
|
78
|
+
currentId = id;
|
|
79
|
+
const result = await fetchJson(`/api/results/${id}`);
|
|
80
|
+
|
|
81
|
+
detailTitle.textContent = result.name || "Regression Run";
|
|
82
|
+
detailMeta.textContent = `Created ${formatDate(result.created_at)}`;
|
|
83
|
+
renderSummary(result.summary);
|
|
84
|
+
await renderDiffs(id);
|
|
85
|
+
|
|
86
|
+
const results = await fetchJson("/api/results");
|
|
87
|
+
renderList(results);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function refresh() {
|
|
91
|
+
const results = await fetchJson("/api/results");
|
|
92
|
+
renderList(results);
|
|
93
|
+
|
|
94
|
+
const passed = results.filter((item) => item.summary && item.summary.passed).length;
|
|
95
|
+
statTotal.textContent = results.length;
|
|
96
|
+
statPassed.textContent = passed;
|
|
97
|
+
statFailed.textContent = results.length - passed;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
filterPath.addEventListener("input", () => {
|
|
101
|
+
if (currentId) {
|
|
102
|
+
renderDiffs(currentId);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
filterType.addEventListener("change", () => {
|
|
107
|
+
if (currentId) {
|
|
108
|
+
renderDiffs(currentId);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
refreshBtn.addEventListener("click", refresh);
|
|
113
|
+
|
|
114
|
+
refresh().catch((error) => {
|
|
115
|
+
console.error(error);
|
|
116
|
+
listEl.innerHTML = '<div class="empty">Failed to load results.</div>';
|
|
117
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Regresso
|
|
8
|
+
module WebUI
|
|
9
|
+
# Simple file-backed store for Web UI results.
|
|
10
|
+
class ResultStore
|
|
11
|
+
# @param storage_path [String,nil]
|
|
12
|
+
def initialize(storage_path: nil)
|
|
13
|
+
@storage_path = storage_path || "tmp/regresso_results"
|
|
14
|
+
@cache = {}
|
|
15
|
+
FileUtils.mkdir_p(@storage_path)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @param result [Hash]
|
|
19
|
+
# @return [String] stored result id
|
|
20
|
+
def store(result)
|
|
21
|
+
id = SecureRandom.uuid
|
|
22
|
+
data = { "id" => id, "created_at" => Time.now.utc.iso8601(6) }.merge(result)
|
|
23
|
+
File.write(path_for(id), JSON.generate(data))
|
|
24
|
+
@cache[id] = data
|
|
25
|
+
id
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param id [String]
|
|
29
|
+
# @return [Hash,nil]
|
|
30
|
+
def find(id)
|
|
31
|
+
return @cache[id] if @cache.key?(id)
|
|
32
|
+
|
|
33
|
+
path = path_for(id)
|
|
34
|
+
return nil unless File.exist?(path)
|
|
35
|
+
|
|
36
|
+
@cache[id] = JSON.parse(File.read(path))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Array<Hash>]
|
|
40
|
+
def list
|
|
41
|
+
Dir.glob(File.join(@storage_path, "*.json")).map do |path|
|
|
42
|
+
find(File.basename(path, ".json"))
|
|
43
|
+
end.compact.sort_by { |entry| Time.parse(entry["created_at"].to_s) }.reverse
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param id [String]
|
|
47
|
+
# @return [void]
|
|
48
|
+
def delete(id)
|
|
49
|
+
@cache.delete(id)
|
|
50
|
+
FileUtils.rm_f(path_for(id))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def path_for(id)
|
|
56
|
+
File.join(@storage_path, "#{id}.json")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sinatra/base"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require "regresso/web_ui/result_store"
|
|
7
|
+
require "regresso/web_ui/diff_formatter"
|
|
8
|
+
|
|
9
|
+
module Regresso
|
|
10
|
+
# Web UI server and helpers.
|
|
11
|
+
module WebUI
|
|
12
|
+
# Sinatra-based Web UI server.
|
|
13
|
+
class Server < Sinatra::Base
|
|
14
|
+
set :public_folder, File.join(__dir__, "public")
|
|
15
|
+
set :views, File.join(__dir__, "views")
|
|
16
|
+
set :result_store, ResultStore.new
|
|
17
|
+
|
|
18
|
+
get "/" do
|
|
19
|
+
erb :index
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
get "/api/results" do
|
|
23
|
+
content_type :json
|
|
24
|
+
settings.result_store.list.to_json
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
get "/api/results/:id" do
|
|
28
|
+
content_type :json
|
|
29
|
+
result = settings.result_store.find(params[:id])
|
|
30
|
+
halt 404, { error: "Not found" }.to_json unless result
|
|
31
|
+
result.to_json
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
post "/api/results" do
|
|
35
|
+
content_type :json
|
|
36
|
+
data = JSON.parse(request.body.read)
|
|
37
|
+
id = settings.result_store.store(data)
|
|
38
|
+
status 201
|
|
39
|
+
{ id: id, status: "created" }.to_json
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
delete "/api/results/:id" do
|
|
43
|
+
settings.result_store.delete(params[:id])
|
|
44
|
+
status 204
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
get "/api/results/:id/diffs" do
|
|
48
|
+
content_type :json
|
|
49
|
+
result = settings.result_store.find(params[:id])
|
|
50
|
+
halt 404, { error: "Not found" }.to_json unless result
|
|
51
|
+
|
|
52
|
+
diffs = Array(result["diffs"])
|
|
53
|
+
if params[:type]
|
|
54
|
+
diffs = diffs.select { |diff| diff["type"] == params[:type] }
|
|
55
|
+
end
|
|
56
|
+
if params[:path]
|
|
57
|
+
diffs = diffs.select { |diff| diff["path"].to_s.include?(params[:path]) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
mode = params[:format] == "html" ? :html : :plain
|
|
61
|
+
formatter = DiffFormatter.new
|
|
62
|
+
|
|
63
|
+
{
|
|
64
|
+
diffs: diffs,
|
|
65
|
+
formatted: formatter.format(diffs, mode: mode)
|
|
66
|
+
}.to_json
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<section class="app-shell">
|
|
2
|
+
<header class="app-header">
|
|
3
|
+
<div>
|
|
4
|
+
<p class="kicker">Regresso</p>
|
|
5
|
+
<h1>Regression Hub</h1>
|
|
6
|
+
<p class="subtitle">Compare environments, surface drift, and keep releases honest.</p>
|
|
7
|
+
</div>
|
|
8
|
+
<div class="stats">
|
|
9
|
+
<div class="stat">
|
|
10
|
+
<span class="stat-label">Runs</span>
|
|
11
|
+
<span class="stat-value" id="stat-total">0</span>
|
|
12
|
+
</div>
|
|
13
|
+
<div class="stat">
|
|
14
|
+
<span class="stat-label">Passed</span>
|
|
15
|
+
<span class="stat-value" id="stat-passed">0</span>
|
|
16
|
+
</div>
|
|
17
|
+
<div class="stat">
|
|
18
|
+
<span class="stat-label">Failed</span>
|
|
19
|
+
<span class="stat-value" id="stat-failed">0</span>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
</header>
|
|
23
|
+
|
|
24
|
+
<main class="app-main">
|
|
25
|
+
<section class="panel panel-list">
|
|
26
|
+
<div class="panel-header">
|
|
27
|
+
<h2>Recent Runs</h2>
|
|
28
|
+
<button class="button" id="refresh">Refresh</button>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="list" id="results-list"></div>
|
|
31
|
+
</section>
|
|
32
|
+
|
|
33
|
+
<section class="panel panel-detail">
|
|
34
|
+
<div class="panel-header">
|
|
35
|
+
<div>
|
|
36
|
+
<h2 id="detail-title">Select a run</h2>
|
|
37
|
+
<p class="detail-meta" id="detail-meta">No run selected</p>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="filters">
|
|
40
|
+
<input id="filter-path" class="input" type="text" placeholder="Filter by path">
|
|
41
|
+
<select id="filter-type" class="select">
|
|
42
|
+
<option value="">All types</option>
|
|
43
|
+
<option value="added">Added</option>
|
|
44
|
+
<option value="removed">Removed</option>
|
|
45
|
+
<option value="changed">Changed</option>
|
|
46
|
+
</select>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div class="detail-grid">
|
|
51
|
+
<div class="summary" id="detail-summary"></div>
|
|
52
|
+
<div class="diffs" id="detail-diffs">
|
|
53
|
+
<div class="empty">Pick a run to see differences.</div>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</section>
|
|
57
|
+
</main>
|
|
58
|
+
</section>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Regresso Web UI</title>
|
|
7
|
+
<link rel="stylesheet" href="/css/app.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<%= yield %>
|
|
11
|
+
<script src="/js/app.js"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
data/lib/regresso.rb
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "regresso/version"
|
|
4
|
+
require_relative "regresso/json_path"
|
|
5
|
+
require_relative "regresso/difference"
|
|
6
|
+
require_relative "regresso/configuration"
|
|
7
|
+
require_relative "regresso/differ"
|
|
8
|
+
require_relative "regresso/result"
|
|
9
|
+
require_relative "regresso/comparator"
|
|
10
|
+
require_relative "regresso/adapters/base"
|
|
11
|
+
require_relative "regresso/adapters/proc"
|
|
12
|
+
require_relative "regresso/adapters/json_file"
|
|
13
|
+
require_relative "regresso/adapters/csv"
|
|
14
|
+
require_relative "regresso/adapters/http"
|
|
15
|
+
require_relative "regresso/adapters/database"
|
|
16
|
+
require_relative "regresso/adapters/database_snapshot"
|
|
17
|
+
require_relative "regresso/adapters/graphql"
|
|
18
|
+
require_relative "regresso/adapters/graphql_batch"
|
|
19
|
+
require_relative "regresso/snapshot_manager"
|
|
20
|
+
require_relative "regresso/reporter"
|
|
21
|
+
|
|
22
|
+
# Regresso provides regression comparison utilities for structured data.
|
|
23
|
+
module Regresso
|
|
24
|
+
# Base error for Regresso-specific failures.
|
|
25
|
+
class Error < StandardError; end
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
attr_accessor :configuration
|
|
29
|
+
|
|
30
|
+
# Yields the global configuration.
|
|
31
|
+
#
|
|
32
|
+
# @yieldparam config [Regresso::Configuration]
|
|
33
|
+
# @return [void]
|
|
34
|
+
def configure
|
|
35
|
+
self.configuration ||= Configuration.new
|
|
36
|
+
yield(configuration) if block_given?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Resets the global configuration to defaults.
|
|
40
|
+
#
|
|
41
|
+
# @return [void]
|
|
42
|
+
def reset_configuration!
|
|
43
|
+
self.configuration = Configuration.new
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/sig/regresso.rbs
ADDED
data/site/Gemfile
ADDED
|
Binary file
|