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.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +3 -0
  3. data/CHANGELOG.md +3 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +258 -0
  6. data/Rakefile +12 -0
  7. data/assets/logo-header.svg +28 -0
  8. data/lib/generators/regresso/install_generator.rb +29 -0
  9. data/lib/generators/regresso/templates/regresso.rb +18 -0
  10. data/lib/regresso/adapters/.gitkeep +0 -0
  11. data/lib/regresso/adapters/base.rb +30 -0
  12. data/lib/regresso/adapters/csv.rb +43 -0
  13. data/lib/regresso/adapters/database.rb +53 -0
  14. data/lib/regresso/adapters/database_snapshot.rb +49 -0
  15. data/lib/regresso/adapters/graphql.rb +75 -0
  16. data/lib/regresso/adapters/graphql_batch.rb +42 -0
  17. data/lib/regresso/adapters/http.rb +70 -0
  18. data/lib/regresso/adapters/json_file.rb +30 -0
  19. data/lib/regresso/adapters/proc.rb +30 -0
  20. data/lib/regresso/ci/github_annotation_formatter.rb +46 -0
  21. data/lib/regresso/ci/github_pr_commenter.rb +55 -0
  22. data/lib/regresso/ci/junit_xml_formatter.rb +42 -0
  23. data/lib/regresso/ci/reporter.rb +49 -0
  24. data/lib/regresso/ci.rb +6 -0
  25. data/lib/regresso/comparator.rb +45 -0
  26. data/lib/regresso/configuration.rb +92 -0
  27. data/lib/regresso/differ.rb +129 -0
  28. data/lib/regresso/difference.rb +45 -0
  29. data/lib/regresso/history/entry.rb +70 -0
  30. data/lib/regresso/history/file_backend.rb +51 -0
  31. data/lib/regresso/history/statistics.rb +65 -0
  32. data/lib/regresso/history/store.rb +122 -0
  33. data/lib/regresso/history/trend_reporter.rb +68 -0
  34. data/lib/regresso/history.rb +7 -0
  35. data/lib/regresso/json_path.rb +55 -0
  36. data/lib/regresso/minitest.rb +107 -0
  37. data/lib/regresso/notifiers/base.rb +33 -0
  38. data/lib/regresso/notifiers/microsoft_teams.rb +60 -0
  39. data/lib/regresso/notifiers/slack.rb +72 -0
  40. data/lib/regresso/notifiers.rb +5 -0
  41. data/lib/regresso/parallel/comparison_result.rb +51 -0
  42. data/lib/regresso/parallel/parallel_result.rb +68 -0
  43. data/lib/regresso/parallel/result_aggregator.rb +25 -0
  44. data/lib/regresso/parallel/runner.rb +71 -0
  45. data/lib/regresso/parallel.rb +6 -0
  46. data/lib/regresso/reporter.rb +68 -0
  47. data/lib/regresso/result.rb +70 -0
  48. data/lib/regresso/rspec/.gitkeep +0 -0
  49. data/lib/regresso/rspec/shared_examples.rb +42 -0
  50. data/lib/regresso/rspec.rb +142 -0
  51. data/lib/regresso/snapshot_manager.rb +57 -0
  52. data/lib/regresso/tasks/ci.rake +51 -0
  53. data/lib/regresso/tasks/history.rake +36 -0
  54. data/lib/regresso/templates/.gitkeep +0 -0
  55. data/lib/regresso/templates/report.html.erb +44 -0
  56. data/lib/regresso/version.rb +6 -0
  57. data/lib/regresso/web_ui/diff_formatter.rb +46 -0
  58. data/lib/regresso/web_ui/public/css/app.css +239 -0
  59. data/lib/regresso/web_ui/public/js/app.js +117 -0
  60. data/lib/regresso/web_ui/result_store.rb +60 -0
  61. data/lib/regresso/web_ui/server.rb +70 -0
  62. data/lib/regresso/web_ui/views/index.erb +58 -0
  63. data/lib/regresso/web_ui/views/layout.erb +13 -0
  64. data/lib/regresso/web_ui.rb +5 -0
  65. data/lib/regresso.rb +46 -0
  66. data/sig/regresso.rbs +4 -0
  67. data/site/Gemfile +7 -0
  68. data/site/assets/logo-header.png +0 -0
  69. data/site/assets/site.css +458 -0
  70. data/site/content/index.md +6 -0
  71. data/site/craze.yml +24 -0
  72. data/site/dist/assets/logo-header.svg +28 -0
  73. data/site/dist/assets/site.css +458 -0
  74. data/site/dist/index.html +232 -0
  75. data/site/dist/search.json +9 -0
  76. data/site/dist/sitemap.xml +7 -0
  77. data/site/templates/index.erb +232 -0
  78. data/site/templates/layout.erb +17 -0
  79. 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>
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "web_ui/result_store"
4
+ require_relative "web_ui/diff_formatter"
5
+ require_relative "web_ui/server"
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
@@ -0,0 +1,4 @@
1
+ module Regresso
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
data/site/Gemfile ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ ruby ">= 3.2.0"
6
+
7
+ gem "craze"
Binary file