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,23 @@
1
+ # checkset.yml — Maps suite names to target URLs
2
+ #
3
+ # Checks are auto-discovered by folder:
4
+ # examples/checks/hey/*.rb → belongs to "hey" suite
5
+ # examples/checks/basecamp/*.rb → belongs to "basecamp" suite
6
+ # examples/checks/*.rb → top-level, runs in EVERY suite
7
+ #
8
+ # Usage:
9
+ # bundle exec checkset --config examples/checkset.yml # runs all suites
10
+ # bundle exec checkset --config examples/checkset.yml --suite hey # runs just "hey"
11
+ # bundle exec checkset --target https://foo.com # ignores yml, original behavior
12
+
13
+ checks_dir: examples/checks
14
+
15
+ suites:
16
+ hey:
17
+ target: https://hey.com
18
+ basecamp:
19
+ target: https://basecamp.com
20
+ once:
21
+ target: https://once.com
22
+ ruby:
23
+ target: https://www.ruby-lang.org
data/exe/checkset ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "checkset"
5
+ Checkset::CLI.new(ARGV).run
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Checkset
4
+ class BrowserManager
5
+ def initialize(configuration: Checkset.configuration)
6
+ @configuration = configuration
7
+ @execution = nil
8
+ @playwright = nil
9
+ @browser = nil
10
+ end
11
+
12
+ def start!
13
+ require "playwright"
14
+
15
+ if @configuration.playwright_server_url
16
+ start_remote!
17
+ else
18
+ start_local!
19
+ end
20
+ end
21
+
22
+ def stop!
23
+ @browser&.close
24
+ @execution&.stop
25
+ @browser = nil
26
+ @playwright = nil
27
+ @execution = nil
28
+ end
29
+
30
+ def with_context
31
+ viewport = @configuration.viewport_size
32
+ context = @browser.new_context(
33
+ viewport: { width: viewport[:width], height: viewport[:height] }
34
+ )
35
+ context.set_default_timeout(@configuration.default_timeout)
36
+ context.tracing.start(screenshots: true, snapshots: true)
37
+
38
+ page = context.new_page
39
+ yield page, context
40
+ ensure
41
+ context&.close
42
+ end
43
+
44
+ private
45
+
46
+ def start_local!
47
+ cli_path = @configuration.playwright_cli_path || find_playwright_cli
48
+ launch_options = { headless: @configuration.headless }
49
+ launch_options[:slowMo] = @configuration.slow_mo if @configuration.slow_mo
50
+
51
+ @execution = Playwright.create(playwright_cli_executable_path: cli_path)
52
+ @playwright = @execution.playwright
53
+ @browser = @playwright.send(@configuration.browser_type).launch(**launch_options)
54
+ end
55
+
56
+ def start_remote!
57
+ @playwright = Playwright.connect_to_playwright_server(@configuration.playwright_server_url)
58
+ launch_options = { headless: @configuration.headless }
59
+ launch_options[:slowMo] = @configuration.slow_mo if @configuration.slow_mo
60
+
61
+ @browser = @playwright.send(@configuration.browser_type).launch(**launch_options)
62
+ end
63
+
64
+ def find_playwright_cli
65
+ # Check common locations
66
+ candidates = [
67
+ File.join(Dir.pwd, "node_modules", ".bin", "playwright"),
68
+ `which playwright 2>/dev/null`.strip
69
+ ]
70
+
71
+ candidates.each do |path|
72
+ return path if !path.empty? && File.exist?(path)
73
+ end
74
+
75
+ raise <<~MSG
76
+ Could not find Playwright CLI. Either:
77
+ 1. Install it: npm install playwright
78
+ 2. Set PLAYWRIGHT_CLI_PATH environment variable
79
+ 3. Configure: Checkset.configure { |c| c.playwright_cli_path = "/path/to/playwright" }
80
+ MSG
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "uri"
5
+
6
+ module Checkset
7
+ class Check
8
+ attr_reader :page, :result, :credentials, :target_url
9
+
10
+ # --- Class-level DSL ---
11
+
12
+ def self.prep(*names)
13
+ @preps ||= []
14
+ @preps.concat(names)
15
+ end
16
+
17
+ def self.preps
18
+ @preps || []
19
+ end
20
+
21
+ def self.description(text = nil)
22
+ if text
23
+ @description = text
24
+ else
25
+ @description
26
+ end
27
+ end
28
+
29
+ def self.tags(*tag_list)
30
+ if tag_list.any?
31
+ @tags = tag_list
32
+ else
33
+ @tags || []
34
+ end
35
+ end
36
+
37
+ # --- Instance ---
38
+
39
+ def initialize(page:, result:, credentials:, target_url:, configuration: Checkset.configuration)
40
+ @page = page
41
+ @result = result
42
+ @credentials = credentials
43
+ @target_url = target_url
44
+ @configuration = configuration
45
+ @step_counter = 0
46
+ end
47
+
48
+ # Override in subclass
49
+ def call
50
+ raise NotImplementedError, "#{self.class}#call must be implemented"
51
+ end
52
+
53
+ private
54
+
55
+ def verify(name, &block)
56
+ step_result = StepResult.new(name: name, type: :verify)
57
+ step_result.start!
58
+
59
+ begin
60
+ # Screenshot before assertion (captures current state)
61
+ screenshot_path = take_screenshot(name, "verify")
62
+
63
+ passed = block.call
64
+ if passed
65
+ step_result.pass!(screenshot_path: screenshot_path)
66
+ else
67
+ # Take full-page failure screenshot
68
+ fail_screenshot = take_screenshot(name, "FAIL_verify", full_page: true)
69
+ step_result.fail!(error: "Verification '#{name}' returned falsy", screenshot_path: fail_screenshot)
70
+ end
71
+ rescue => e
72
+ fail_screenshot = take_screenshot(name, "FAIL_verify", full_page: true)
73
+ step_result.fail!(error: e, screenshot_path: fail_screenshot)
74
+ end
75
+
76
+ @result.add_step_result(step_result)
77
+ step_result
78
+ # verify does NOT re-raise — continues collecting failures
79
+ end
80
+
81
+ def step(name, &block)
82
+ step_result = StepResult.new(name: name, type: :step)
83
+ step_result.start!
84
+
85
+ begin
86
+ block.call
87
+
88
+ # Screenshot after action (captures result)
89
+ screenshot_path = take_screenshot(name, "step")
90
+ step_result.pass!(screenshot_path: screenshot_path)
91
+ rescue => e
92
+ fail_screenshot = take_screenshot(name, "FAIL_step", full_page: true)
93
+ step_result.fail!(error: e, screenshot_path: fail_screenshot)
94
+ @result.add_step_result(step_result)
95
+ raise # step re-raises to halt execution
96
+ end
97
+
98
+ @result.add_step_result(step_result)
99
+ step_result
100
+ end
101
+
102
+ def visit(path)
103
+ url = URI.join(@target_url, path).to_s
104
+ @page.goto(url)
105
+ end
106
+
107
+ def take_screenshot(name, type, full_page: false)
108
+ @step_counter += 1
109
+ check_name = sanitize_name(self.class.name || "anonymous")
110
+ step_name = sanitize_name(name)
111
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S_%L")
112
+ filename = "#{@step_counter}_#{type}_#{step_name}_#{timestamp}.png"
113
+
114
+ dir = File.join(@configuration.screenshots_dir, check_name)
115
+ FileUtils.mkdir_p(dir)
116
+ path = File.join(dir, filename)
117
+
118
+ @page.screenshot(path: path, fullPage: full_page)
119
+ path
120
+ rescue => e
121
+ Checkset.logger.warn("Screenshot failed: #{e.message}")
122
+ nil
123
+ end
124
+
125
+ def sanitize_name(name)
126
+ name.to_s.gsub("::", "_").gsub(/[^a-zA-Z0-9_-]/, "_").downcase
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "fileutils"
5
+
6
+ module Checkset
7
+ class CLI
8
+ def initialize(argv)
9
+ @argv = argv
10
+ @init = @argv.first == "init"
11
+ @filter = nil
12
+ @suite_name = nil
13
+ @config_path = "checkset.yml"
14
+ @domain = nil
15
+ @tag = nil
16
+ @clean = false
17
+ parse_options! unless @init
18
+ end
19
+
20
+ def run
21
+ if @init
22
+ Init.run
23
+ return
24
+ end
25
+
26
+ if @clean
27
+ clean!
28
+ return
29
+ end
30
+
31
+ config = Checkset.configuration
32
+
33
+ if config.target_url
34
+ run_single(config)
35
+ elsif (config_file = ConfigFile.load(@config_path, domain: @domain))
36
+ run_suites(config, config_file)
37
+ else
38
+ $stderr.puts "Error: --target URL is required (or provide a checkset.yml)"
39
+ $stderr.puts "Usage: bundle exec checkset --target https://staging.myapp.com"
40
+ exit 1
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def clean!
47
+ config = Checkset.configuration
48
+ dirs = [config.screenshots_dir, config.traces_dir, config.results_dir]
49
+
50
+ dirs.each do |dir|
51
+ if File.directory?(dir)
52
+ FileUtils.rm_rf(dir)
53
+ puts "Removed #{dir}"
54
+ end
55
+ end
56
+
57
+ puts "Clean!"
58
+ end
59
+
60
+ def run_single(config)
61
+ cli_reporter = Reporter::CLI.new(target_url: config.target_url)
62
+ json_reporter = Reporter::JSON.new(configuration: config)
63
+
64
+ cli_reporter.print_header
65
+
66
+ runner = Runner.new(configuration: config, filter: @filter, tag: @tag)
67
+ results = runner.run
68
+
69
+ results.each { |r| cli_reporter.report_result(r) }
70
+ cli_reporter.print_summary(results)
71
+
72
+ json_path = json_reporter.write(results)
73
+ cli_reporter.print_results_path(json_path)
74
+
75
+ exit 1 if results.any?(&:failed?)
76
+ end
77
+
78
+ def run_suites(config, config_file)
79
+ config.checks_paths = [config_file.checks_dir] if config_file.checks_dir
80
+
81
+ suites = if @suite_name
82
+ suite = config_file.find_suite(@suite_name)
83
+ unless suite
84
+ $stderr.puts "Error: Suite '#{@suite_name}' not found in #{@config_path}"
85
+ $stderr.puts "Available suites: #{config_file.suites.map(&:name).join(', ')}"
86
+ exit 1
87
+ end
88
+ [suite]
89
+ else
90
+ config_file.suites
91
+ end
92
+
93
+ json_reporter = Reporter::JSON.new(configuration: config)
94
+ browser_manager = BrowserManager.new(configuration: config)
95
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
96
+
97
+ # Preload all check/prep files before spawning threads
98
+ Runner.preload(configuration: config)
99
+
100
+ begin
101
+ browser_manager.start!
102
+
103
+ mutex = Mutex.new
104
+ suite_outputs = {}
105
+
106
+ threads = suites.map do |suite|
107
+ Thread.new do
108
+ runner = Runner.new(
109
+ configuration: config,
110
+ filter: @filter,
111
+ tag: @tag,
112
+ target_url: suite.target_url,
113
+ suite_name: suite.name,
114
+ browser_manager: browser_manager,
115
+ preloaded: true
116
+ )
117
+ results = runner.run
118
+ json_path = mutex.synchronize { json_reporter.write(results, target_url: suite.target_url, suite_name: suite.name) }
119
+
120
+ suite_outputs[suite.name] = { suite: suite, results: results, json_path: json_path }
121
+ end
122
+ end
123
+
124
+ threads.each(&:join)
125
+ ensure
126
+ browser_manager.stop!
127
+ end
128
+
129
+ # Print results in suite order
130
+ any_failed = false
131
+ suites.each do |suite|
132
+ output = suite_outputs[suite.name]
133
+ next unless output
134
+
135
+ cli_reporter = Reporter::CLI.new(target_url: suite.target_url, suite_name: suite.name)
136
+ cli_reporter.print_header
137
+ output[:results].each { |r| cli_reporter.report_result(r) }
138
+ cli_reporter.print_summary(output[:results])
139
+ cli_reporter.print_results_path(output[:json_path])
140
+
141
+ any_failed = true if output[:results].any?(&:failed?)
142
+ end
143
+
144
+ total_duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
145
+ all_results = suites.flat_map { |s| suite_outputs.dig(s.name, :results) || [] }
146
+ passed = all_results.count(&:passed?)
147
+ failed = all_results.count(&:failed?)
148
+ skipped = all_results.count { |r| r.status == :skipped }
149
+
150
+ puts
151
+ puts "━" * 50
152
+ puts "All suites: #{passed + failed + skipped} checks across #{suites.size} suites — #{passed} passed, #{failed} failed, #{skipped} skipped (#{format("%.1fs", total_duration)})"
153
+ puts
154
+
155
+ exit 1 if any_failed
156
+ end
157
+
158
+ def parse_options!
159
+ parser = OptionParser.new do |opts|
160
+ opts.banner = "Usage: checkset [options] [CheckName]\n checkset init"
161
+ opts.separator ""
162
+ opts.separator "Options:"
163
+
164
+ opts.on("--target URL", "Target URL to verify against") do |url|
165
+ Checkset.configuration.target_url = url
166
+ end
167
+
168
+ opts.on("--suite NAME", "Run only the named suite from checkset.yml") do |name|
169
+ @suite_name = name
170
+ end
171
+
172
+ opts.on("--config FILE", "Path to checkset.yml (default: checkset.yml)") do |path|
173
+ @config_path = path
174
+ end
175
+
176
+ opts.on("--domain DOMAIN", "Base domain for suite targets (overrides base_domain in yml)") do |domain|
177
+ @domain = domain
178
+ end
179
+
180
+ opts.on("--headed", "Run browser in headed mode") do
181
+ Checkset.configuration.headless = false
182
+ end
183
+
184
+ opts.on("--headless", "Run browser in headless mode (default)") do
185
+ Checkset.configuration.headless = true
186
+ end
187
+
188
+ opts.on("--browser TYPE", "Browser type: chromium, firefox, webkit (default: chromium)") do |type|
189
+ Checkset.configuration.browser_type = type.to_sym
190
+ end
191
+
192
+ opts.on("--screenshots-dir DIR", "Directory for screenshots") do |dir|
193
+ Checkset.configuration.screenshots_dir = dir
194
+ end
195
+
196
+ opts.on("--timeout MS", Integer, "Default timeout in milliseconds") do |ms|
197
+ Checkset.configuration.default_timeout = ms
198
+ end
199
+
200
+ opts.on("--playwright-server URL", "WebSocket URL for remote Playwright server") do |url|
201
+ Checkset.configuration.playwright_server_url = url
202
+ end
203
+
204
+ opts.on("--slow-mo MS", Integer, "Slow down actions by N milliseconds") do |ms|
205
+ Checkset.configuration.slow_mo = ms
206
+ end
207
+
208
+ opts.on("--parallel N", Integer, "Number of concurrent checks (default: 1)") do |n|
209
+ Checkset.configuration.parallel = n
210
+ end
211
+
212
+ opts.on("--checks-dir DIR", "Directory containing check files (default: checks)") do |dir|
213
+ Checkset.configuration.checks_paths = [dir]
214
+ end
215
+
216
+ opts.on("--preps-dir DIR", "Directory containing prep files (default: preps)") do |dir|
217
+ Checkset.configuration.preps_paths = [dir]
218
+ end
219
+
220
+ opts.on("--tag TAG", "Only run checks tagged with TAG") do |tag|
221
+ @tag = tag.to_sym
222
+ end
223
+
224
+ opts.on("--retries N", Integer, "Retry failed checks N times (default: 0)") do |n|
225
+ Checkset.configuration.retries = n
226
+ end
227
+
228
+ opts.on("--clean", "Remove all screenshots, traces, and results") do
229
+ @clean = true
230
+ end
231
+
232
+ opts.on("-v", "--version", "Print version") do
233
+ puts "checkset #{Checkset::VERSION}"
234
+ exit
235
+ end
236
+
237
+ opts.on("-h", "--help", "Print help") do
238
+ puts opts
239
+ exit
240
+ end
241
+ end
242
+
243
+ remaining = parser.parse(@argv)
244
+ @filter = remaining.first if remaining.any?
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Checkset
6
+ class ConfigFile
7
+ attr_reader :suites, :checks_dir, :base_domain
8
+
9
+ def self.load(path = "checkset.yml", domain: nil)
10
+ return nil unless File.exist?(path)
11
+
12
+ new(path, domain: domain)
13
+ end
14
+
15
+ def initialize(path, domain: nil)
16
+ data = YAML.safe_load_file(path, permitted_classes: [Symbol])
17
+ @checks_dir = data["checks_dir"]
18
+ @base_domain = domain || data["base_domain"]
19
+ @suites = parse_suites(data)
20
+ end
21
+
22
+ def find_suite(name)
23
+ @suites.find { |s| s.name == name.to_s }
24
+ end
25
+
26
+ private
27
+
28
+ def parse_suites(data)
29
+ suites_data = data.fetch("suites", {})
30
+
31
+ suites_data.map do |name, config|
32
+ Suite.new(
33
+ name: name,
34
+ target_url: interpolate(config.fetch("target"))
35
+ )
36
+ end
37
+ end
38
+
39
+ def interpolate(value)
40
+ # Replace %{domain} with base_domain
41
+ result = value.gsub("%{domain}") do
42
+ @base_domain || raise("No domain set — use --domain or set base_domain in checkset.yml")
43
+ end
44
+
45
+ # Replace ${ENV_VAR} with environment variables
46
+ result.gsub(/\$\{([^}]+)\}/) do
47
+ ENV.fetch($1) { raise "Environment variable #{$1} is not set" }
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Checkset
4
+ class Configuration
5
+ attr_accessor :target_url,
6
+ :playwright_cli_path,
7
+ :playwright_server_url,
8
+ :headless,
9
+ :screenshots_dir,
10
+ :traces_dir,
11
+ :results_dir,
12
+ :default_timeout,
13
+ :credentials_provider,
14
+ :checks_paths,
15
+ :preps_paths,
16
+ :browser_type,
17
+ :viewport_size,
18
+ :slow_mo,
19
+ :parallel,
20
+ :retries
21
+
22
+ def initialize
23
+ @target_url = ENV["CHECKSET_TARGET_URL"]
24
+ @playwright_cli_path = ENV["PLAYWRIGHT_CLI_PATH"]
25
+ @playwright_server_url = ENV["PLAYWRIGHT_SERVER_URL"]
26
+ @headless = true
27
+ @screenshots_dir = "tmp/checkset/screenshots"
28
+ @traces_dir = "tmp/checkset/traces"
29
+ @results_dir = "tmp/checkset/results"
30
+ @default_timeout = 10_000
31
+ @credentials_provider = :env
32
+ @checks_paths = ["checks"]
33
+ @preps_paths = ["preps"]
34
+ @browser_type = :chromium
35
+ @viewport_size = { width: 1280, height: 720 }
36
+ @slow_mo = nil
37
+ @parallel = 1
38
+ @retries = 0
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Checkset
4
+ class Credentials
5
+ def initialize(provider: Checkset.configuration.credentials_provider)
6
+ @provider = provider
7
+ end
8
+
9
+ def fetch(key)
10
+ case @provider
11
+ when :env
12
+ ENV.fetch(key.to_s.upcase)
13
+ when :rails_credentials
14
+ fetch_rails_credential(key)
15
+ else
16
+ raise ArgumentError, "Unknown credentials provider: #{@provider}"
17
+ end
18
+ end
19
+
20
+ alias_method :[], :fetch
21
+
22
+ private
23
+
24
+ def fetch_rails_credential(key)
25
+ if defined?(Rails) && Rails.application.respond_to?(:credentials)
26
+ value = Rails.application.credentials.dig(:checkset, key)
27
+ return value if value
28
+ end
29
+
30
+ # Fall back to ENV
31
+ ENV.fetch(key.to_s.upcase)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Checkset
6
+ class Init
7
+ CHECKSET_YML = <<~YAML
8
+ # Base domain for %{domain} interpolation in suite targets
9
+ # base_domain: example.com
10
+
11
+ # Directory containing check files (default: checks)
12
+ # checks_dir: checks
13
+
14
+ # Define suites to run checks against multiple targets:
15
+ # suites:
16
+ # app:
17
+ # target: https://app.%{domain}
18
+ # admin:
19
+ # target: https://admin.%{domain}
20
+ YAML
21
+
22
+ EXAMPLE_CHECK = <<~RUBY
23
+ # frozen_string_literal: true
24
+
25
+ class Checks::HomepageLoads < Checkset::Check
26
+ description "Verifies the homepage loads correctly"
27
+ tags :homepage
28
+
29
+ def call
30
+ visit "/"
31
+
32
+ verify "page has a title" do
33
+ page.title.is_a?(String) && !page.title.empty?
34
+ end
35
+
36
+ verify "page has visible content" do
37
+ page.text_content("body").strip.length > 0
38
+ end
39
+ end
40
+ end
41
+ RUBY
42
+
43
+ def self.run(base_dir: ".")
44
+ new(base_dir: base_dir).run
45
+ end
46
+
47
+ def initialize(base_dir: ".")
48
+ @base_dir = base_dir
49
+ end
50
+
51
+ def run
52
+ create_file("checkset.yml", CHECKSET_YML)
53
+ create_file(File.join("checks", "homepage_loads.rb"), EXAMPLE_CHECK)
54
+ end
55
+
56
+ private
57
+
58
+ def create_file(relative_path, content)
59
+ path = File.join(@base_dir, relative_path)
60
+
61
+ if File.exist?(path)
62
+ puts " exists #{relative_path}"
63
+ else
64
+ FileUtils.mkdir_p(File.dirname(path))
65
+ File.write(path, content)
66
+ puts " create #{relative_path}"
67
+ end
68
+ end
69
+ end
70
+ end