ruby_raider 4.1.0 → 4.2.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 +4 -4
- data/.github/workflows/e2e_tests.yml +3 -0
- data/README.md +52 -53
- data/RELEASE.md +14 -1
- data/lib/commands/schedule_commands.rb +84 -0
- data/lib/generators/templates/common/git_ignore.tt +1 -0
- data/lib/ruby_raider/runtime/retry_reset.rb +32 -0
- data/lib/ruby_raider/runtime/rspec.rb +1 -0
- data/lib/ruby_raider/runtime/show_browser.rb +53 -0
- data/lib/ruby_raider/runtime.rb +2 -0
- data/lib/ruby_raider.rb +22 -7
- data/lib/utilities/schedules/adapter.rb +22 -0
- data/lib/utilities/schedules/base.rb +27 -0
- data/lib/utilities/schedules/kind.rb +74 -0
- data/lib/utilities/schedules/legacy.rb +196 -0
- data/lib/utilities/schedules/legacy_stub.rb +62 -0
- data/lib/utilities/{flows → schedules}/next_run.rb +2 -2
- data/lib/utilities/{flows → schedules}/notifier.rb +6 -6
- data/lib/utilities/{flows → schedules}/os/command_runner.rb +1 -1
- data/lib/utilities/{flows → schedules}/os/cron.rb +27 -17
- data/lib/utilities/{flows → schedules}/os/launchd.rb +32 -14
- data/lib/utilities/{flows → schedules}/os/linux.rb +23 -5
- data/lib/utilities/{flows → schedules}/os/systemd.rb +33 -17
- data/lib/utilities/{flows → schedules}/os/task_scheduler.rb +36 -20
- data/lib/utilities/schedules/os_enumerator.rb +30 -0
- data/lib/utilities/schedules/project.rb +70 -0
- data/lib/utilities/schedules/rule.rb +162 -0
- data/lib/utilities/schedules/run_lock.rb +160 -0
- data/lib/utilities/schedules/run_log.rb +122 -0
- data/lib/utilities/schedules/run_report.rb +139 -0
- data/lib/utilities/schedules/run_wrapper.rb +216 -0
- data/lib/utilities/schedules/schedule.rb +89 -0
- data/lib/utilities/schedules/schedule_store.rb +53 -0
- data/lib/utilities/schedules/scheduler.rb +178 -0
- data/lib/utilities/schedules/scheduler_steps.rb +69 -0
- data/lib/utilities/schedules/selection.rb +133 -0
- data/lib/utilities/schedules/spawn.rb +68 -0
- data/lib/utilities/{flows/schedule_store.rb → schedules/store.rb} +17 -13
- data/lib/version +1 -1
- data/ruby_raider.gemspec +1 -0
- data/sig/commands/schedule_commands.rbs +12 -0
- data/sig/ruby_raider/runtime/retry_reset.rbs +7 -0
- data/sig/ruby_raider/runtime/show_browser.rbs +27 -0
- data/sig/ruby_raider.rbs +4 -0
- data/sig/utilities/schedules/adapter.rbs +5 -0
- data/sig/utilities/schedules/base.rbs +15 -0
- data/sig/utilities/schedules/kind.rbs +32 -0
- data/sig/utilities/schedules/legacy.rbs +45 -0
- data/sig/utilities/schedules/legacy_stub.rbs +15 -0
- data/sig/utilities/{flows → schedules}/next_run.rbs +3 -3
- data/sig/utilities/{flows → schedules}/notifier.rbs +1 -1
- data/sig/utilities/{flows → schedules}/os/command_runner.rbs +1 -1
- data/sig/utilities/{flows → schedules}/os/cron.rbs +7 -7
- data/sig/utilities/{flows → schedules}/os/launchd.rbs +7 -6
- data/sig/utilities/{flows → schedules}/os/linux.rbs +10 -4
- data/sig/utilities/{flows → schedules}/os/systemd.rbs +8 -7
- data/sig/utilities/{flows → schedules}/os/task_scheduler.rbs +13 -10
- data/sig/utilities/schedules/os_enumerator.rbs +12 -0
- data/sig/utilities/schedules/project.rbs +22 -0
- data/sig/utilities/{flows/schedule.rbs → schedules/rule.rbs} +23 -12
- data/sig/utilities/schedules/run_lock.rbs +29 -0
- data/sig/utilities/schedules/run_log.rbs +33 -0
- data/sig/utilities/schedules/run_report.rbs +34 -0
- data/sig/utilities/schedules/run_wrapper.rbs +45 -0
- data/sig/utilities/schedules/schedule.rbs +27 -0
- data/sig/utilities/schedules/schedule_store.rbs +21 -0
- data/sig/utilities/{flows → schedules}/scheduler.rbs +22 -15
- data/sig/utilities/schedules/scheduler_steps.rbs +22 -0
- data/sig/utilities/schedules/selection.rbs +36 -0
- data/sig/utilities/schedules/spawn.rbs +10 -0
- data/sig/utilities/{flows/schedule_store.rbs → schedules/store.rbs} +14 -9
- data/spec/commands/schedule_commands_spec.rb +284 -0
- data/spec/integration/content/common_content_spec.rb +8 -2
- data/spec/integration/content/gemfile_content_spec.rb +2 -2
- data/spec/integration/end_to_end_spec.rb +0 -63
- data/spec/runtime/retry_reset_spec.rb +112 -0
- data/spec/runtime/show_browser_project_spec.rb +70 -0
- data/spec/runtime/show_browser_spec.rb +101 -0
- data/spec/utilities/schedules/kind_spec.rb +81 -0
- data/spec/utilities/schedules/legacy_spec.rb +433 -0
- data/spec/utilities/schedules/legacy_stub_spec.rb +144 -0
- data/spec/utilities/{flows → schedules}/next_run_spec.rb +5 -5
- data/spec/utilities/{flows → schedules}/notifier_spec.rb +3 -4
- data/spec/utilities/{flows → schedules}/os/command_runner_spec.rb +2 -3
- data/spec/utilities/{flows → schedules}/os/cron_spec.rb +34 -29
- data/spec/utilities/{flows → schedules}/os/launchd_spec.rb +42 -29
- data/spec/utilities/{flows → schedules}/os/linux_spec.rb +27 -13
- data/spec/utilities/schedules/os/list_ids_spec.rb +202 -0
- data/spec/utilities/{flows → schedules}/os/systemd_spec.rb +35 -31
- data/spec/utilities/{flows → schedules}/os/task_scheduler_spec.rb +40 -34
- data/spec/utilities/schedules/os_entry_spec.rb +54 -0
- data/spec/utilities/schedules/os_enumerator_spec.rb +57 -0
- data/spec/utilities/schedules/project_spec.rb +125 -0
- data/spec/utilities/schedules/run_lock_spec.rb +143 -0
- data/spec/utilities/schedules/run_log_spec.rb +83 -0
- data/spec/utilities/schedules/run_report_spec.rb +150 -0
- data/spec/utilities/schedules/run_wrapper_integration_spec.rb +166 -0
- data/spec/utilities/schedules/run_wrapper_spec.rb +430 -0
- data/spec/utilities/schedules/schedule_spec.rb +120 -0
- data/spec/utilities/schedules/schedule_store_spec.rb +151 -0
- data/spec/utilities/schedules/scheduler_launchd_spec.rb +77 -0
- data/spec/utilities/schedules/scheduler_spec.rb +445 -0
- data/spec/utilities/schedules/selection_spec.rb +176 -0
- data/spec/utilities/schedules/spawn_spec.rb +104 -0
- metadata +106 -53
- data/lib/commands/flow_commands.rb +0 -104
- data/lib/generators/templates/flows/flow_helper.tt +0 -42
- data/lib/generators/templates/flows/run.tt +0 -332
- data/lib/utilities/flows/project.rb +0 -170
- data/lib/utilities/flows/run_log.rb +0 -61
- data/lib/utilities/flows/runner.rb +0 -206
- data/lib/utilities/flows/schedule.rb +0 -221
- data/lib/utilities/flows/scheduler.rb +0 -187
- data/sig/commands/flow_commands.rbs +0 -13
- data/sig/utilities/flows/project.rbs +0 -46
- data/sig/utilities/flows/run_log.rbs +0 -22
- data/sig/utilities/flows/runner.rbs +0 -35
- data/spec/commands/flow_commands_spec.rb +0 -128
- data/spec/generators/flow_helper_spec.rb +0 -40
- data/spec/integration/flows/flow_project.rb +0 -62
- data/spec/integration/flows/run_command_spec.rb +0 -91
- data/spec/integration/flows/run_lifecycle_spec.rb +0 -383
- data/spec/utilities/flows/project_spec.rb +0 -178
- data/spec/utilities/flows/run_log_spec.rb +0 -47
- data/spec/utilities/flows/runner_spec.rb +0 -313
- data/spec/utilities/flows/schedule_spec.rb +0 -233
- data/spec/utilities/flows/schedule_store_spec.rb +0 -137
- data/spec/utilities/flows/scheduler_spec.rb +0 -329
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'stringio'
|
|
6
|
+
require 'tmpdir'
|
|
7
|
+
require_relative '../../../lib/utilities/schedules/run_wrapper'
|
|
8
|
+
|
|
9
|
+
# `raider schedule run` for real: a project with its own Gemfile (rspec, rspec-retry, raider_reporter 1.x)
|
|
10
|
+
# and a spec_helper wired like the generated one (retry count from RETRY_COUNT, raider_reporter formatter),
|
|
11
|
+
# run through the shipped spawner. No browser: the selections, retries, lock and counts are what is tested.
|
|
12
|
+
RSpec.describe 'raider schedule run against a real project', :slow do
|
|
13
|
+
files = {
|
|
14
|
+
'Gemfile' => <<~RUBY,
|
|
15
|
+
source 'https://rubygems.org'
|
|
16
|
+
|
|
17
|
+
gem 'raider_reporter', '~> 1.0'
|
|
18
|
+
gem 'rspec'
|
|
19
|
+
gem 'rspec-retry'
|
|
20
|
+
RUBY
|
|
21
|
+
'.rspec' => "--require spec_helper\n",
|
|
22
|
+
'spec/spec_helper.rb' => <<~RUBY,
|
|
23
|
+
require 'rspec'
|
|
24
|
+
require 'rspec/retry'
|
|
25
|
+
require 'raider_reporter/rspec'
|
|
26
|
+
|
|
27
|
+
RSpec.configure do |config|
|
|
28
|
+
config.verbose_retry = true
|
|
29
|
+
config.display_try_failure_messages = true
|
|
30
|
+
config.default_retry_count = ENV.fetch('RETRY_COUNT', 0).to_i
|
|
31
|
+
config.add_formatter(RaiderReporter::RSpec::Formatter)
|
|
32
|
+
end
|
|
33
|
+
RUBY
|
|
34
|
+
'spec/smoke_spec.rb' => <<~RUBY,
|
|
35
|
+
RSpec.describe 'smoke', :smoke do
|
|
36
|
+
it('opens') { expect(1).to eq(1) }
|
|
37
|
+
it('closes') { expect(ENV['HEADLESS']).to eq('true') }
|
|
38
|
+
end
|
|
39
|
+
RUBY
|
|
40
|
+
'spec/cart_spec.rb' => <<~RUBY,
|
|
41
|
+
RSpec.describe 'cart' do
|
|
42
|
+
it('adds') { expect(1).to eq(1) }
|
|
43
|
+
it('pays') { expect(1).to eq(2) }
|
|
44
|
+
end
|
|
45
|
+
RUBY
|
|
46
|
+
# Fails on its first try, passes on the next: only a retry makes it pass
|
|
47
|
+
'spec/flaky_spec.rb' => <<~RUBY
|
|
48
|
+
RSpec.describe 'flaky', :flaky do
|
|
49
|
+
it 'passes on the second try' do
|
|
50
|
+
marker = File.expand_path('../flaky.tried', __dir__)
|
|
51
|
+
next if File.exist?(marker)
|
|
52
|
+
|
|
53
|
+
File.write(marker, '')
|
|
54
|
+
raise 'first try'
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
RUBY
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# rubocop:disable RSpec/InstanceVariable
|
|
61
|
+
before(:all) do # rubocop:disable RSpec/BeforeAfterAll
|
|
62
|
+
@dir = File.realpath(Dir.mktmpdir('schedules-run-real'))
|
|
63
|
+
files.each do |path, content|
|
|
64
|
+
FileUtils.mkdir_p(File.dirname(File.join(@dir, path)))
|
|
65
|
+
File.write(File.join(@dir, path), content)
|
|
66
|
+
end
|
|
67
|
+
installed = Bundler.with_unbundled_env do
|
|
68
|
+
Open3.capture2e('bundle', 'install', '--local', '--quiet', chdir: @dir).last.success? ||
|
|
69
|
+
Open3.capture2e('bundle', 'install', '--quiet', chdir: @dir).last.success?
|
|
70
|
+
end
|
|
71
|
+
raise 'bundle install of the fixture project failed' unless installed
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
after(:all) { FileUtils.rm_rf(@dir) } # rubocop:disable RSpec/BeforeAfterAll
|
|
75
|
+
|
|
76
|
+
let(:dir) { @dir }
|
|
77
|
+
# rubocop:enable RSpec/InstanceVariable
|
|
78
|
+
let(:project) { RubyRaider::Schedules::Project.new(dir) }
|
|
79
|
+
let(:out) { StringIO.new }
|
|
80
|
+
let(:notifier) { Class.new { def notify(*) = false }.new }
|
|
81
|
+
let(:wrapper) { RubyRaider::Schedules::RunWrapper.new(project, out: out, err: StringIO.new, notifier: notifier) }
|
|
82
|
+
|
|
83
|
+
before { FileUtils.rm_f(File.join(dir, 'flaky.tried')) }
|
|
84
|
+
|
|
85
|
+
def schedule(name, selection: RubyRaider::Schedules::Selection.all, retries: 0)
|
|
86
|
+
RubyRaider::Schedules::ScheduleStore.new(project).save(
|
|
87
|
+
RubyRaider::Schedules::Schedule.build(project: project, name: name, every: 'day', at: '08:00', retries: retries,
|
|
88
|
+
selection: selection, host_os: 'darwin')
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# rspec's own output goes to a file, not the spec run's terminal
|
|
93
|
+
def run(name)
|
|
94
|
+
saved = [$stdout.dup, $stderr.dup]
|
|
95
|
+
File.open(File.join(dir, 'rspec.out'), 'w') do |file|
|
|
96
|
+
$stdout.reopen(file)
|
|
97
|
+
$stderr.reopen(file)
|
|
98
|
+
wrapper.run(name)
|
|
99
|
+
end
|
|
100
|
+
ensure
|
|
101
|
+
$stdout.reopen(saved[0])
|
|
102
|
+
$stderr.reopen(saved[1])
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def last_line(name) = JSON.parse(File.readlines(File.join(dir, ".raider/schedules/#{name}/runs.jsonl")).last)
|
|
106
|
+
|
|
107
|
+
def tag(name) = RubyRaider::Schedules::Selection.new(kind: 'tag', tag: name)
|
|
108
|
+
|
|
109
|
+
it 'runs all tests: failed, counted from raider-results/run.json, report copied' do
|
|
110
|
+
schedule('all_tests')
|
|
111
|
+
|
|
112
|
+
expect(run('all_tests')).to eq(1)
|
|
113
|
+
expect(last_line('all_tests')).to include('status' => 'failed', 'counts' => { 'total' => 5, 'passed' => 3, 'failed' => 2, 'skipped' => 0 })
|
|
114
|
+
expect(out.string).to eq("Schedule all_tests failed: 2 of 5 tests failed\n")
|
|
115
|
+
expect(File.read(File.join(dir, '.raider/schedules/all_tests/last/run.json'))).to eq(File.read(File.join(dir, 'raider-results/run.json')))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it 'runs one tag, hidden: passed' do
|
|
119
|
+
schedule('tag_smoke', selection: tag('smoke'))
|
|
120
|
+
|
|
121
|
+
expect(run('tag_smoke')).to eq(0)
|
|
122
|
+
expect(out.string).to eq("Schedule tag_smoke passed: 2 tests\n")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'runs the listed files' do
|
|
126
|
+
schedule('selected_tests', selection: RubyRaider::Schedules::Selection.new(kind: 'files', files: %w[spec/cart_spec.rb]))
|
|
127
|
+
|
|
128
|
+
expect(run('selected_tests')).to eq(1)
|
|
129
|
+
expect(last_line('selected_tests')['counts']).to eq('total' => 2, 'passed' => 1, 'failed' => 1, 'skipped' => 0)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it 'retries a failing test through RETRY_COUNT, so a test that passes on its second try passes' do
|
|
133
|
+
schedule('flaky_none', selection: tag('flaky'))
|
|
134
|
+
schedule('flaky_once', selection: tag('flaky'), retries: 1)
|
|
135
|
+
|
|
136
|
+
expect(run('flaky_none')).to eq(1)
|
|
137
|
+
FileUtils.rm_f(File.join(dir, 'flaky.tried'))
|
|
138
|
+
expect(run('flaky_once')).to eq(0)
|
|
139
|
+
expect(last_line('flaky_once')['counts']).to eq('total' => 1, 'passed' => 1, 'failed' => 0, 'skipped' => 0)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it 'is busy while another run holds the lock: exit 3, nothing run, raider-results untouched' do
|
|
143
|
+
schedule('tag_smoke', selection: tag('smoke'))
|
|
144
|
+
run('tag_smoke')
|
|
145
|
+
results = File.join(dir, 'raider-results')
|
|
146
|
+
snapshot = -> { Dir.glob('**/*', base: results).sort.map { |path| [path, File.mtime(File.join(results, path)), File.size(File.join(results, path))] } }
|
|
147
|
+
before = snapshot.call
|
|
148
|
+
|
|
149
|
+
File.open(File.join(dir, '.raider/schedules/run.lock'), File::RDWR) do |held|
|
|
150
|
+
held.flock(File::LOCK_EX)
|
|
151
|
+
|
|
152
|
+
expect(run('tag_smoke')).to eq(3)
|
|
153
|
+
end
|
|
154
|
+
expect([last_line('tag_smoke')['status'], snapshot.call]).to eq(['busy', before])
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
it 'is a setup error when a listed file is gone: exit 2, nothing run' do
|
|
158
|
+
File.write(File.join(dir, 'spec/gone_spec.rb'), "RSpec.describe('gone') { it('x') {} }\n")
|
|
159
|
+
schedule('gone', selection: RubyRaider::Schedules::Selection.new(kind: 'files', files: %w[spec/gone_spec.rb]))
|
|
160
|
+
File.delete(File.join(dir, 'spec/gone_spec.rb'))
|
|
161
|
+
|
|
162
|
+
expect(run('gone')).to eq(2)
|
|
163
|
+
expect(last_line('gone')['status']).to eq('setup_error')
|
|
164
|
+
expect(out.string).to eq("Schedule gone could not start: spec/gone_spec.rb not found: remove the schedule or add it again\n")
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
require_relative '../../../lib/utilities/schedules/run_wrapper'
|
|
7
|
+
|
|
8
|
+
# The fake spawner needs its calls, outcome and project next to the wrapper
|
|
9
|
+
# rubocop:disable RSpec/MultipleMemoizedHelpers
|
|
10
|
+
RSpec.describe RubyRaider::Schedules::RunWrapper do # rubocop:disable RSpec/FilePath
|
|
11
|
+
let(:dir) { File.realpath(Dir.mktmpdir('schedules-wrapper')) }
|
|
12
|
+
let(:project) { RubyRaider::Schedules::Project.new(dir) }
|
|
13
|
+
let(:store) { RubyRaider::Schedules::ScheduleStore.new(project) }
|
|
14
|
+
let(:out) { StringIO.new }
|
|
15
|
+
let(:err) { StringIO.new }
|
|
16
|
+
let(:now) { Time.utc(2026, 11, 11, 8, 0, 0) }
|
|
17
|
+
let(:calls) { [] }
|
|
18
|
+
let(:notices) { [] }
|
|
19
|
+
let(:notifier) do
|
|
20
|
+
sent = notices
|
|
21
|
+
Class.new { define_method(:notify) { |title, message| sent << [title, message] && false } }.new
|
|
22
|
+
end
|
|
23
|
+
# What the fake rspec run does: exit code, stopped, and the statuses of the run.json it writes (nil: none)
|
|
24
|
+
let(:outcome) { { code: 0, stopped: false, statuses: %w[passed passed] } }
|
|
25
|
+
let(:spawner) do
|
|
26
|
+
lambda do |env, argv, chdir|
|
|
27
|
+
calls << { env: env, argv: argv, chdir: chdir, running: File.exist?(File.join(dir, '.raider/schedules/running.json')) }
|
|
28
|
+
write_run(outcome[:statuses]) if outcome[:statuses]
|
|
29
|
+
[outcome[:code], outcome[:stopped]]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
let(:wrapper) { described_class.new(project, out: out, err: err, spawner: spawner, clock: -> { now }, notifier: notifier) }
|
|
33
|
+
let(:folder) { File.join(dir, '.raider/schedules/nightly') }
|
|
34
|
+
|
|
35
|
+
def write_run(statuses, started_at: '2026-11-11T08:00:01.000Z')
|
|
36
|
+
results = File.join(dir, 'raider-results')
|
|
37
|
+
FileUtils.mkdir_p(File.join(results, 'attachments'))
|
|
38
|
+
File.write(File.join(results, 'run.json'), JSON.generate('run' => { 'started_at' => started_at }, 'results' => statuses.map { |status| { 'status' => status } }))
|
|
39
|
+
File.write(File.join(results, 'index.html'), '<html>')
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def schedule(name: 'nightly', **options)
|
|
43
|
+
store.save(RubyRaider::Schedules::Schedule.build(project: project, name: name, every: 'day', at: '08:00', host_os: 'darwin', **options))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def lines = File.readlines(File.join(folder, 'runs.jsonl')).map { |line| JSON.parse(line) }
|
|
47
|
+
|
|
48
|
+
before do
|
|
49
|
+
FileUtils.mkdir_p(File.join(dir, 'spec'))
|
|
50
|
+
File.write(File.join(dir, 'spec/login_spec.rb'), '')
|
|
51
|
+
File.write(File.join(dir, 'spec/cart_spec.rb'), '')
|
|
52
|
+
schedule
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
after { FileUtils.rm_rf(dir) }
|
|
56
|
+
|
|
57
|
+
describe 'a passing run' do
|
|
58
|
+
it 'runs bundle exec rspec with the scheduled ruby and bundle, from the project, with no path for all tests' do
|
|
59
|
+
entry = store.find('nightly')
|
|
60
|
+
|
|
61
|
+
expect(wrapper.run('nightly')).to eq(0)
|
|
62
|
+
expect(calls.first[:argv]).to eq([entry.ruby, entry.bundle, 'exec', 'rspec'])
|
|
63
|
+
expect([calls.first[:chdir], calls.first[:env]['BUNDLE_GEMFILE']]).to eq([dir, File.join(dir, 'Gemfile')])
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'records the run line with counts, trigger and the report copy, and no messages' do
|
|
67
|
+
wrapper.run('nightly')
|
|
68
|
+
|
|
69
|
+
line = lines.last
|
|
70
|
+
expect(line.keys).to eq(%w[run_id started_at finished_at status counts trigger results_dir report_copy])
|
|
71
|
+
expect(line).to include('status' => 'passed', 'counts' => { 'total' => 2, 'passed' => 2, 'failed' => 0, 'skipped' => 0 },
|
|
72
|
+
'trigger' => 'manual', 'results_dir' => 'raider-results', 'report_copy' => '.raider/schedules/nightly/last',
|
|
73
|
+
'started_at' => '2026-11-11T08:00:00.000Z', 'finished_at' => '2026-11-11T08:00:00.000Z')
|
|
74
|
+
expect(line['run_id']).to match(/\A[0-9a-f]{16}\z/)
|
|
75
|
+
expect(Dir.children(File.join(folder, 'last')).sort).to eq(%w[attachments index.html run.json])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'prints one result line and does not notify' do
|
|
79
|
+
schedule(notify: true)
|
|
80
|
+
wrapper.run('nightly')
|
|
81
|
+
|
|
82
|
+
expect([out.string, notices]).to eq(["Schedule nightly passed: 2 tests\n", []])
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'says test for a total of one' do
|
|
86
|
+
outcome.merge!(statuses: %w[passed])
|
|
87
|
+
wrapper.run('nightly')
|
|
88
|
+
|
|
89
|
+
expect(out.string).to eq("Schedule nightly passed: 1 test\n")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'holds running.json while rspec runs and removes it after' do
|
|
93
|
+
wrapper.run('nightly')
|
|
94
|
+
|
|
95
|
+
expect([calls.first[:running], File.exist?(File.join(dir, '.raider/schedules/running.json'))]).to eq([true, false])
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'records a schedule trigger' do
|
|
99
|
+
wrapper.run('nightly', trigger: 'schedule')
|
|
100
|
+
|
|
101
|
+
expect(lines.last['trigger']).to eq('schedule')
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
describe 'selection' do
|
|
106
|
+
it 'passes one tag' do
|
|
107
|
+
schedule(selection: RubyRaider::Schedules::Selection.new(kind: 'tag', tag: 'smoke'))
|
|
108
|
+
wrapper.run('nightly')
|
|
109
|
+
|
|
110
|
+
expect(calls.first[:argv].drop(2)).to eq(%w[exec rspec --tag smoke])
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'passes the listed files' do
|
|
114
|
+
schedule(selection: RubyRaider::Schedules::Selection.new(kind: 'files', files: %w[spec/login_spec.rb spec/cart_spec.rb]))
|
|
115
|
+
wrapper.run('nightly')
|
|
116
|
+
|
|
117
|
+
expect(calls.first[:argv].drop(2)).to eq(%w[exec rspec spec/login_spec.rb spec/cart_spec.rb])
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
describe 'env' do
|
|
122
|
+
around do |example|
|
|
123
|
+
saved = %w[HEADLESS RAIDER_SHOW_BROWSER RETRY_COUNT RSPEC_RETRY_RETRY_COUNT].to_h { |key| [key, ENV.fetch(key, nil)] }
|
|
124
|
+
example.run
|
|
125
|
+
ensure
|
|
126
|
+
saved.each { |key, value| ENV[key] = value }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def env_for(show_browser:, inherited:)
|
|
130
|
+
ENV['HEADLESS'] = inherited
|
|
131
|
+
ENV['RAIDER_SHOW_BROWSER'] = '1'
|
|
132
|
+
ENV['RSPEC_RETRY_RETRY_COUNT'] = '9'
|
|
133
|
+
schedule(show_browser: show_browser, retries: 2)
|
|
134
|
+
wrapper.run('nightly')
|
|
135
|
+
calls.last[:env].slice('HEADLESS', 'RAIDER_SHOW_BROWSER', 'RETRY_COUNT', 'RSPEC_RETRY_RETRY_COUNT')
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
hidden = { 'HEADLESS' => 'true', 'RAIDER_SHOW_BROWSER' => nil, 'RETRY_COUNT' => '3', 'RSPEC_RETRY_RETRY_COUNT' => nil }
|
|
139
|
+
shown = { 'HEADLESS' => nil, 'RAIDER_SHOW_BROWSER' => '1', 'RETRY_COUNT' => '3', 'RSPEC_RETRY_RETRY_COUNT' => nil }
|
|
140
|
+
|
|
141
|
+
{ [false, nil] => hidden, [false, 'false'] => hidden, [true, nil] => shown, [true, 'true'] => shown }.each do |(show, inherited), env|
|
|
142
|
+
it "sets #{env.compact} for show_browser #{show} with HEADLESS #{inherited.inspect} inherited" do
|
|
143
|
+
expect(env_for(show_browser: show, inherited: inherited)).to eq(env)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it 'sets the GEM_HOME and GEM_PATH captured at add, unsetting one not captured' do
|
|
148
|
+
allow(RubyRaider::Schedules::Schedule).to receive(:captured_gem_env).and_return('GEM_HOME' => '/gems/home')
|
|
149
|
+
schedule
|
|
150
|
+
wrapper.run('nightly')
|
|
151
|
+
|
|
152
|
+
expect(calls.last[:env].slice('GEM_HOME', 'GEM_PATH')).to eq('GEM_HOME' => '/gems/home', 'GEM_PATH' => nil)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it 'leaves the caller\'s GEM_HOME and GEM_PATH when none were captured' do
|
|
156
|
+
allow(RubyRaider::Schedules::Schedule).to receive(:captured_gem_env).and_return({})
|
|
157
|
+
schedule
|
|
158
|
+
wrapper.run('nightly')
|
|
159
|
+
|
|
160
|
+
expect(calls.last[:env].keys & %w[GEM_HOME GEM_PATH]).to eq([])
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
it 'counts tries: one with no retries' do
|
|
164
|
+
wrapper.run('nightly')
|
|
165
|
+
|
|
166
|
+
expect(calls.first[:env]['RETRY_COUNT']).to eq('1')
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
it 'never writes config/config.yml' do
|
|
170
|
+
config = File.join(dir, 'config/config.yml')
|
|
171
|
+
FileUtils.mkdir_p(File.dirname(config))
|
|
172
|
+
File.write(config, "browser: chrome\nheadless: true\n")
|
|
173
|
+
schedule(show_browser: true)
|
|
174
|
+
wrapper.run('nightly')
|
|
175
|
+
|
|
176
|
+
expect(File.binread(config)).to eq("browser: chrome\nheadless: true\n")
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
describe 'a failing run' do
|
|
181
|
+
let(:outcome) { { code: 1, stopped: false, statuses: %w[passed failed quarantined skipped] } }
|
|
182
|
+
|
|
183
|
+
it 'exits 1, prints the failures and notifies when asked' do
|
|
184
|
+
schedule(notify: true)
|
|
185
|
+
|
|
186
|
+
expect(wrapper.run('nightly')).to eq(1)
|
|
187
|
+
expect(out.string).to eq("Schedule nightly failed: 2 of 4 tests failed\n")
|
|
188
|
+
expect(notices).to eq([['Ruby Raider: nightly failed', '2 of 4 tests failed']])
|
|
189
|
+
expect(lines.last['counts']).to eq('total' => 4, 'passed' => 1, 'failed' => 2, 'skipped' => 1)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it 'says test for a total of one' do
|
|
193
|
+
outcome.merge!(statuses: %w[failed])
|
|
194
|
+
schedule(notify: true)
|
|
195
|
+
wrapper.run('nightly')
|
|
196
|
+
|
|
197
|
+
expect([out.string, notices.last.last]).to eq(["Schedule nightly failed: 1 of 1 test failed\n", '1 of 1 test failed'])
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
it 'does not notify without notify' do
|
|
201
|
+
wrapper.run('nightly')
|
|
202
|
+
|
|
203
|
+
expect(notices).to eq([])
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
describe 'setup errors (exit 2)' do
|
|
208
|
+
before { schedule(notify: true) }
|
|
209
|
+
|
|
210
|
+
it 'is a setup error when rspec exits 1 without a report' do
|
|
211
|
+
outcome.merge!(code: 1, statuses: nil)
|
|
212
|
+
|
|
213
|
+
expect(wrapper.run('nightly')).to eq(2)
|
|
214
|
+
expect(lines.last).to include('status' => 'setup_error', 'counts' => { 'total' => 0, 'passed' => 0, 'failed' => 0, 'skipped' => 0 })
|
|
215
|
+
expect(lines.last).not_to have_key('report_copy')
|
|
216
|
+
expect(notices).to eq([['Ruby Raider: nightly failed', 'Could not start: see the run output']])
|
|
217
|
+
expect(out.string).to eq("Schedule nightly could not start: see the run output\n")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
it 'points a scheduled run at its scheduler.log', skip: Gem.win_platform? do
|
|
221
|
+
outcome.merge!(code: 1, statuses: nil)
|
|
222
|
+
wrapper.run('nightly', trigger: 'schedule')
|
|
223
|
+
|
|
224
|
+
expect(notices.last.last).to eq('Could not start: see .raider/schedules/nightly/scheduler.log')
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
it 'is a setup error when no example ran' do
|
|
228
|
+
outcome.merge!(code: 0, statuses: [])
|
|
229
|
+
|
|
230
|
+
expect(wrapper.run('nightly')).to eq(2)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
it 'is a setup error when the run.json is an earlier run, which it keeps' do
|
|
234
|
+
outcome.merge!(code: 1, statuses: nil)
|
|
235
|
+
write_run(%w[failed], started_at: '2026-11-10T08:00:00.000Z')
|
|
236
|
+
|
|
237
|
+
expect(wrapper.run('nightly')).to eq(2)
|
|
238
|
+
expect([lines.last['status'], File.exist?(File.join(folder, 'last')), File.exist?(File.join(dir, 'raider-results/run.json'))]).to eq(['setup_error', false, true])
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
it 'is a setup error when rspec did not start' do
|
|
242
|
+
outcome.merge!(code: nil, statuses: nil)
|
|
243
|
+
|
|
244
|
+
expect(wrapper.run('nightly')).to eq(2)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
it 'records a missing spec file before anything runs' do
|
|
248
|
+
schedule(notify: true, selection: RubyRaider::Schedules::Selection.new(kind: 'files', files: %w[spec/login_spec.rb]))
|
|
249
|
+
File.delete(File.join(dir, 'spec/login_spec.rb'))
|
|
250
|
+
|
|
251
|
+
expect(wrapper.run('nightly')).to eq(2)
|
|
252
|
+
expect([calls, lines.last['status']]).to eq([[], 'setup_error'])
|
|
253
|
+
expect(out.string).to eq("Schedule nightly could not start: spec/login_spec.rb not found: remove the schedule or add it again\n")
|
|
254
|
+
expect(notices).to eq([['Ruby Raider: nightly failed', 'Could not start: spec/login_spec.rb not found']])
|
|
255
|
+
expect(File.exist?(File.join(dir, '.raider/schedules/running.json'))).to be(false)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it 'never changes the exit code for a notification that could not be shown' do
|
|
259
|
+
outcome.merge!(code: 1, statuses: nil)
|
|
260
|
+
|
|
261
|
+
expect(wrapper.run('nightly')).to eq(2)
|
|
262
|
+
expect(notices.size).to eq(1)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
describe 'a stopped run' do
|
|
267
|
+
let(:outcome) { { code: 1, stopped: true, statuses: %w[passed failed] } }
|
|
268
|
+
|
|
269
|
+
it 'is a setup error even with failures, never notified' do
|
|
270
|
+
schedule(notify: true)
|
|
271
|
+
|
|
272
|
+
expect(wrapper.run('nightly')).to eq(2)
|
|
273
|
+
expect([lines.last['status'], notices]).to eq(['setup_error', []])
|
|
274
|
+
expect(out.string).to eq("Schedule nightly was stopped\n")
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# INT or TERM outside the spawn's own traps arrives as Interrupt or SignalException
|
|
279
|
+
describe 'a stop around the spawn' do
|
|
280
|
+
let(:running) { File.join(dir, '.raider/schedules/running.json') }
|
|
281
|
+
# Each stop is raised once: what comes after it runs normally
|
|
282
|
+
let(:stops) { [] }
|
|
283
|
+
|
|
284
|
+
def stop_after_acquire(signal)
|
|
285
|
+
stops << signal
|
|
286
|
+
allow(RubyRaider::Schedules::RunLock).to receive(:new).and_wrap_original do |original, *args|
|
|
287
|
+
original.call(*args).tap do |lock|
|
|
288
|
+
allow(lock).to receive(:acquire).and_wrap_original do |acquire, **options|
|
|
289
|
+
acquire.call(**options).tap { |held| raise(stops.shift) if held && stops.any? }
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
it 'records a stop while taking the lock: exit 2, one stopped line, lock and running.json freed, never notified' do
|
|
296
|
+
schedule(notify: true)
|
|
297
|
+
stop_after_acquire(Interrupt.new)
|
|
298
|
+
|
|
299
|
+
expect(wrapper.run('nightly')).to eq(2)
|
|
300
|
+
expect([calls, notices, lines.map { |line| line['status'] }, File.exist?(running)]).to eq([[], [], ['setup_error'], false])
|
|
301
|
+
expect(out.string).to eq("Schedule nightly was stopped\n")
|
|
302
|
+
expect(described_class.new(project, out: out, err: err, spawner: spawner, clock: -> { now }, notifier: notifier).run('nightly')).to eq(0)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
it 'records a TERM while the report is copied after rspec ended, with the counts, as stopped' do
|
|
306
|
+
allow(RubyRaider::Schedules::RunReport).to receive(:load).and_wrap_original do |original, *args, **options|
|
|
307
|
+
original.call(*args, **options).tap { |report| allow(report).to receive(:copy_to).and_raise(SignalException.new('TERM')) }
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
expect(wrapper.run('nightly')).to eq(2)
|
|
311
|
+
expect(lines.map { |line| line.values_at('status', 'counts') }).to eq([['setup_error', { 'total' => 2, 'passed' => 2, 'failed' => 0, 'skipped' => 0 }]])
|
|
312
|
+
expect([out.string, File.exist?(running)]).to eq(["Schedule nightly was stopped\n", false])
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
it 'keeps the recorded outcome when the stop comes after the run line' do
|
|
316
|
+
stops << Interrupt.new
|
|
317
|
+
allow(RubyRaider::Schedules::RunLog).to receive(:new).and_wrap_original do |original, *args, **options|
|
|
318
|
+
original.call(*args, **options).tap do |log|
|
|
319
|
+
allow(log).to receive(:append).and_wrap_original do |append, line|
|
|
320
|
+
append.call(line).tap { raise(stops.shift) if stops.any? }
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
expect(wrapper.run('nightly')).to eq(0)
|
|
326
|
+
expect(lines.map { |line| line['status'] }).to eq(['passed'])
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
describe 'a run line that cannot be written' do
|
|
331
|
+
before do
|
|
332
|
+
FileUtils.mkdir_p(folder)
|
|
333
|
+
File.symlink(File.join(dir, 'elsewhere.jsonl'), File.join(folder, 'runs.jsonl'))
|
|
334
|
+
schedule(notify: true)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
it 'keeps a stop: not notified, still stopped' do
|
|
338
|
+
outcome.merge!(code: 1, stopped: true)
|
|
339
|
+
|
|
340
|
+
expect(wrapper.run('nightly')).to eq(2)
|
|
341
|
+
expect([notices, out.string, File.exist?(File.join(dir, 'elsewhere.jsonl'))]).to eq([[], "Schedule nightly was stopped\n", false])
|
|
342
|
+
expect(err.string).to include('Could not record the run')
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
it 'keeps the outcome of a run that passed' do
|
|
346
|
+
expect(wrapper.run('nightly')).to eq(0)
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
describe 'busy (exit 3, nothing spawned, raider-results untouched)' do
|
|
351
|
+
before do
|
|
352
|
+
schedule(notify: true)
|
|
353
|
+
write_run(%w[failed])
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def results_snapshot = Dir.glob('**/*', base: File.join(dir, 'raider-results')).sort.map { |path| [path, File.mtime(File.join(dir, 'raider-results', path))] }
|
|
357
|
+
|
|
358
|
+
it 'is busy while another run holds the lock' do
|
|
359
|
+
before = results_snapshot
|
|
360
|
+
File.open(File.join(dir, '.raider/schedules/run.lock'), File::RDWR | File::CREAT, 0o600) do |held|
|
|
361
|
+
held.flock(File::LOCK_EX)
|
|
362
|
+
|
|
363
|
+
expect(wrapper.run('nightly')).to eq(3)
|
|
364
|
+
end
|
|
365
|
+
expect([calls, notices, lines.last['status'], results_snapshot]).to eq([[], [], 'busy', before])
|
|
366
|
+
expect(out.string).to eq("Schedule nightly is already running\n")
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
it 'is busy while running.json names a live process' do
|
|
370
|
+
File.write(File.join(dir, '.raider/schedules/running.json'), JSON.generate('pid' => Process.ppid))
|
|
371
|
+
|
|
372
|
+
expect(wrapper.run('nightly')).to eq(3)
|
|
373
|
+
expect(File.exist?(File.join(dir, '.raider/schedules/running.json'))).to be(true)
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
describe 'refusals (Error, exit 2, no line)' do
|
|
378
|
+
it 'refuses an unknown schedule' do
|
|
379
|
+
expect { wrapper.run('nope') }.to raise_error(RubyRaider::Schedules::Error, /nope/) { |error| expect(error.exit_code).to eq(2) }
|
|
380
|
+
expect(File.exist?(File.join(dir, '.raider/schedules/nope'))).to be(false)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
it 'refuses an entry that no longer validates' do
|
|
384
|
+
yml = File.join(dir, '.raider/schedules/schedules.yml')
|
|
385
|
+
File.write(yml, File.read(yml).sub(/os_id: .*/, 'os_id: dev.rubyraider.schedule.0000000000.nightly'))
|
|
386
|
+
|
|
387
|
+
expect { wrapper.run('nightly') }.to raise_error(RubyRaider::Schedules::Error)
|
|
388
|
+
expect(calls).to eq([])
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
it 'refuses an unknown trigger' do
|
|
392
|
+
expect { wrapper.run('nightly', trigger: 'desktop') }.to raise_error(RubyRaider::Schedules::Error, /trigger/)
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
describe 'scheduler.log' do
|
|
397
|
+
let(:log) { File.join(folder, 'scheduler.log') }
|
|
398
|
+
|
|
399
|
+
before do
|
|
400
|
+
FileUtils.mkdir_p(folder)
|
|
401
|
+
File.write("#{log}.1", 'older')
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
it 'rotates a scheduled run log over 1 MB to scheduler.log.1' do
|
|
405
|
+
File.write(log, 'x' * ((1024 * 1024) + 1))
|
|
406
|
+
wrapper.run('nightly', trigger: 'schedule')
|
|
407
|
+
|
|
408
|
+
expect([File.exist?(log), File.size("#{log}.1")]).to eq([false, (1024 * 1024) + 1])
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
it 'keeps a log of 1 MB or less, and any log on a manual run' do
|
|
412
|
+
File.write(log, 'x' * (1024 * 1024))
|
|
413
|
+
wrapper.run('nightly', trigger: 'schedule')
|
|
414
|
+
File.write(log, 'x' * ((1024 * 1024) + 1))
|
|
415
|
+
wrapper.run('nightly')
|
|
416
|
+
|
|
417
|
+
expect([File.size(log), File.read("#{log}.1")]).to eq([(1024 * 1024) + 1, 'older'])
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
it 'does not rotate a linked log' do
|
|
421
|
+
target = File.join(dir, 'big.log')
|
|
422
|
+
File.write(target, 'x' * ((1024 * 1024) + 1))
|
|
423
|
+
File.symlink(target, log)
|
|
424
|
+
wrapper.run('nightly', trigger: 'schedule')
|
|
425
|
+
|
|
426
|
+
expect([File.symlink?(log), File.read("#{log}.1")]).to eq([true, 'older'])
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
# rubocop:enable RSpec/MultipleMemoizedHelpers
|