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.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/e2e_tests.yml +3 -0
  3. data/README.md +52 -53
  4. data/RELEASE.md +14 -1
  5. data/lib/commands/schedule_commands.rb +84 -0
  6. data/lib/generators/templates/common/git_ignore.tt +1 -0
  7. data/lib/ruby_raider/runtime/retry_reset.rb +32 -0
  8. data/lib/ruby_raider/runtime/rspec.rb +1 -0
  9. data/lib/ruby_raider/runtime/show_browser.rb +53 -0
  10. data/lib/ruby_raider/runtime.rb +2 -0
  11. data/lib/ruby_raider.rb +22 -7
  12. data/lib/utilities/schedules/adapter.rb +22 -0
  13. data/lib/utilities/schedules/base.rb +27 -0
  14. data/lib/utilities/schedules/kind.rb +74 -0
  15. data/lib/utilities/schedules/legacy.rb +196 -0
  16. data/lib/utilities/schedules/legacy_stub.rb +62 -0
  17. data/lib/utilities/{flows → schedules}/next_run.rb +2 -2
  18. data/lib/utilities/{flows → schedules}/notifier.rb +6 -6
  19. data/lib/utilities/{flows → schedules}/os/command_runner.rb +1 -1
  20. data/lib/utilities/{flows → schedules}/os/cron.rb +27 -17
  21. data/lib/utilities/{flows → schedules}/os/launchd.rb +32 -14
  22. data/lib/utilities/{flows → schedules}/os/linux.rb +23 -5
  23. data/lib/utilities/{flows → schedules}/os/systemd.rb +33 -17
  24. data/lib/utilities/{flows → schedules}/os/task_scheduler.rb +36 -20
  25. data/lib/utilities/schedules/os_enumerator.rb +30 -0
  26. data/lib/utilities/schedules/project.rb +70 -0
  27. data/lib/utilities/schedules/rule.rb +162 -0
  28. data/lib/utilities/schedules/run_lock.rb +160 -0
  29. data/lib/utilities/schedules/run_log.rb +122 -0
  30. data/lib/utilities/schedules/run_report.rb +139 -0
  31. data/lib/utilities/schedules/run_wrapper.rb +216 -0
  32. data/lib/utilities/schedules/schedule.rb +89 -0
  33. data/lib/utilities/schedules/schedule_store.rb +53 -0
  34. data/lib/utilities/schedules/scheduler.rb +178 -0
  35. data/lib/utilities/schedules/scheduler_steps.rb +69 -0
  36. data/lib/utilities/schedules/selection.rb +133 -0
  37. data/lib/utilities/schedules/spawn.rb +68 -0
  38. data/lib/utilities/{flows/schedule_store.rb → schedules/store.rb} +17 -13
  39. data/lib/version +1 -1
  40. data/ruby_raider.gemspec +1 -0
  41. data/sig/commands/schedule_commands.rbs +12 -0
  42. data/sig/ruby_raider/runtime/retry_reset.rbs +7 -0
  43. data/sig/ruby_raider/runtime/show_browser.rbs +27 -0
  44. data/sig/ruby_raider.rbs +4 -0
  45. data/sig/utilities/schedules/adapter.rbs +5 -0
  46. data/sig/utilities/schedules/base.rbs +15 -0
  47. data/sig/utilities/schedules/kind.rbs +32 -0
  48. data/sig/utilities/schedules/legacy.rbs +45 -0
  49. data/sig/utilities/schedules/legacy_stub.rbs +15 -0
  50. data/sig/utilities/{flows → schedules}/next_run.rbs +3 -3
  51. data/sig/utilities/{flows → schedules}/notifier.rbs +1 -1
  52. data/sig/utilities/{flows → schedules}/os/command_runner.rbs +1 -1
  53. data/sig/utilities/{flows → schedules}/os/cron.rbs +7 -7
  54. data/sig/utilities/{flows → schedules}/os/launchd.rbs +7 -6
  55. data/sig/utilities/{flows → schedules}/os/linux.rbs +10 -4
  56. data/sig/utilities/{flows → schedules}/os/systemd.rbs +8 -7
  57. data/sig/utilities/{flows → schedules}/os/task_scheduler.rbs +13 -10
  58. data/sig/utilities/schedules/os_enumerator.rbs +12 -0
  59. data/sig/utilities/schedules/project.rbs +22 -0
  60. data/sig/utilities/{flows/schedule.rbs → schedules/rule.rbs} +23 -12
  61. data/sig/utilities/schedules/run_lock.rbs +29 -0
  62. data/sig/utilities/schedules/run_log.rbs +33 -0
  63. data/sig/utilities/schedules/run_report.rbs +34 -0
  64. data/sig/utilities/schedules/run_wrapper.rbs +45 -0
  65. data/sig/utilities/schedules/schedule.rbs +27 -0
  66. data/sig/utilities/schedules/schedule_store.rbs +21 -0
  67. data/sig/utilities/{flows → schedules}/scheduler.rbs +22 -15
  68. data/sig/utilities/schedules/scheduler_steps.rbs +22 -0
  69. data/sig/utilities/schedules/selection.rbs +36 -0
  70. data/sig/utilities/schedules/spawn.rbs +10 -0
  71. data/sig/utilities/{flows/schedule_store.rbs → schedules/store.rbs} +14 -9
  72. data/spec/commands/schedule_commands_spec.rb +284 -0
  73. data/spec/integration/content/common_content_spec.rb +8 -2
  74. data/spec/integration/content/gemfile_content_spec.rb +2 -2
  75. data/spec/integration/end_to_end_spec.rb +0 -63
  76. data/spec/runtime/retry_reset_spec.rb +112 -0
  77. data/spec/runtime/show_browser_project_spec.rb +70 -0
  78. data/spec/runtime/show_browser_spec.rb +101 -0
  79. data/spec/utilities/schedules/kind_spec.rb +81 -0
  80. data/spec/utilities/schedules/legacy_spec.rb +433 -0
  81. data/spec/utilities/schedules/legacy_stub_spec.rb +144 -0
  82. data/spec/utilities/{flows → schedules}/next_run_spec.rb +5 -5
  83. data/spec/utilities/{flows → schedules}/notifier_spec.rb +3 -4
  84. data/spec/utilities/{flows → schedules}/os/command_runner_spec.rb +2 -3
  85. data/spec/utilities/{flows → schedules}/os/cron_spec.rb +34 -29
  86. data/spec/utilities/{flows → schedules}/os/launchd_spec.rb +42 -29
  87. data/spec/utilities/{flows → schedules}/os/linux_spec.rb +27 -13
  88. data/spec/utilities/schedules/os/list_ids_spec.rb +202 -0
  89. data/spec/utilities/{flows → schedules}/os/systemd_spec.rb +35 -31
  90. data/spec/utilities/{flows → schedules}/os/task_scheduler_spec.rb +40 -34
  91. data/spec/utilities/schedules/os_entry_spec.rb +54 -0
  92. data/spec/utilities/schedules/os_enumerator_spec.rb +57 -0
  93. data/spec/utilities/schedules/project_spec.rb +125 -0
  94. data/spec/utilities/schedules/run_lock_spec.rb +143 -0
  95. data/spec/utilities/schedules/run_log_spec.rb +83 -0
  96. data/spec/utilities/schedules/run_report_spec.rb +150 -0
  97. data/spec/utilities/schedules/run_wrapper_integration_spec.rb +166 -0
  98. data/spec/utilities/schedules/run_wrapper_spec.rb +430 -0
  99. data/spec/utilities/schedules/schedule_spec.rb +120 -0
  100. data/spec/utilities/schedules/schedule_store_spec.rb +151 -0
  101. data/spec/utilities/schedules/scheduler_launchd_spec.rb +77 -0
  102. data/spec/utilities/schedules/scheduler_spec.rb +445 -0
  103. data/spec/utilities/schedules/selection_spec.rb +176 -0
  104. data/spec/utilities/schedules/spawn_spec.rb +104 -0
  105. metadata +106 -53
  106. data/lib/commands/flow_commands.rb +0 -104
  107. data/lib/generators/templates/flows/flow_helper.tt +0 -42
  108. data/lib/generators/templates/flows/run.tt +0 -332
  109. data/lib/utilities/flows/project.rb +0 -170
  110. data/lib/utilities/flows/run_log.rb +0 -61
  111. data/lib/utilities/flows/runner.rb +0 -206
  112. data/lib/utilities/flows/schedule.rb +0 -221
  113. data/lib/utilities/flows/scheduler.rb +0 -187
  114. data/sig/commands/flow_commands.rbs +0 -13
  115. data/sig/utilities/flows/project.rbs +0 -46
  116. data/sig/utilities/flows/run_log.rbs +0 -22
  117. data/sig/utilities/flows/runner.rbs +0 -35
  118. data/spec/commands/flow_commands_spec.rb +0 -128
  119. data/spec/generators/flow_helper_spec.rb +0 -40
  120. data/spec/integration/flows/flow_project.rb +0 -62
  121. data/spec/integration/flows/run_command_spec.rb +0 -91
  122. data/spec/integration/flows/run_lifecycle_spec.rb +0 -383
  123. data/spec/utilities/flows/project_spec.rb +0 -178
  124. data/spec/utilities/flows/run_log_spec.rb +0 -47
  125. data/spec/utilities/flows/runner_spec.rb +0 -313
  126. data/spec/utilities/flows/schedule_spec.rb +0 -233
  127. data/spec/utilities/flows/schedule_store_spec.rb +0 -137
  128. 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