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.
@@ -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