twinshot 1.2.1
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/LICENSE.txt +21 -0
- data/PLAN.md +107 -0
- data/README.md +179 -0
- data/Rakefile +12 -0
- data/exe/twinshot +6 -0
- data/lib/twinshot/adapters/binary.rb +21 -0
- data/lib/twinshot/adapters/html.rb +27 -0
- data/lib/twinshot/adapters/png.rb +128 -0
- data/lib/twinshot/adapters/txt.rb +27 -0
- data/lib/twinshot/adapters/xml.rb +28 -0
- data/lib/twinshot/adapters.rb +105 -0
- data/lib/twinshot/cli.rb +503 -0
- data/lib/twinshot/compare.rb +249 -0
- data/lib/twinshot/compare_worker.rb +47 -0
- data/lib/twinshot/config.rb +212 -0
- data/lib/twinshot/logo.svg +8 -0
- data/lib/twinshot/opener/darwin.rb +18 -0
- data/lib/twinshot/opener/linux.rb +19 -0
- data/lib/twinshot/opener/windows.rb +20 -0
- data/lib/twinshot/opener.rb +59 -0
- data/lib/twinshot/progress.rb +104 -0
- data/lib/twinshot/report.rb +600 -0
- data/lib/twinshot/soft_check.rb +19 -0
- data/lib/twinshot/tree.rb +31 -0
- data/lib/twinshot/version.rb +5 -0
- data/lib/twinshot.rb +18 -0
- data/logo.svg +100 -0
- data/sig/twinshot.rbs +4 -0
- metadata +130 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module Twinshot
|
|
6
|
+
# Cross-platform port of macOS `open`: launch the default browser with a
|
|
7
|
+
# local file (the report). One adapter per OS family — Darwin (`open`),
|
|
8
|
+
# Windows (`start` via cmd.exe), Linux (`xdg-open`) — selected by
|
|
9
|
+
# RbConfig's host_os. The registry mirrors the format-adapters one.
|
|
10
|
+
module Opener
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
|
|
13
|
+
@adapters = {}
|
|
14
|
+
class << self
|
|
15
|
+
attr_reader :adapters
|
|
16
|
+
|
|
17
|
+
def register(key, adapter)
|
|
18
|
+
@adapters[key.to_s] = adapter
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# host_os (RbConfig::CONFIG["host_os"]) normalized to the adapter key.
|
|
22
|
+
def platform_for(host_os)
|
|
23
|
+
case host_os.to_s
|
|
24
|
+
when /darwin|macintosh/ then "darwin"
|
|
25
|
+
when /mswin|mingw|cygwin|bccwin|wince|emx|msys|windows/ then "windows"
|
|
26
|
+
else "linux"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def for_platform(host_os)
|
|
31
|
+
adapters.fetch(platform_for(host_os)) do
|
|
32
|
+
raise Error, "no opener adapter for platform #{host_os.inspect}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Adapter for the running Ruby build.
|
|
37
|
+
def current
|
|
38
|
+
for_platform(RbConfig::CONFIG["host_os"])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Expand and open a local file in the default browser. Raises Error
|
|
42
|
+
# (nothing thrown at the caller's exit code by itself).
|
|
43
|
+
def open(path)
|
|
44
|
+
current.open(File.expand_path(path))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Spawn helper shared by the adapters; stdout/stderr suppressed (openers
|
|
48
|
+
# chatter). Returns the system() truth value — false/nil means the
|
|
49
|
+
# command is missing or refused to run. Stubbed in tests.
|
|
50
|
+
def launch(*args)
|
|
51
|
+
system(*args, out: File::NULL, err: File::NULL)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
require_relative "opener/darwin"
|
|
58
|
+
require_relative "opener/windows"
|
|
59
|
+
require_relative "opener/linux"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "colorize"
|
|
4
|
+
|
|
5
|
+
module Twinshot
|
|
6
|
+
# Human-friendly run progress on stdout: banner, one line per result with
|
|
7
|
+
# color, summary block. Colors only when the output is a tty; fully silent
|
|
8
|
+
# when quiet (e.g. --json).
|
|
9
|
+
class Progress
|
|
10
|
+
def initialize(out: $stdout, quiet: false)
|
|
11
|
+
@out = quiet ? nil : out
|
|
12
|
+
@color = !@out.nil? && @out.tty?
|
|
13
|
+
@index = 0
|
|
14
|
+
@total = nil
|
|
15
|
+
@fails = []
|
|
16
|
+
@warns = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def banner(config, group:, oracle_count:, current_count:, total:, workers:)
|
|
20
|
+
@total = total
|
|
21
|
+
@index = 0
|
|
22
|
+
return unless @out
|
|
23
|
+
|
|
24
|
+
@out.puts paint("twinshot — 1:1 parity · group #{group.name}", :bold)
|
|
25
|
+
@out.puts " oracle #{display_path(group.oracle_path)} (#{oracle_count} files)"
|
|
26
|
+
@out.puts " current #{display_path(group.current_path)} (#{current_count} files)"
|
|
27
|
+
mode = workers == 1 ? "sequential" : "#{workers} workers"
|
|
28
|
+
@out.puts " #{total} results · #{mode} · png threshold #{format("%.2f", config.max_diff_ratio * 100)}%"
|
|
29
|
+
@out.puts
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def result(result)
|
|
33
|
+
return unless @out
|
|
34
|
+
|
|
35
|
+
@index += 1
|
|
36
|
+
status = result["status"]
|
|
37
|
+
@fails << result if status == "fail"
|
|
38
|
+
@warns << result if status == "warn"
|
|
39
|
+
mark, tone = case status
|
|
40
|
+
when "pass" then [paint("✓", :green), nil]
|
|
41
|
+
when "warn" then [paint("!", :yellow), :yellow]
|
|
42
|
+
else [paint("✗", :red), :red]
|
|
43
|
+
end
|
|
44
|
+
detail = result["detail"].to_s
|
|
45
|
+
detail = paint(detail, tone) if tone
|
|
46
|
+
rel = result["group"] ? "#{result["group"]}/#{result["rel"]}" : result["rel"]
|
|
47
|
+
@out.puts format(" [%3d/%-3d] %s %s — %s", @index, @total || "?", mark, rel, detail)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def summary(summary, report_path, elapsed: nil)
|
|
51
|
+
return unless @out
|
|
52
|
+
|
|
53
|
+
@out.puts
|
|
54
|
+
if summary["failed"].zero?
|
|
55
|
+
warned = summary["warned"].to_i
|
|
56
|
+
ok = "OK: #{summary["passed"]}/#{summary["total"]} passed"
|
|
57
|
+
ok = "#{ok} (#{warned} warn)" if warned.positive?
|
|
58
|
+
@out.puts paint(ok, :green)
|
|
59
|
+
if warned.positive?
|
|
60
|
+
# Copyable list of every warned file (soft_check), like FAILS below.
|
|
61
|
+
@out.puts paint("WARNS:", :yellow)
|
|
62
|
+
@warns.each { |r| @out.puts paint(rel_path(r), :yellow) }
|
|
63
|
+
@out.puts
|
|
64
|
+
end
|
|
65
|
+
else
|
|
66
|
+
breakdown = summary["results"]
|
|
67
|
+
.select { |r| r["status"] == "fail" }
|
|
68
|
+
.group_by { |r| r["kind"] }
|
|
69
|
+
.map { |kind, rs| "#{kind}: #{rs.size}" }.join(", ")
|
|
70
|
+
@out.puts paint("FAIL: #{summary["failed"]}/#{summary["total"]} failed (#{breakdown})", :red)
|
|
71
|
+
# Copyable list of every failed file, printed once more as a group.
|
|
72
|
+
@out.puts paint("FAILS:", :red)
|
|
73
|
+
@fails.each { |r| @out.puts rel_path(r) }
|
|
74
|
+
@out.puts
|
|
75
|
+
end
|
|
76
|
+
@out.puts "Done in #{format_duration(elapsed)}" if elapsed
|
|
77
|
+
@out.puts "report: #{report_path}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def rel_path(result)
|
|
83
|
+
result["group"] ? "#{result["group"]}/#{result["rel"]}" : result["rel"]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def paint(text, meth)
|
|
87
|
+
return text.to_s unless @color
|
|
88
|
+
|
|
89
|
+
text.to_s.public_send(meth)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def format_duration(seconds)
|
|
93
|
+
return format("%.2fs", seconds) if seconds < 60
|
|
94
|
+
|
|
95
|
+
format("%dm %.1fs", (seconds / 60).to_i, seconds % 60)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def display_path(path)
|
|
99
|
+
Pathname.new(path).relative_path_from(Pathname.pwd).to_s
|
|
100
|
+
rescue ArgumentError
|
|
101
|
+
path
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|