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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +428 -0
- data/Rakefile +11 -0
- data/checks/example.rb +19 -0
- data/checkset.gemspec +34 -0
- data/examples/checks/basecamp/homepage.rb +24 -0
- data/examples/checks/hey/homepage.rb +24 -0
- data/examples/checks/hey/pricing.rb +24 -0
- data/examples/checks/homepage_loads.rb +23 -0
- data/examples/checks/once/homepage.rb +24 -0
- data/examples/checks/ruby/ruby_lang.rb +28 -0
- data/examples/checkset.yml +23 -0
- data/exe/checkset +5 -0
- data/lib/checkset/browser_manager.rb +83 -0
- data/lib/checkset/check.rb +129 -0
- data/lib/checkset/cli.rb +247 -0
- data/lib/checkset/config_file.rb +51 -0
- data/lib/checkset/configuration.rb +41 -0
- data/lib/checkset/credentials.rb +34 -0
- data/lib/checkset/init.rb +70 -0
- data/lib/checkset/prep.rb +38 -0
- data/lib/checkset/reporter/cli.rb +118 -0
- data/lib/checkset/reporter/json.rb +41 -0
- data/lib/checkset/result.rb +86 -0
- data/lib/checkset/runner.rb +245 -0
- data/lib/checkset/step_result.rb +73 -0
- data/lib/checkset/suite.rb +12 -0
- data/lib/checkset/version.rb +5 -0
- data/lib/checkset.rb +46 -0
- metadata +89 -0
|
@@ -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,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
|
data/lib/checkset/cli.rb
ADDED
|
@@ -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
|