ruby_raider 4.0.1 → 4.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 +4 -4
- data/README.md +59 -1
- data/RELEASE.md +12 -0
- data/lib/commands/flow_commands.rb +104 -0
- data/lib/generators/templates/flows/flow_helper.tt +42 -0
- data/lib/generators/templates/flows/run.tt +332 -0
- data/lib/ruby_raider.rb +16 -0
- data/lib/utilities/flows/next_run.rb +61 -0
- data/lib/utilities/flows/notifier.rb +76 -0
- data/lib/utilities/flows/os/command_runner.rb +73 -0
- data/lib/utilities/flows/os/cron.rb +137 -0
- data/lib/utilities/flows/os/launchd.rb +164 -0
- data/lib/utilities/flows/os/linux.rb +63 -0
- data/lib/utilities/flows/os/systemd.rb +178 -0
- data/lib/utilities/flows/os/task_scheduler.rb +179 -0
- data/lib/utilities/flows/project.rb +170 -0
- data/lib/utilities/flows/run_log.rb +61 -0
- data/lib/utilities/flows/runner.rb +206 -0
- data/lib/utilities/flows/schedule.rb +221 -0
- data/lib/utilities/flows/schedule_store.rb +135 -0
- data/lib/utilities/flows/scheduler.rb +187 -0
- data/lib/version +1 -1
- data/sig/commands/flow_commands.rbs +13 -0
- data/sig/ruby_raider.rbs +2 -0
- data/sig/utilities/flows/next_run.rbs +15 -0
- data/sig/utilities/flows/notifier.rbs +29 -0
- data/sig/utilities/flows/os/command_runner.rbs +27 -0
- data/sig/utilities/flows/os/cron.rbs +34 -0
- data/sig/utilities/flows/os/launchd.rbs +45 -0
- data/sig/utilities/flows/os/linux.rbs +27 -0
- data/sig/utilities/flows/os/systemd.rbs +43 -0
- data/sig/utilities/flows/os/task_scheduler.rbs +53 -0
- data/sig/utilities/flows/project.rbs +46 -0
- data/sig/utilities/flows/run_log.rbs +22 -0
- data/sig/utilities/flows/runner.rbs +35 -0
- data/sig/utilities/flows/schedule.rbs +61 -0
- data/sig/utilities/flows/schedule_store.rbs +34 -0
- data/sig/utilities/flows/scheduler.rbs +42 -0
- data/spec/commands/flow_commands_spec.rb +128 -0
- data/spec/generators/flow_helper_spec.rb +40 -0
- data/spec/integration/content/gemfile_content_spec.rb +2 -2
- data/spec/integration/end_to_end_spec.rb +63 -0
- data/spec/integration/flows/flow_project.rb +62 -0
- data/spec/integration/flows/run_command_spec.rb +91 -0
- data/spec/integration/flows/run_lifecycle_spec.rb +383 -0
- data/spec/utilities/flows/next_run_spec.rb +119 -0
- data/spec/utilities/flows/notifier_spec.rb +121 -0
- data/spec/utilities/flows/os/command_runner_spec.rb +56 -0
- data/spec/utilities/flows/os/cron_spec.rb +287 -0
- data/spec/utilities/flows/os/launchd_spec.rb +293 -0
- data/spec/utilities/flows/os/linux_spec.rb +149 -0
- data/spec/utilities/flows/os/systemd_spec.rb +329 -0
- data/spec/utilities/flows/os/task_scheduler_spec.rb +505 -0
- data/spec/utilities/flows/project_spec.rb +178 -0
- data/spec/utilities/flows/run_log_spec.rb +47 -0
- data/spec/utilities/flows/runner_spec.rb +313 -0
- data/spec/utilities/flows/schedule_spec.rb +233 -0
- data/spec/utilities/flows/schedule_store_spec.rb +137 -0
- data/spec/utilities/flows/scheduler_spec.rb +329 -0
- metadata +53 -2
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tmpdir'
|
|
4
|
+
require_relative '../../../lib/utilities/flows/run_log'
|
|
5
|
+
require_relative '../../../lib/utilities/flows/project'
|
|
6
|
+
|
|
7
|
+
# Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
|
|
8
|
+
RSpec.describe RubyRaider::Flows::RunLog do # rubocop:disable RSpec/FilePath
|
|
9
|
+
let(:dir) { File.realpath(Dir.mktmpdir('flows-log')) }
|
|
10
|
+
let(:log) { described_class.new(RubyRaider::Flows::Project.new(dir), 'report') }
|
|
11
|
+
let(:file) { File.join(dir, '.raider/flows/report/runs.jsonl') }
|
|
12
|
+
|
|
13
|
+
before { FileUtils.mkdir_p(File.dirname(file)) }
|
|
14
|
+
|
|
15
|
+
after { FileUtils.rm_rf(dir) }
|
|
16
|
+
|
|
17
|
+
it 'skips broken and non-object lines' do
|
|
18
|
+
File.write(file, %({"status":"failed"}\nnot json\n[1]\n{"status":"completed","run_id":"a"}\n))
|
|
19
|
+
|
|
20
|
+
expect([log.lines.size, log.last['status'], log.find('a')['status']]).to eq([2, 'completed', 'completed'])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'reads only the last megabyte of a big log, from a whole line' do
|
|
24
|
+
File.write(file, %({"status":"old","pad":"#{'x' * 2_000_000}"}\n{"status":"completed"}\n))
|
|
25
|
+
|
|
26
|
+
expect(log.lines).to eq([{ 'status' => 'completed' }])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'does not follow a linked log' do
|
|
30
|
+
File.write(File.join(dir, 'other.jsonl'), %({"status":"completed"}\n))
|
|
31
|
+
File.symlink(File.join(dir, 'other.jsonl'), file)
|
|
32
|
+
|
|
33
|
+
expect(log.lines).to eq([])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'lists the last run that ran, not a busy line after it' do
|
|
37
|
+
FileUtils.mkdir_p(File.join(dir, 'flows'))
|
|
38
|
+
File.write(File.join(dir, 'flows/report_flow.rb'), '')
|
|
39
|
+
File.write(file, %({"status":"failed"}\n{"status":"busy"}\n))
|
|
40
|
+
|
|
41
|
+
expect(described_class.summaries(RubyRaider::Flows::Project.new(dir)).first['last']['status']).to eq('failed')
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'is empty without a log' do
|
|
45
|
+
expect(log.last).to be_nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
require_relative '../../../lib/utilities/flows/runner'
|
|
7
|
+
|
|
8
|
+
# Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
|
|
9
|
+
# The fake spawner needs its call, sleep and outcome lists next to the project and runner
|
|
10
|
+
# rubocop:disable RSpec/MultipleMemoizedHelpers
|
|
11
|
+
RSpec.describe RubyRaider::Flows::Runner do # rubocop:disable RSpec/FilePath
|
|
12
|
+
let(:dir) { File.realpath(Dir.mktmpdir('flows-runner')) }
|
|
13
|
+
let(:project) { RubyRaider::Flows::Project.new(dir) }
|
|
14
|
+
let(:out) { StringIO.new }
|
|
15
|
+
let(:calls) { [] }
|
|
16
|
+
let(:sleeps) { [] }
|
|
17
|
+
let(:outcomes) { [] }
|
|
18
|
+
|
|
19
|
+
# Stands in for `bundle exec rspec`: records the call and appends the line flows/support/run.rb would
|
|
20
|
+
let(:spawner) do
|
|
21
|
+
lambda do |env, argv, chdir|
|
|
22
|
+
calls << { env: env, argv: argv, chdir: chdir }
|
|
23
|
+
line = outcomes.shift || {}
|
|
24
|
+
unless line == :nothing
|
|
25
|
+
log = File.join(dir, '.raider/flows/report/runs.jsonl')
|
|
26
|
+
FileUtils.mkdir_p(File.dirname(log))
|
|
27
|
+
File.write(log, "#{JSON.generate(line_for(env, line))}\n", mode: 'a')
|
|
28
|
+
end
|
|
29
|
+
line == :nothing ? 2 : line.fetch(:code, 0)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
let(:runner) { described_class.new(project, out: out, spawner: spawner, sleeper: ->(seconds) { sleeps << seconds }) }
|
|
34
|
+
|
|
35
|
+
def line_for(env, line)
|
|
36
|
+
{ 'started_at' => '2026-11-09T08:00:00.000Z', 'finished_at' => '2026-11-09T08:00:12.300Z', 'status' => 'completed',
|
|
37
|
+
'attempt' => env['RAIDER_FLOW_ATTEMPT'].to_i, 'trigger' => env['RAIDER_FLOW_TRIGGER'],
|
|
38
|
+
'run_id' => env['RAIDER_FLOW_RUN_ID'], 'results_dir' => 'flow-results/report/20261109T080000Z' }
|
|
39
|
+
.merge(line.except(:code).transform_keys(&:to_s))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def log_statuses = File.readlines(File.join(dir, '.raider/flows/report/runs.jsonl')).map { |l| JSON.parse(l)['status'] }
|
|
43
|
+
|
|
44
|
+
before do
|
|
45
|
+
%w[flows/flow_helper.rb flows/support/run.rb flows/report_flow.rb].each do |file|
|
|
46
|
+
FileUtils.mkdir_p(File.dirname(File.join(dir, file)))
|
|
47
|
+
File.write(File.join(dir, file), '')
|
|
48
|
+
end
|
|
49
|
+
FileUtils.mkdir_p(File.join(dir, 'flow-results/report/20261109T080000Z'))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
after { FileUtils.rm_rf(dir) }
|
|
53
|
+
|
|
54
|
+
it 'runs the flow file through bundle exec rspec as an argv array, from the project root' do
|
|
55
|
+
runner.run('report')
|
|
56
|
+
|
|
57
|
+
expect(calls.first[:argv]).to eq(['bundle', 'exec', 'rspec', 'flows/report_flow.rb', '--tag', 'flow'])
|
|
58
|
+
expect(calls.first[:chdir]).to eq(dir)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'exits 0 and prints the time and results folder when the flow completed' do
|
|
62
|
+
expect(runner.run('report')).to eq(0)
|
|
63
|
+
expect(out.string).to eq("Flow report completed in 12.3 s\nResults: flow-results/report/20261109T080000Z\n")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'exits 1 and names the step when a step failed' do
|
|
67
|
+
outcomes << { status: 'failed', code: 1, failed_step: { 'number' => 4, 'title' => 'click "Export"', 'line' => 9 } }
|
|
68
|
+
|
|
69
|
+
expect(runner.run('report')).to eq(1)
|
|
70
|
+
expect(out.string).to start_with(%(Flow report failed at step 4 (click "Export")\n))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'names the line when the flow has no recorded steps' do
|
|
74
|
+
outcomes << { status: 'failed', code: 1, failed_step: { 'title' => 'exports', 'line' => 9 } }
|
|
75
|
+
|
|
76
|
+
runner.run('report')
|
|
77
|
+
|
|
78
|
+
expect(out.string).to start_with("Flow report failed at line 9 (exports)\n")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'exits 2 on a setup error' do
|
|
82
|
+
outcomes << { status: 'setup_error', code: 1 }
|
|
83
|
+
|
|
84
|
+
expect(runner.run('report')).to eq(2)
|
|
85
|
+
expect(out.string).to start_with("Flow report could not start: see the output above\n")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'exits 2 when the run left no log line' do
|
|
89
|
+
outcomes << :nothing
|
|
90
|
+
|
|
91
|
+
expect(runner.run('report')).to eq(2)
|
|
92
|
+
expect(out.string).to eq("Flow report did not record a run (exit 2): see the output above\n")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it 'exits 3 without retrying when the flow is already running' do
|
|
96
|
+
outcomes << { status: 'busy', code: 3, results_dir: nil }
|
|
97
|
+
|
|
98
|
+
expect(runner.run('report', retries: 2)).to eq(3)
|
|
99
|
+
expect([calls.size, sleeps]).to eq([1, []])
|
|
100
|
+
expect(out.string).to eq("Flow report is already running\n")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it 'retries the whole flow 30 s later and exits 0 once it completes' do
|
|
104
|
+
outcomes.push({ status: 'failed', code: 1 }, { status: 'completed' })
|
|
105
|
+
|
|
106
|
+
expect(runner.run('report', retries: 2)).to eq(0)
|
|
107
|
+
expect(sleeps).to eq([30])
|
|
108
|
+
expect(calls.map { |call| call[:env]['RAIDER_FLOW_ATTEMPT'] }).to eq(%w[1 2])
|
|
109
|
+
expect(log_statuses).to eq(%w[failed completed])
|
|
110
|
+
expect(out.string).to include('Retrying in 30 s (attempt 2 of 3)')
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'gives up after the last retry with the last status' do
|
|
114
|
+
outcomes.push({ status: 'failed', code: 1 }, { status: 'setup_error', code: 1 })
|
|
115
|
+
|
|
116
|
+
expect(runner.run('report', retries: 1)).to eq(2)
|
|
117
|
+
expect(calls.size).to eq(2)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it 'reads each attempt by its own run id, not the last line in the log' do
|
|
121
|
+
outcomes << { status: 'completed', run_id: 'someone-else' }
|
|
122
|
+
|
|
123
|
+
expect(runner.run('report')).to eq(2)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it 'passes inputs, trigger and headless through the environment' do
|
|
127
|
+
runner.run('report', inputs: ['who=ada lovelace', 'note=a=b'], trigger: 'schedule')
|
|
128
|
+
env = calls.first[:env]
|
|
129
|
+
|
|
130
|
+
expect(env).to include('RAIDER_FLOW_NAME' => 'report', 'RAIDER_FLOW_TRIGGER' => 'schedule', 'RAIDER_FLOW_HEADED' => nil,
|
|
131
|
+
'HEADLESS' => 'true', 'RETRY_COUNT' => '0',
|
|
132
|
+
'RAIDER_INPUT_WHO' => 'ada lovelace', 'RAIDER_INPUT_NOTE' => 'a=b')
|
|
133
|
+
expect(env['RAIDER_FLOW_RUN_ID']).to match(/\A[0-9a-f]{16}\z/)
|
|
134
|
+
expect(calls.first[:argv].join(' ')).not_to include('ada')
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it 'unsets HEADLESS and sets RAIDER_FLOW_HEADED for --headed' do
|
|
138
|
+
runner.run('report', headed: true)
|
|
139
|
+
|
|
140
|
+
expect(calls.first[:env]).to include('RAIDER_FLOW_HEADED' => '1', 'HEADLESS' => nil)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it 'exits 2 for a status it does not know' do
|
|
144
|
+
outcomes << { status: 'running' }
|
|
145
|
+
|
|
146
|
+
expect(runner.run('report')).to eq(2)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it 'unsets RAIDER_INPUT_* left in the shell and points bundler at the project Gemfile' do
|
|
150
|
+
ENV['RAIDER_INPUT_STALE'] = 'leak'
|
|
151
|
+
runner.run('report')
|
|
152
|
+
|
|
153
|
+
expect(calls.first[:env]).to include('RAIDER_INPUT_STALE' => nil, 'BUNDLE_GEMFILE' => File.join(dir, 'Gemfile'))
|
|
154
|
+
ensure
|
|
155
|
+
ENV.delete('RAIDER_INPUT_STALE')
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it 'passes the name-built path, not the target of a linked flow file' do
|
|
159
|
+
File.delete(File.join(dir, 'flows/report_flow.rb'))
|
|
160
|
+
File.write(File.join(dir, 'flows/a&calc.rb'), '')
|
|
161
|
+
File.symlink(File.join(dir, 'flows/a&calc.rb'), File.join(dir, 'flows/report_flow.rb'))
|
|
162
|
+
|
|
163
|
+
runner.run('report')
|
|
164
|
+
|
|
165
|
+
expect(calls.first[:argv]).to include('flows/report_flow.rb')
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it 'prints control characters from the log as ?' do
|
|
169
|
+
outcomes << { status: 'failed', code: 1, failed_step: { 'title' => "x\e[2Jy\nFlow fake completed", 'line' => 1 } }
|
|
170
|
+
|
|
171
|
+
runner.run('report')
|
|
172
|
+
|
|
173
|
+
expect(out.string).to start_with("Flow report failed at line 1 (x?[2Jy?Flow fake completed)\n")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
describe '--notify' do
|
|
177
|
+
let(:notes) { [] }
|
|
178
|
+
let(:notify_result) { [true] }
|
|
179
|
+
let(:notifier) do
|
|
180
|
+
sent = notes
|
|
181
|
+
result = notify_result
|
|
182
|
+
Class.new do
|
|
183
|
+
define_method(:notify) do |title, message|
|
|
184
|
+
sent << [title, message]
|
|
185
|
+
result.first
|
|
186
|
+
end
|
|
187
|
+
end.new
|
|
188
|
+
end
|
|
189
|
+
let(:notifying) { described_class.new(project, out: out, spawner: spawner, sleeper: ->(_) {}, notifier: notifier) }
|
|
190
|
+
|
|
191
|
+
it 'sends one notification naming the failed step after the last attempt' do
|
|
192
|
+
2.times { outcomes << { status: 'failed', code: 1, failed_step: { 'number' => 3, 'title' => "Open\e[2J report" } } }
|
|
193
|
+
|
|
194
|
+
expect(notifying.run('report', retries: 1, notify: true)).to eq(1)
|
|
195
|
+
expect(notes).to eq([['Ruby Raider flow report', "Failed at step 3 (Open\e[2J report)"]])
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
it 'points a scheduled run at the scheduler log on a setup error or when no line was recorded' do
|
|
199
|
+
allow(Gem).to receive(:win_platform?).and_return(false)
|
|
200
|
+
outcomes.push({ status: 'setup_error', code: 1 }, :nothing)
|
|
201
|
+
2.times { notifying.run('report', trigger: 'schedule', notify: true) }
|
|
202
|
+
|
|
203
|
+
expect(notes.map(&:last)).to eq(['Could not start: see .raider/flows/report/scheduler.log'] * 2)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
it 'points at the run output when there is no scheduler log: a manual run, or Windows' do
|
|
207
|
+
outcomes.push({ status: 'setup_error', code: 1 }, { status: 'setup_error', code: 1 })
|
|
208
|
+
allow(Gem).to receive(:win_platform?).and_return(false)
|
|
209
|
+
notifying.run('report', notify: true)
|
|
210
|
+
allow(Gem).to receive(:win_platform?).and_return(true)
|
|
211
|
+
notifying.run('report', trigger: 'schedule', notify: true)
|
|
212
|
+
|
|
213
|
+
expect(notes.map(&:last)).to eq(['Could not start: see the run output'] * 2)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
it 'stays quiet when the run was stopped' do
|
|
217
|
+
outcomes << { status: 'failed', code: 1 }
|
|
218
|
+
stopping = lambda do |*args|
|
|
219
|
+
described_class.stopped = true
|
|
220
|
+
spawner.call(*args)
|
|
221
|
+
end
|
|
222
|
+
described_class.new(project, out: out, spawner: stopping, sleeper: ->(_) {}, notifier: notifier).run('report', notify: true)
|
|
223
|
+
|
|
224
|
+
expect(notes).to eq([])
|
|
225
|
+
ensure
|
|
226
|
+
described_class.stopped = false
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
it 'stays quiet when the flow completed or was busy, and without --notify' do
|
|
230
|
+
outcomes.push({}, { status: 'busy', code: 3 }, { status: 'failed', code: 1 })
|
|
231
|
+
notifying.run('report', notify: true)
|
|
232
|
+
notifying.run('report', notify: true)
|
|
233
|
+
notifying.run('report')
|
|
234
|
+
|
|
235
|
+
expect(notes).to eq([])
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
it 'keeps the exit code when the notification fails' do
|
|
239
|
+
notify_result[0] = false
|
|
240
|
+
outcomes << { status: 'failed', code: 1 }
|
|
241
|
+
|
|
242
|
+
expect([notifying.run('report', notify: true), notes.size]).to eq([1, 1])
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
describe 'refusals (exit 2 through Flows::Error, nothing spawned)' do
|
|
247
|
+
def refuse(name, message, **options)
|
|
248
|
+
expect { runner.run(name, **options) }.to raise_error(RubyRaider::Flows::Error, message)
|
|
249
|
+
expect(calls).to be_empty
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
it('rejects an input name that is not lower snake case') { refuse('report', /Invalid input "WHO"/, inputs: ['WHO=x']) }
|
|
253
|
+
it('rejects an input without a value') { refuse('report', /Invalid input "who"/, inputs: ['who']) }
|
|
254
|
+
it('rejects retries above 3') { refuse('report', /Invalid retries 4/, retries: 4) }
|
|
255
|
+
it('rejects an unknown trigger') { refuse('report', /Invalid trigger/, trigger: 'cron') }
|
|
256
|
+
it('rejects an unsafe flow name') { refuse('../report', /Invalid flow name/) }
|
|
257
|
+
it('rejects the reserved name env') { refuse('env', /Invalid flow name "env"/) }
|
|
258
|
+
it('rejects a NUL byte in an input value') { refuse('report', /NUL byte/, inputs: ["who=a\0b"]) }
|
|
259
|
+
|
|
260
|
+
it 'rejects a project without flow init' do
|
|
261
|
+
File.delete(File.join(dir, 'flows/support/run.rb'))
|
|
262
|
+
|
|
263
|
+
refuse('report', /run raider flow init/)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
describe '.spawn' do
|
|
268
|
+
it 'runs without a shell and returns the exit status' do
|
|
269
|
+
code = described_class.spawn({ 'X' => '; exit 7' }, [RbConfig.ruby, '-e', 'exit(ENV["X"] == "; exit 7" ? 5 : 6)'], dir)
|
|
270
|
+
|
|
271
|
+
expect(code).to eq(5)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
it 'passes INT or TERM to the flow once, as INT, and marks the run stopped', skip: Gem.win_platform? do
|
|
275
|
+
ready = File.join(dir, 'ready')
|
|
276
|
+
got = File.join(dir, 'got')
|
|
277
|
+
child = "n = 0; trap('INT') { n += 1 }; trap('TERM') { n += 100 }; File.write(#{ready.inspect}, ''); " \
|
|
278
|
+
"sleep 1.5; File.write(#{got.inspect}, n.to_s)"
|
|
279
|
+
Thread.new do
|
|
280
|
+
sleep 0.05 until File.exist?(ready)
|
|
281
|
+
Process.kill('TERM', Process.pid)
|
|
282
|
+
Process.kill('INT', Process.pid)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
code = described_class.spawn({}, [RbConfig.ruby, '-e', child], dir)
|
|
286
|
+
|
|
287
|
+
expect([code, File.read(got), described_class.stopped]).to eq([0, '1', true])
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
it 'does not retry a stopped run' do
|
|
291
|
+
outcomes.push({ status: 'setup_error', code: 1 }, { status: 'completed' })
|
|
292
|
+
stopping = lambda do |*args|
|
|
293
|
+
described_class.stopped = true
|
|
294
|
+
spawner.call(*args)
|
|
295
|
+
end
|
|
296
|
+
stopped_runner = described_class.new(project, out: out, spawner: stopping, sleeper: ->(seconds) { sleeps << seconds })
|
|
297
|
+
|
|
298
|
+
expect(stopped_runner.run('report', retries: 2)).to eq(2)
|
|
299
|
+
expect([calls.size, sleeps]).to eq([1, []])
|
|
300
|
+
expect(out.string).to include('Flow report was stopped: no retry')
|
|
301
|
+
ensure
|
|
302
|
+
described_class.stopped = false
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
it 'returns nil with one line on stderr when the command is not found' do
|
|
306
|
+
code = nil
|
|
307
|
+
|
|
308
|
+
expect { code = described_class.spawn({}, ['raider-no-such-command'], dir) }.to output(/Could not start raider-no-such-command/).to_stderr
|
|
309
|
+
expect(code).to be_nil
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
# rubocop:enable RSpec/MultipleMemoizedHelpers
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tmpdir'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require_relative '../../../lib/utilities/flows/schedule'
|
|
6
|
+
|
|
7
|
+
# Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
|
|
8
|
+
RSpec.describe RubyRaider::Flows::Schedule do # rubocop:disable RSpec/FilePath
|
|
9
|
+
let(:dir) { File.realpath(Dir.mktmpdir('flows-schedule')) }
|
|
10
|
+
let(:project) { RubyRaider::Flows::Project.new(dir) }
|
|
11
|
+
let(:now) { Time.utc(2026, 11, 9, 10, 0, 0) }
|
|
12
|
+
|
|
13
|
+
def build(**options)
|
|
14
|
+
described_class.build(project: project, name: 'report', every: 'day', at: '08:00', now: now, **options)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def error_for(**options)
|
|
18
|
+
build(**options)
|
|
19
|
+
rescue RubyRaider::Flows::Error => e
|
|
20
|
+
e.message
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
after { FileUtils.rm_rf(dir) }
|
|
24
|
+
|
|
25
|
+
describe 'rules' do
|
|
26
|
+
it 'reads the hour and minute and spells the rule' do
|
|
27
|
+
schedule = build(at: '18:05')
|
|
28
|
+
|
|
29
|
+
expect([schedule.hour, schedule.minute, schedule.at, schedule.rule_text]).to eq([18, 5, '18:05', 'every day at 18:05'])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'takes :MM or HH:MM for every hour, ignores the hour and defaults to :00' do
|
|
33
|
+
texts = [':15', '07:15', nil].map { |at| build(every: 'hour', at: at) }.map { |s| [s.at, s.rule_text] }
|
|
34
|
+
|
|
35
|
+
expect(texts).to eq([['00:15', 'every hour at :15'], ['00:15', 'every hour at :15'], ['00:00', 'every hour at :00']])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'runs every day and every hour on all days, weekdays mon to fri, a week on its day' do
|
|
39
|
+
days = [build, build(every: 'hour', at: ':00'), build(every: 'weekday'), build(every: 'week', on: 'wed')].map(&:days)
|
|
40
|
+
|
|
41
|
+
expect(days).to eq([%w[mon tue wed thu fri sat sun], %w[mon tue wed thu fri sat sun], %w[mon tue wed thu fri], %w[wed]])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'spells weekday and week rules' do
|
|
45
|
+
expect([build(every: 'weekday').rule_text, build(every: 'week', on: 'mon').rule_text]).to eq(['every weekday at 08:00', 'every week on mon at 08:00'])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'refuses an unknown every' do
|
|
49
|
+
expect(error_for(every: 'month')).to include('use hour, day, weekday or week')
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'refuses a missing or malformed at for day, weekday and week' do
|
|
53
|
+
errors = [nil, '8:00', '24:00', '08:60', '08:00x', ':15'].map { |at| error_for(at: at) }
|
|
54
|
+
|
|
55
|
+
expect(errors).to all(include('HH:MM'))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'refuses a malformed at for every hour' do
|
|
59
|
+
expect([error_for(every: 'hour', at: ':60'), error_for(every: 'hour', at: '25:00')]).to all(include(':MM'))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'needs on for every week and refuses it otherwise' do
|
|
63
|
+
expect([error_for(every: 'week'), error_for(every: 'week', on: 'monday'), error_for(on: 'mon')]).to eq(
|
|
64
|
+
['Every week needs on: use mon, tue, wed, thu, fri, sat or sun',
|
|
65
|
+
'Invalid on "monday": use mon, tue, wed, thu, fri, sat or sun',
|
|
66
|
+
'on is only for every week']
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'takes retries 0 to 3' do
|
|
71
|
+
expect([build(retries: 3).retries, error_for(retries: 4), error_for(retries: '1')]).to eq([3, 'Invalid retries 4: use 0 to 3', 'Invalid retries "1": use 0 to 3'])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'refuses a bad flow name' do
|
|
75
|
+
expect(error_for(name: '../x')).to include('Invalid flow name')
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'refuses bad input names and values with control characters' do
|
|
79
|
+
errors = [['Bad=1'], ['nokey'], ["note=a\nb"], ["note=a\tb"], ["note=a\0b"]].map { |inputs| error_for(inputs: inputs) }
|
|
80
|
+
|
|
81
|
+
expect(errors).to all(start_with('Invalid input'))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'refuses input that is not valid UTF-8' do
|
|
85
|
+
expect([error_for(inputs: ["note=a\xFFb"]), error_for(inputs: ["n\xFFte=ab"])]).to all(include('valid UTF-8'))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'keeps hostile input values as plain argv items' do
|
|
89
|
+
values = ["'; rm -rf ~; '", '</string>', '%h', '$(id)']
|
|
90
|
+
schedule = build(inputs: values.map { |value| "note=#{value}" })
|
|
91
|
+
|
|
92
|
+
expect(schedule.argv.last(4)).to eq(values.map { |value| "note=#{value}" })
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
describe 'the OS entry' do
|
|
97
|
+
it 'names the entry from the project realpath and the flow name' do
|
|
98
|
+
digest = Digest::SHA256.hexdigest(dir)[0, 10]
|
|
99
|
+
|
|
100
|
+
expect([build.os_id, described_class.os_id_for(dir, 'report')]).to eq(["dev.rubyraider.flow.#{digest}.report"] * 2)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it 'runs raider flow run under the absolute ruby and bundle' do
|
|
104
|
+
expect(build.argv).to eq([RbConfig.ruby, Gem.bin_path('bundler', 'bundle'), 'exec', 'raider', 'flow', 'run', 'report',
|
|
105
|
+
'--project', dir, '--trigger', 'schedule'])
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'adds retries only above 0 and --input with each pair' do
|
|
109
|
+
expect(build(retries: 2, inputs: %w[a=1 b=2]).argv.last(5)).to eq(%w[--retries 2 --input a=1 b=2])
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it 'adds --notify before the inputs when the schedule notifies' do
|
|
113
|
+
expect(build(notify: true, retries: 1, inputs: %w[a=1]).argv.last(5)).to eq(%w[--retries 1 --notify --input a=1])
|
|
114
|
+
expect(build.argv).not_to include('--notify')
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it 'refuses with a Flows::Error when bundler is not installed for this Ruby' do
|
|
118
|
+
[Gem::GemNotFoundException.new('no bundler'), Gem::MissingSpecError.new('bundler', '>= 0')].each do |error|
|
|
119
|
+
allow(Gem).to receive(:bin_path).with('bundler', 'bundle').and_raise(error)
|
|
120
|
+
|
|
121
|
+
expect(error_for).to start_with('Bundler not found for this Ruby')
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'runs in the project and logs to the flow folder' do
|
|
126
|
+
expect([build.working_directory, build.log_path]).to eq([dir, File.join(dir, '.raider/flows/report/scheduler.log')])
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it 'records when it was made' do
|
|
130
|
+
expect(build.created_at).to eq('2026-11-09T10:00:00Z')
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it 'leaves the given time as it was' do
|
|
134
|
+
local = Time.local(2026, 11, 9, 11, 0, 0)
|
|
135
|
+
offset = local.utc_offset
|
|
136
|
+
build(now: local)
|
|
137
|
+
|
|
138
|
+
expect([local.utc?, local.utc_offset]).to eq([false, offset])
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it 'is frozen' do
|
|
142
|
+
schedule = build(inputs: %w[a=1])
|
|
143
|
+
|
|
144
|
+
expect([schedule, schedule.inputs, schedule.gem_env]).to all(be_frozen)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
describe 'the env' do
|
|
149
|
+
let(:bin) { File.dirname(RbConfig.ruby) }
|
|
150
|
+
|
|
151
|
+
it 'captures GEM_HOME and GEM_PATH from the env Bundler started with' do
|
|
152
|
+
allow(Bundler).to receive(:original_env).and_return('GEM_HOME' => '/gems/home', 'GEM_PATH' => '/gems/home:/gems/more', 'OTHER' => 'x')
|
|
153
|
+
|
|
154
|
+
expect(build.env).to include('GEM_HOME' => '/gems/home', 'GEM_PATH' => '/gems/home:/gems/more', 'LANG' => 'en_US.UTF-8')
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
it 'leaves GEM_HOME and GEM_PATH out when they are not set' do
|
|
158
|
+
allow(Bundler).to receive(:original_env).and_return('GEM_HOME' => '', 'PATH' => '/x')
|
|
159
|
+
|
|
160
|
+
expect([build.gem_env, build.env.keys.sort]).to eq([{}, %w[LANG PATH]])
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
it 'reads ENV when Bundler is not loaded' do
|
|
164
|
+
hide_const('Bundler')
|
|
165
|
+
stub_const('ENV', { 'GEM_HOME' => '/env/home' })
|
|
166
|
+
|
|
167
|
+
expect(build.gem_env).to eq('GEM_HOME' => '/env/home')
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
it 'puts the ruby and bundle folders on PATH before the system ones, once each' do
|
|
171
|
+
folders = [bin, File.dirname(Gem.bin_path('bundler', 'bundle')), '/usr/bin', '/bin'].uniq
|
|
172
|
+
|
|
173
|
+
expect(build.env['PATH']).to eq(folders.join(File::PATH_SEPARATOR))
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
it 'refuses to build for a project folder with a control character in its path' do
|
|
178
|
+
odd = File.join(dir, "line\nbreak")
|
|
179
|
+
FileUtils.mkdir_p(odd)
|
|
180
|
+
project = RubyRaider::Flows::Project.new(odd)
|
|
181
|
+
|
|
182
|
+
expect { described_class.build(project: project, name: 'report', every: 'day', at: '08:00', now: now) }
|
|
183
|
+
.to raise_error(RubyRaider::Flows::Error, /Invalid project_root/)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
describe 'the yml shape' do
|
|
187
|
+
let(:schedule) { build(every: 'week', on: 'fri', retries: 1, inputs: %w[a=1]) }
|
|
188
|
+
|
|
189
|
+
it 'writes string keys' do
|
|
190
|
+
expect(schedule.to_h.keys).to eq(%w[name every at on retries inputs notify os_id ruby bundle gem_env created_at])
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
it 'reads back what it wrote' do
|
|
194
|
+
expect(described_class.from_h(schedule.to_h, dir).to_h).to eq(schedule.to_h)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
it 'refuses an entry whose os_id is not this project\'s, or not in lowercase hex' do
|
|
198
|
+
upper = schedule.os_id.sub(/\.(\h{10})\./) { ".#{Regexp.last_match(1).upcase}." }
|
|
199
|
+
|
|
200
|
+
expect { described_class.from_h(schedule.to_h, '/elsewhere') }.to raise_error(RubyRaider::Flows::Error, /os_id/)
|
|
201
|
+
expect { described_class.from_h(schedule.to_h.merge('os_id' => upper), dir) }.to raise_error(RubyRaider::Flows::Error, /os_id/)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
it 'refuses entries that were tampered with' do
|
|
205
|
+
bad = [{ 'every' => 'yearly' }, { 'os_id' => 'evil; id' }, { 'ruby' => 'ruby' }, { 'gem_env' => { 'RUBYOPT' => '-rx' } },
|
|
206
|
+
{ 'inputs' => ["a=\n"] }, { 'name' => nil }, { 'created_at' => 5 }]
|
|
207
|
+
|
|
208
|
+
bad.each do |change|
|
|
209
|
+
expect { described_class.from_h(schedule.to_h.merge(change), dir) }.to raise_error(RubyRaider::Flows::Error)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
it 'refuses a project root with a control character or invalid UTF-8' do
|
|
214
|
+
roots = ["/p/a\nb", "/p/a\tb", "/p/\e[2J", "/p/\xFF".dup.force_encoding(Encoding::UTF_8), 'relative']
|
|
215
|
+
|
|
216
|
+
roots.each do |root|
|
|
217
|
+
expect { described_class.from_h(schedule.to_h, root) }.to raise_error(RubyRaider::Flows::Error, /project_root/)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
it 'keeps notify, and reads an entry written before notify as off' do
|
|
222
|
+
notifying = build(notify: true)
|
|
223
|
+
|
|
224
|
+
expect(described_class.from_h(notifying.to_h, dir).notify).to be(true)
|
|
225
|
+
expect(described_class.from_h(schedule.to_h.except('notify'), dir).notify).to be(false)
|
|
226
|
+
expect { described_class.from_h(schedule.to_h.merge('notify' => 'yes'), dir) }.to raise_error(RubyRaider::Flows::Error, /notify/)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
it 'refuses a non-hash' do
|
|
230
|
+
expect { described_class.from_h('x', dir) }.to raise_error(RubyRaider::Flows::Error)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|