checkset 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.
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Checkset
4
+ class Prep
5
+ attr_reader :credentials, :target_url
6
+
7
+ def initialize(credentials:, target_url:)
8
+ @credentials = credentials
9
+ @target_url = target_url
10
+ end
11
+
12
+ # Override in subclass — perform the prep action.
13
+ # Receives the same Playwright page the check will use,
14
+ # so browser state (cookies, localStorage) persists.
15
+ def satisfy(page)
16
+ raise NotImplementedError, "#{self.class}#satisfy must be implemented"
17
+ end
18
+
19
+ # Override in subclass — optional optimization.
20
+ # Return true to skip satisfy (e.g., already signed in).
21
+ def satisfied?(page)
22
+ false
23
+ end
24
+
25
+ # Resolve a prep name to its class.
26
+ # :sign_in_as_admin → Preps::SignInAsAdmin
27
+ def self.resolve(name)
28
+ return name if name.is_a?(Class) && name < Checkset::Prep
29
+
30
+ const_name = name.to_s.split("_").map(&:capitalize).join
31
+ if defined?(Preps) && Preps.const_defined?(const_name)
32
+ Preps.const_get(const_name)
33
+ else
34
+ raise NameError, "Could not resolve prep :#{name} — expected class Preps::#{const_name}"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Checkset
4
+ module Reporter
5
+ class CLI
6
+ COLORS = {
7
+ green: "\e[32m",
8
+ red: "\e[31m",
9
+ yellow: "\e[33m",
10
+ bold: "\e[1m",
11
+ dim: "\e[2m",
12
+ reset: "\e[0m"
13
+ }.freeze
14
+
15
+ def initialize(target_url:, suite_name: nil, io: $stdout)
16
+ @target_url = target_url
17
+ @suite_name = suite_name
18
+ @io = io
19
+ @color = io.respond_to?(:tty?) && io.tty?
20
+ @mutex = Mutex.new
21
+ end
22
+
23
+ def print_header
24
+ puts
25
+ if @suite_name
26
+ puts "#{bold}Checkset#{reset} — #{bold}#{@suite_name}#{reset} → #{@target_url}"
27
+ else
28
+ puts "#{bold}Checkset#{reset} — #{@target_url}"
29
+ end
30
+ puts line
31
+ puts
32
+ end
33
+
34
+ def report_result(result)
35
+ @mutex.synchronize do
36
+ status = format_status(result.status)
37
+ name = result.check_name.to_s.split("::").last || result.check_name
38
+ duration = result.duration ? format("%.1fs", result.duration) : "?"
39
+ dots = "." * [2, 50 - name.length].max
40
+
41
+ puts " #{status} #{name} #{dim}#{dots}#{reset} #{duration}"
42
+
43
+ if result.failed?
44
+ result.step_results.select(&:failed?).each do |sr|
45
+ puts " #{red}✗#{reset} #{sr.type} #{dim}\"#{sr.name}\"#{reset} — #{sr.error}"
46
+ end
47
+
48
+ result.step_results.select(&:failed?).each do |sr|
49
+ if sr.screenshot_path
50
+ puts " #{dim}screenshot: #{sr.screenshot_path}#{reset}"
51
+ end
52
+ end
53
+
54
+ if result.trace_path
55
+ puts " #{dim}trace: #{result.trace_path}#{reset}"
56
+ end
57
+
58
+ if result.error && result.step_results.none?(&:failed?)
59
+ puts " #{red}✗#{reset} #{result.error}"
60
+ end
61
+ end
62
+
63
+ if result.status == :skipped
64
+ puts " #{yellow}⊘#{reset} #{result.error || "skipped"}"
65
+ end
66
+ end
67
+ end
68
+
69
+ def print_summary(results)
70
+ puts
71
+ puts line
72
+
73
+ passed = results.count(&:passed?)
74
+ failed = results.count(&:failed?)
75
+ skipped = results.count { |r| r.status == :skipped }
76
+ total_duration = results.sum { |r| r.duration || 0 }
77
+
78
+ parts = []
79
+ parts << "#{green}#{passed} passed#{reset}" if passed > 0
80
+ parts << "#{red}#{failed} failed#{reset}" if failed > 0
81
+ parts << "#{yellow}#{skipped} skipped#{reset}" if skipped > 0
82
+
83
+ puts "#{parts.join(", ")} #{dim}(#{format("%.1fs", total_duration)})#{reset}"
84
+ end
85
+
86
+ def print_results_path(path)
87
+ puts "#{dim}Results: #{path}#{reset}"
88
+ puts
89
+ end
90
+
91
+ private
92
+
93
+ def puts(text = "")
94
+ @io.puts(text)
95
+ end
96
+
97
+ def line
98
+ "━" * 50
99
+ end
100
+
101
+ def format_status(status)
102
+ case status
103
+ when :passed then "#{green}#{bold} PASS #{reset}"
104
+ when :failed then "#{red}#{bold} FAIL #{reset}"
105
+ when :skipped then "#{yellow}#{bold} SKIP #{reset}"
106
+ else " ?? "
107
+ end
108
+ end
109
+
110
+ def bold = @color ? COLORS[:bold] : ""
111
+ def dim = @color ? COLORS[:dim] : ""
112
+ def green = @color ? COLORS[:green] : ""
113
+ def red = @color ? COLORS[:red] : ""
114
+ def yellow = @color ? COLORS[:yellow] : ""
115
+ def reset = @color ? COLORS[:reset] : ""
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Checkset
7
+ module Reporter
8
+ class JSON
9
+ def initialize(configuration: Checkset.configuration)
10
+ @configuration = configuration
11
+ end
12
+
13
+ def write(results, target_url: @configuration.target_url, suite_name: nil)
14
+ dir = @configuration.results_dir
15
+ FileUtils.mkdir_p(dir)
16
+
17
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
18
+ prefix = suite_name ? "results_#{suite_name}" : "results"
19
+ path = File.join(dir, "#{prefix}_#{timestamp}.json")
20
+
21
+ total_duration = results.sum { |r| r.duration || 0 }
22
+
23
+ data = {
24
+ run_at: Time.now.iso8601,
25
+ target_url: target_url,
26
+ duration: total_duration.round(2),
27
+ summary: {
28
+ total: results.size,
29
+ passed: results.count(&:passed?),
30
+ failed: results.count(&:failed?),
31
+ skipped: results.count { |r| r.status == :skipped }
32
+ },
33
+ checks: results.map(&:to_h)
34
+ }
35
+
36
+ File.write(path, ::JSON.pretty_generate(data))
37
+ path
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Checkset
4
+ class Result
5
+ attr_reader :check_name, :status, :started_at, :finished_at,
6
+ :step_results, :prep_results, :trace_path,
7
+ :error, :error_backtrace
8
+
9
+ def initialize(check_name:)
10
+ @check_name = check_name
11
+ @status = :pending
12
+ @started_at = nil
13
+ @finished_at = nil
14
+ @step_results = []
15
+ @prep_results = []
16
+ @trace_path = nil
17
+ @error = nil
18
+ @error_backtrace = nil
19
+ end
20
+
21
+ def start!
22
+ @status = :running
23
+ @started_at = Time.now
24
+ end
25
+
26
+ def pass!
27
+ @status = :passed
28
+ @finished_at = Time.now
29
+ end
30
+
31
+ def fail!(error: nil)
32
+ @status = :failed
33
+ @finished_at = Time.now
34
+ if error.is_a?(Exception)
35
+ @error = error.message
36
+ @error_backtrace = error.backtrace&.first(10)
37
+ elsif error
38
+ @error = error.to_s
39
+ end
40
+ end
41
+
42
+ def skip!(reason: nil)
43
+ @status = :skipped
44
+ @finished_at = Time.now
45
+ @error = reason
46
+ end
47
+
48
+ def add_step_result(step_result)
49
+ @step_results << step_result
50
+ end
51
+
52
+ def add_prep_result(prep_result)
53
+ @prep_results << prep_result
54
+ end
55
+
56
+ def trace_path=(path)
57
+ @trace_path = path
58
+ end
59
+
60
+ def duration
61
+ return nil unless @started_at && @finished_at
62
+ @finished_at - @started_at
63
+ end
64
+
65
+ def passed?
66
+ @status == :passed
67
+ end
68
+
69
+ def failed?
70
+ @status == :failed
71
+ end
72
+
73
+ def to_h
74
+ {
75
+ check: @check_name,
76
+ status: @status,
77
+ duration: duration,
78
+ preps: @prep_results.map(&:to_h),
79
+ steps: @step_results.map(&:to_h),
80
+ trace_path: @trace_path,
81
+ error: @error,
82
+ error_backtrace: @error_backtrace
83
+ }.compact
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Checkset
6
+ class Runner
7
+ def initialize(configuration: Checkset.configuration, filter: nil, tag: nil, target_url: nil, suite_name: nil, browser_manager: nil, preloaded: false)
8
+ @configuration = configuration
9
+ @filter = filter
10
+ @tag = tag
11
+ @target_url = target_url || @configuration.target_url
12
+ @suite_name = suite_name
13
+ @browser_manager = browser_manager || BrowserManager.new(configuration: configuration)
14
+ @owns_browser = browser_manager.nil?
15
+ @preloaded = preloaded
16
+ @credentials = Credentials.new(provider: configuration.credentials_provider)
17
+ end
18
+
19
+ # Preload all check and prep files without running anything.
20
+ # Call this before spawning concurrent suite runners.
21
+ def self.preload(configuration: Checkset.configuration)
22
+ configuration.checks_paths.each do |path|
23
+ Dir.glob(File.join(path, "**", "*.rb")).sort.each { |f| require File.expand_path(f) }
24
+ end
25
+ configuration.preps_paths.each do |path|
26
+ Dir.glob(File.join(path, "**", "*.rb")).sort.each { |f| require File.expand_path(f) }
27
+ end
28
+ end
29
+
30
+ def run
31
+ if @preloaded
32
+ compute_check_files
33
+ else
34
+ load_check_files
35
+ load_prep_files
36
+ end
37
+ check_classes = discover_checks
38
+
39
+ if check_classes.empty?
40
+ Checkset.logger.warn("No checks found in #{@configuration.checks_paths.inspect}")
41
+ return []
42
+ end
43
+
44
+ @browser_manager.start! if @owns_browser
45
+
46
+ results = if @configuration.parallel > 1
47
+ run_parallel(check_classes, pool_size: @configuration.parallel)
48
+ else
49
+ check_classes.map { |klass| run_check(klass) }
50
+ end
51
+
52
+ results
53
+ ensure
54
+ @browser_manager.stop! if @owns_browser
55
+ end
56
+
57
+ private
58
+
59
+ def compute_check_files
60
+ @check_files = []
61
+
62
+ @configuration.checks_paths.each do |path|
63
+ if @suite_name
64
+ @check_files.concat(Dir.glob(File.join(path, "*.rb")).sort)
65
+ @check_files.concat(Dir.glob(File.join(path, @suite_name, "**", "*.rb")).sort)
66
+ else
67
+ @check_files.concat(Dir.glob(File.join(path, "**", "*.rb")).sort)
68
+ end
69
+ end
70
+
71
+ @check_files.map! { |f| File.expand_path(f) }
72
+ end
73
+
74
+ def load_check_files
75
+ compute_check_files
76
+ @check_files.each { |f| require f }
77
+ end
78
+
79
+ def load_prep_files
80
+ @configuration.preps_paths.each do |path|
81
+ Dir.glob(File.join(path, "**", "*.rb")).sort.each { |f| require File.expand_path(f) }
82
+ end
83
+ end
84
+
85
+ def discover_checks
86
+ classes = ObjectSpace.each_object(Class).select { |klass| klass < Checkset::Check }
87
+
88
+ if @check_files
89
+ classes.select! do |klass|
90
+ source = klass.instance_method(:call).source_location&.first
91
+ source && @check_files.include?(source)
92
+ end
93
+ end
94
+
95
+ if @filter
96
+ classes.select! { |klass| klass.name&.include?(@filter) }
97
+ end
98
+
99
+ if @tag
100
+ classes.select! { |klass| klass.tags.include?(@tag) }
101
+ end
102
+
103
+ # Sort top-level checks first, then suite-specific checks, alphabetically within each group
104
+ top_level_files = @suite_name ? @configuration.checks_paths.flat_map { |p| Dir.glob(File.join(p, "*.rb")).map { |f| File.expand_path(f) } } : []
105
+
106
+ classes.sort_by do |klass|
107
+ source = klass.instance_method(:call).source_location&.first
108
+ top_level = top_level_files.include?(source) ? 0 : 1
109
+ [top_level, klass.name || ""]
110
+ end
111
+ end
112
+
113
+ def run_check(klass)
114
+ max_attempts = @configuration.retries + 1
115
+
116
+ max_attempts.times do |attempt|
117
+ result = attempt_check(klass)
118
+
119
+ # Return immediately on pass, skip, or last attempt
120
+ if result.passed? || result.status == :skipped || attempt == max_attempts - 1
121
+ return result
122
+ end
123
+
124
+ Checkset.logger.info("Retrying #{klass.name} (attempt #{attempt + 2}/#{max_attempts})")
125
+ end
126
+ end
127
+
128
+ def attempt_check(klass)
129
+ result = Result.new(check_name: klass.name || klass.to_s)
130
+ result.start!
131
+
132
+ @browser_manager.with_context do |page, context|
133
+ # Run preps
134
+ prep_ok = run_preps(klass, page, result)
135
+ unless prep_ok
136
+ result.skip!(reason: "Prep failed")
137
+ save_trace(context, result)
138
+ next
139
+ end
140
+
141
+ # Run the check
142
+ check = klass.new(
143
+ page: page,
144
+ result: result,
145
+ credentials: @credentials,
146
+ target_url: @target_url,
147
+ configuration: @configuration
148
+ )
149
+
150
+ begin
151
+ check.call
152
+
153
+ if result.step_results.any?(&:failed?)
154
+ result.fail!
155
+ save_trace(context, result)
156
+ else
157
+ result.pass!
158
+ discard_trace(context)
159
+ end
160
+ rescue => e
161
+ # Emergency screenshot
162
+ emergency_screenshot(page, klass)
163
+ result.fail!(error: e)
164
+ save_trace(context, result)
165
+ end
166
+ end
167
+
168
+ result
169
+ end
170
+
171
+ def run_preps(klass, page, result)
172
+ klass.preps.each do |prep_name|
173
+ step_result = StepResult.new(name: prep_name.to_s, type: :prep)
174
+ step_result.start!
175
+
176
+ begin
177
+ prep_class = Prep.resolve(prep_name)
178
+ prep = prep_class.new(credentials: @credentials, target_url: @target_url)
179
+
180
+ if prep.satisfied?(page)
181
+ step_result.pass!
182
+ else
183
+ prep.satisfy(page)
184
+ step_result.pass!
185
+ end
186
+ rescue => e
187
+ step_result.fail!(error: e)
188
+ result.add_prep_result(step_result)
189
+ return false
190
+ end
191
+
192
+ result.add_prep_result(step_result)
193
+ end
194
+
195
+ true
196
+ end
197
+
198
+ def run_parallel(classes, pool_size:)
199
+ results = []
200
+ mutex = Mutex.new
201
+ queue = Queue.new
202
+ classes.each { |klass| queue << klass }
203
+
204
+ workers = pool_size.times.map do
205
+ Thread.new do
206
+ while (klass = begin; queue.pop(true); rescue ThreadError; nil; end)
207
+ r = run_check(klass)
208
+ mutex.synchronize { results << r }
209
+ end
210
+ end
211
+ end
212
+
213
+ workers.each(&:join)
214
+ results
215
+ end
216
+
217
+ def save_trace(context, result)
218
+ check_name = result.check_name.to_s.gsub("::", "_").downcase
219
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
220
+ dir = @configuration.traces_dir
221
+ FileUtils.mkdir_p(dir)
222
+ path = File.join(dir, "#{check_name}_#{timestamp}.zip")
223
+ context.tracing.stop(path: path)
224
+ result.trace_path = path
225
+ rescue => e
226
+ Checkset.logger.warn("Failed to save trace: #{e.message}")
227
+ end
228
+
229
+ def discard_trace(context)
230
+ context.tracing.stop
231
+ rescue => e
232
+ Checkset.logger.warn("Failed to discard trace: #{e.message}")
233
+ end
234
+
235
+ def emergency_screenshot(page, klass)
236
+ check_name = (klass.name || "anonymous").gsub("::", "_").downcase
237
+ dir = File.join(@configuration.screenshots_dir, check_name)
238
+ FileUtils.mkdir_p(dir)
239
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S_%L")
240
+ page.screenshot(path: File.join(dir, "EMERGENCY_#{timestamp}.png"), fullPage: true)
241
+ rescue => e
242
+ Checkset.logger.warn("Emergency screenshot failed: #{e.message}")
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Checkset
4
+ class StepResult
5
+ attr_reader :name, :type, :status, :started_at, :finished_at,
6
+ :screenshot_path, :error, :error_backtrace
7
+
8
+ def initialize(name:, type:)
9
+ @name = name
10
+ @type = type # :verify, :step, :prep
11
+ @status = :pending
12
+ @started_at = nil
13
+ @finished_at = nil
14
+ @screenshot_path = nil
15
+ @error = nil
16
+ @error_backtrace = nil
17
+ end
18
+
19
+ def start!
20
+ @status = :running
21
+ @started_at = Time.now
22
+ end
23
+
24
+ def pass!(screenshot_path: nil)
25
+ @status = :passed
26
+ @finished_at = Time.now
27
+ @screenshot_path = screenshot_path
28
+ end
29
+
30
+ def fail!(error:, screenshot_path: nil)
31
+ @status = :failed
32
+ @finished_at = Time.now
33
+ @screenshot_path = screenshot_path
34
+ if error.is_a?(Exception)
35
+ @error = error.message
36
+ @error_backtrace = error.backtrace&.first(10)
37
+ else
38
+ @error = error.to_s
39
+ end
40
+ end
41
+
42
+ def skip!(reason: nil)
43
+ @status = :skipped
44
+ @finished_at = Time.now
45
+ @error = reason
46
+ end
47
+
48
+ def duration
49
+ return nil unless @started_at && @finished_at
50
+ @finished_at - @started_at
51
+ end
52
+
53
+ def passed?
54
+ @status == :passed
55
+ end
56
+
57
+ def failed?
58
+ @status == :failed
59
+ end
60
+
61
+ def to_h
62
+ {
63
+ name: @name,
64
+ type: @type,
65
+ status: @status,
66
+ duration: duration,
67
+ screenshot_path: @screenshot_path,
68
+ error: @error,
69
+ error_backtrace: @error_backtrace
70
+ }.compact
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Checkset
4
+ class Suite
5
+ attr_reader :name, :target_url
6
+
7
+ def initialize(name:, target_url:)
8
+ @name = name.to_s
9
+ @target_url = target_url
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Checkset
4
+ VERSION = "0.1.0"
5
+ end
data/lib/checkset.rb ADDED
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ require_relative "checkset/version"
6
+ require_relative "checkset/configuration"
7
+ require_relative "checkset/credentials"
8
+ require_relative "checkset/step_result"
9
+ require_relative "checkset/result"
10
+ require_relative "checkset/browser_manager"
11
+ require_relative "checkset/prep"
12
+ require_relative "checkset/check"
13
+ require_relative "checkset/suite"
14
+ require_relative "checkset/config_file"
15
+ require_relative "checkset/runner"
16
+ require_relative "checkset/reporter/cli"
17
+ require_relative "checkset/reporter/json"
18
+ require_relative "checkset/init"
19
+ require_relative "checkset/cli"
20
+
21
+ module Checkset
22
+ class Error < StandardError; end
23
+
24
+ class << self
25
+ def configuration
26
+ @configuration ||= Configuration.new
27
+ end
28
+
29
+ def configure
30
+ yield(configuration)
31
+ end
32
+
33
+ def logger
34
+ @logger ||= Logger.new($stdout, level: Logger::INFO)
35
+ end
36
+
37
+ def logger=(logger)
38
+ @logger = logger
39
+ end
40
+
41
+ def reset!
42
+ @configuration = Configuration.new
43
+ @logger = nil
44
+ end
45
+ end
46
+ end