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,383 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'open3'
|
|
6
|
+
require 'time'
|
|
7
|
+
require 'timeout'
|
|
8
|
+
require 'tmpdir'
|
|
9
|
+
require_relative 'flow_project'
|
|
10
|
+
|
|
11
|
+
# Runs flows/support/run.rb and flows/flow_helper.rb, as `raider flow init` writes them, under a real
|
|
12
|
+
# rspec process (see FlowProject: no browser, no raider_reporter).
|
|
13
|
+
RSpec.describe 'flow run lifecycle' do
|
|
14
|
+
flows = {
|
|
15
|
+
'ok' => <<~RUBY,
|
|
16
|
+
require_relative 'flow_helper'
|
|
17
|
+
|
|
18
|
+
RSpec.describe 'ok' do
|
|
19
|
+
it 'runs every step' do
|
|
20
|
+
driver
|
|
21
|
+
puts "headless=\#{ENV.fetch('HEADLESS', 'unset')} who=\#{flow_input('who', 'nobody')}"
|
|
22
|
+
puts "secret loaded=\#{ENV['RAIDER_SECRET_TOKEN'] == 'hunter2'}"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
RUBY
|
|
26
|
+
'broken' => <<~RUBY,
|
|
27
|
+
require_relative 'flow_helper'
|
|
28
|
+
|
|
29
|
+
RSpec.describe 'broken' do
|
|
30
|
+
it 'downloads the report' do
|
|
31
|
+
driver
|
|
32
|
+
driver
|
|
33
|
+
raise 'no such element: { id: "export" } (waited 10s)'
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
RUBY
|
|
37
|
+
'recorded' => <<~RUBY,
|
|
38
|
+
require_relative 'flow_helper'
|
|
39
|
+
|
|
40
|
+
RSpec.describe 'recorded' do
|
|
41
|
+
it 'exports' do
|
|
42
|
+
raise 'no such element'
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
RUBY
|
|
46
|
+
'leaky' => <<~RUBY,
|
|
47
|
+
require_relative 'flow_helper'
|
|
48
|
+
|
|
49
|
+
RSpec.describe 'leaky' do
|
|
50
|
+
it "types \#{ENV.fetch('RAIDER_SECRET_TOKEN', 'x')} somewhere" do
|
|
51
|
+
raise 'failed'
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
RUBY
|
|
55
|
+
'env' => <<~RUBY,
|
|
56
|
+
require_relative 'flow_helper'
|
|
57
|
+
|
|
58
|
+
RSpec.describe('env') { it('runs') { puts 'ran' } }
|
|
59
|
+
RUBY
|
|
60
|
+
'long' => <<~RUBY,
|
|
61
|
+
require_relative 'flow_helper'
|
|
62
|
+
|
|
63
|
+
RSpec.describe 'long' do
|
|
64
|
+
it 'waits' do
|
|
65
|
+
File.write('ready', '')
|
|
66
|
+
sleep 20
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
RUBY
|
|
70
|
+
'unloadable' => <<~RUBY
|
|
71
|
+
require_relative 'flow_helper'
|
|
72
|
+
|
|
73
|
+
UndefinedConstant.call
|
|
74
|
+
RUBY
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let(:dir) { File.realpath(Dir.mktmpdir('flow-lifecycle')) }
|
|
78
|
+
|
|
79
|
+
before { FlowProject.build(dir, flows) }
|
|
80
|
+
|
|
81
|
+
after { FileUtils.rm_rf(dir) }
|
|
82
|
+
|
|
83
|
+
# Unsets the variables the test process may carry that would change a run
|
|
84
|
+
def clean_env
|
|
85
|
+
%w[HEADLESS RAIDER_FLOW_NAME RAIDER_FLOW_HEADED RAIDER_FLOW_ATTEMPT RAIDER_FLOW_TRIGGER RAIDER_FLOW_RUN_ID
|
|
86
|
+
RAIDER_SECRET_TOKEN RAIDER_INPUT_WHO FAKE_DRIVER_FAIL FAKE_STEPS].to_h { |key| [key, nil] }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def run_flow(name, env = {})
|
|
90
|
+
out, err, status = Open3.capture3(clean_env.merge(env), RbConfig.ruby, '-S', 'rspec', "flows/#{name}_flow.rb", chdir: dir)
|
|
91
|
+
[out + err, status.exitstatus]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def log(name)
|
|
95
|
+
file = File.join(dir, ".raider/flows/#{name}/runs.jsonl")
|
|
96
|
+
File.exist?(file) ? File.readlines(file).map { |line| JSON.parse(line) } : []
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def mode(relative) = File.stat(File.join(dir, relative)).mode & 0o777
|
|
100
|
+
|
|
101
|
+
def write_env_file(content, permissions)
|
|
102
|
+
FileUtils.mkdir_p(File.join(dir, '.raider/flows'))
|
|
103
|
+
File.write(File.join(dir, '.raider/flows/env'), content)
|
|
104
|
+
File.chmod(permissions, File.join(dir, '.raider/flows/env'))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'records a completed run with its results folder, headless by default', :aggregate_failures do
|
|
108
|
+
output, code = run_flow('ok', 'RAIDER_INPUT_WHO' => 'ada')
|
|
109
|
+
line = log('ok').last
|
|
110
|
+
|
|
111
|
+
expect(code).to eq(0)
|
|
112
|
+
expect(output).to include('headless=true who=ada')
|
|
113
|
+
expect(line).to include('status' => 'completed', 'attempt' => 1, 'trigger' => 'manual')
|
|
114
|
+
expect(line['results_dir']).to match(%r{\Aflow-results/ok/\d{8}T\d{6}Z\z})
|
|
115
|
+
expect(File).to be_directory(File.join(dir, line['results_dir']))
|
|
116
|
+
expect(Time.iso8601(line['finished_at'])).to be >= Time.iso8601(line['started_at'])
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it 'keeps the run log and lock private' do
|
|
120
|
+
run_flow('ok')
|
|
121
|
+
|
|
122
|
+
expect([mode('.raider/flows'), mode('.raider/flows/ok'), mode('.raider/flows/ok/runs.jsonl'), mode('.raider/flows/ok.lock')])
|
|
123
|
+
.to eq([0o700, 0o700, 0o600, 0o600])
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it 'opens the browser when RAIDER_FLOW_HEADED=1' do
|
|
127
|
+
output, = run_flow('ok', 'RAIDER_FLOW_HEADED' => '1')
|
|
128
|
+
|
|
129
|
+
expect(output).to include('headless=unset')
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it 'takes the attempt, trigger and run id from the wrapper' do
|
|
133
|
+
run_flow('ok', 'RAIDER_FLOW_ATTEMPT' => '2', 'RAIDER_FLOW_TRIGGER' => 'schedule', 'RAIDER_FLOW_RUN_ID' => 'abcdef0123456789')
|
|
134
|
+
|
|
135
|
+
expect(log('ok').last).to include('attempt' => 2, 'trigger' => 'schedule', 'run_id' => 'abcdef0123456789')
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it 'falls back to manual for an unknown trigger and drops a malformed run id' do
|
|
139
|
+
run_flow('ok', 'RAIDER_FLOW_TRIGGER' => 'cron; rm -rf /', 'RAIDER_FLOW_RUN_ID' => '../x')
|
|
140
|
+
|
|
141
|
+
expect(log('ok').last).to include('trigger' => 'manual').and(satisfy { |line| !line.key?('run_id') })
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it 'records a failed step with its line in the flow file' do
|
|
145
|
+
_output, code = run_flow('broken')
|
|
146
|
+
line = log('broken').last
|
|
147
|
+
|
|
148
|
+
expect(code).to eq(1)
|
|
149
|
+
expect(line).to include('status' => 'failed', 'failed_step' => { 'title' => 'downloads the report', 'line' => 7 })
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
it 'names the recorded step when the report has the desktop step map' do
|
|
153
|
+
run_flow('recorded', 'FAKE_STEPS' => '1')
|
|
154
|
+
|
|
155
|
+
expect(log('recorded').last['failed_step']).to eq('number' => 2, 'title' => 'click "Export"', 'line' => 5)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it 'records a setup error when the driver fails before the first step' do
|
|
159
|
+
_output, code = run_flow('ok', 'FAKE_DRIVER_FAIL' => '1')
|
|
160
|
+
|
|
161
|
+
expect(code).to eq(1)
|
|
162
|
+
expect(log('ok').last).to include('status' => 'setup_error').and(satisfy { |line| !line.key?('failed_step') })
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'records a setup error when the flow file does not load' do
|
|
166
|
+
_output, code = run_flow('unloadable')
|
|
167
|
+
|
|
168
|
+
expect(code).not_to eq(0)
|
|
169
|
+
expect(log('unloadable').last['status']).to eq('setup_error')
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it 'refuses a RAIDER_FLOW_NAME that is not the flow being run' do
|
|
173
|
+
output, code = run_flow('ok', 'RAIDER_FLOW_NAME' => 'other')
|
|
174
|
+
|
|
175
|
+
expect(code).not_to eq(0)
|
|
176
|
+
expect(output).to include('RAIDER_FLOW_NAME is "other" but the flow is "ok"')
|
|
177
|
+
expect(log('ok')).to be_empty
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
it 'reports into the run folder and never cleans raider-results' do
|
|
181
|
+
run_flow('ok')
|
|
182
|
+
reporter = JSON.parse(File.read(File.join(dir, 'reporter.json')))
|
|
183
|
+
|
|
184
|
+
expect(reporter).to eq('output_dir' => File.join(dir, log('ok').last['results_dir']), 'clean_output_dir' => false)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it 'keeps a refused flow away from raider-results too' do
|
|
188
|
+
run_flow('ok', 'RAIDER_FLOW_NAME' => 'other')
|
|
189
|
+
reporter = JSON.parse(File.read(File.join(dir, 'reporter.json')))
|
|
190
|
+
|
|
191
|
+
expect(reporter['clean_output_dir']).to be(false)
|
|
192
|
+
expect(reporter['output_dir']).to start_with(File.realpath(Dir.tmpdir)).or start_with(Dir.tmpdir)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it 'drops a HEADLESS left in the shell for RAIDER_FLOW_HEADED=1' do
|
|
196
|
+
output, = run_flow('ok', 'RAIDER_FLOW_HEADED' => '1', 'HEADLESS' => 'true')
|
|
197
|
+
|
|
198
|
+
expect(output).to include('headless=unset')
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it 'exits 3 with one busy line when another run holds the lock, and never waits' do
|
|
202
|
+
FileUtils.mkdir_p(File.join(dir, '.raider/flows'))
|
|
203
|
+
File.open(File.join(dir, '.raider/flows/ok.lock'), File::RDWR | File::CREAT, 0o600) do |held|
|
|
204
|
+
held.flock(File::LOCK_EX)
|
|
205
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
206
|
+
|
|
207
|
+
output, code = run_flow('ok')
|
|
208
|
+
|
|
209
|
+
expect(code).to eq(3)
|
|
210
|
+
expect(output).to include('ok is already running')
|
|
211
|
+
expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 30
|
|
212
|
+
end
|
|
213
|
+
expect(log('ok').map { |line| line['status'] }).to eq(['busy'])
|
|
214
|
+
expect(log('ok').last['results_dir']).to be_nil
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
it 'ends the step on the first Ctrl-C, ignores the second, quits the driver and records the run' do
|
|
218
|
+
pid = Process.spawn(clean_env, RbConfig.ruby, '-S', 'rspec', 'flows/long_flow.rb', chdir: dir, %i[out err] => File::NULL)
|
|
219
|
+
Timeout.timeout(30) { sleep 0.05 until File.exist?(File.join(dir, 'ready')) }
|
|
220
|
+
2.times do
|
|
221
|
+
Process.kill('INT', pid)
|
|
222
|
+
sleep 0.2
|
|
223
|
+
end
|
|
224
|
+
Timeout.timeout(15) { Process.wait(pid) }
|
|
225
|
+
|
|
226
|
+
expect(log('long').map { |line| line['status'] }).to eq(['setup_error'])
|
|
227
|
+
expect(File).to exist(File.join(dir, 'driver_quit'))
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
it 'releases the lock when the run ends' do
|
|
231
|
+
run_flow('ok')
|
|
232
|
+
_output, code = run_flow('ok')
|
|
233
|
+
|
|
234
|
+
expect(code).to eq(0)
|
|
235
|
+
expect(log('ok').map { |line| line['status'] }).to eq(%w[completed completed])
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
context 'with the flow env file' do
|
|
239
|
+
it 'loads a 0600 file without printing its values' do
|
|
240
|
+
write_env_file("# secrets\nRAIDER_SECRET_TOKEN=\"hunter2\"\n\nbad line\n", 0o600)
|
|
241
|
+
|
|
242
|
+
output, = run_flow('ok')
|
|
243
|
+
|
|
244
|
+
expect(output).to include('secret loaded=true', '.raider/flows/env line 4 ignored: expected KEY=value')
|
|
245
|
+
expect(output).not_to include('hunter2')
|
|
246
|
+
expect(File.read(File.join(dir, '.raider/flows/ok/runs.jsonl'))).not_to include('hunter2')
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
it 'ignores a 0644 file with a warning' do
|
|
250
|
+
write_env_file("RAIDER_SECRET_TOKEN=hunter2\n", 0o644)
|
|
251
|
+
|
|
252
|
+
output, code = run_flow('ok')
|
|
253
|
+
|
|
254
|
+
expect(code).to eq(0)
|
|
255
|
+
expect(output).to include('ignored .raider/flows/env: permissions must be 600', 'secret loaded=false')
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it 'ignores a link to a file elsewhere' do
|
|
259
|
+
target = File.join(dir, 'elsewhere')
|
|
260
|
+
File.write(target, "RAIDER_SECRET_TOKEN=hunter2\n")
|
|
261
|
+
File.chmod(0o600, target)
|
|
262
|
+
FileUtils.mkdir_p(File.join(dir, '.raider/flows'))
|
|
263
|
+
File.symlink(target, File.join(dir, '.raider/flows/env'))
|
|
264
|
+
|
|
265
|
+
output, = run_flow('ok')
|
|
266
|
+
|
|
267
|
+
expect(output).to include('ignored .raider/flows/env: it must be a regular file you own', 'secret loaded=false')
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
it 'does not override a variable already set' do
|
|
271
|
+
write_env_file("RAIDER_SECRET_TOKEN=hunter2\n", 0o600)
|
|
272
|
+
|
|
273
|
+
output, = run_flow('ok', 'RAIDER_SECRET_TOKEN' => 'from-shell')
|
|
274
|
+
|
|
275
|
+
expect(output).to include('secret loaded=false')
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
it 'keeps secret values out of the failed step title' do
|
|
279
|
+
write_env_file("RAIDER_SECRET_TOKEN=hunter2\n", 0o600)
|
|
280
|
+
|
|
281
|
+
run_flow('leaky')
|
|
282
|
+
|
|
283
|
+
expect(log('leaky').last['failed_step']['title']).to eq('types [secret] somewhere')
|
|
284
|
+
expect(File.read(File.join(dir, '.raider/flows/leaky/runs.jsonl'))).not_to include('hunter2')
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
context 'when something fails after the lock is taken' do
|
|
289
|
+
it 'still records the run when flow-results is not a folder' do
|
|
290
|
+
File.write(File.join(dir, 'flow-results'), '')
|
|
291
|
+
|
|
292
|
+
_output, code = run_flow('ok')
|
|
293
|
+
|
|
294
|
+
expect(code).not_to eq(0)
|
|
295
|
+
expect(log('ok').last).to include('status' => 'setup_error', 'results_dir' => nil)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
it 'skips an env line that is not UTF-8 and runs the flow' do
|
|
299
|
+
write_env_file("RAIDER_SECRET_TOKEN=hunter2\nBAD=\xFF\n".b, 0o600)
|
|
300
|
+
|
|
301
|
+
output, code = run_flow('ok')
|
|
302
|
+
|
|
303
|
+
expect(code).to eq(0)
|
|
304
|
+
expect(output).to include('.raider/flows/env line 2 ignored', 'secret loaded=true')
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
it 'refuses a flow named env, whose log folder would be the secrets file' do
|
|
309
|
+
output, code = run_flow('env')
|
|
310
|
+
|
|
311
|
+
expect(code).not_to eq(0)
|
|
312
|
+
expect(output).to include('invalid flow name "env"')
|
|
313
|
+
expect(File).not_to exist(File.join(dir, '.raider/flows/env'))
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
it 'leaves no lock, record or results for rspec --dry-run' do
|
|
317
|
+
Open3.capture3(clean_env, RbConfig.ruby, '-S', 'rspec', '--dry-run', 'flows/ok_flow.rb', chdir: dir)
|
|
318
|
+
|
|
319
|
+
expect([log('ok'), File.exist?(File.join(dir, 'flow-results'))]).to eq([[], false])
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
it 'never lets the env file set RAIDER_FLOW_* and clamps the attempt to 1' do
|
|
323
|
+
write_env_file("RAIDER_FLOW_TRIGGER=schedule\n", 0o600)
|
|
324
|
+
|
|
325
|
+
output, = run_flow('ok', 'RAIDER_FLOW_ATTEMPT' => '0')
|
|
326
|
+
|
|
327
|
+
expect(output).to include('.raider/flows/env line 1 ignored')
|
|
328
|
+
expect(log('ok').last).to include('trigger' => 'manual', 'attempt' => 1)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
it 'ignores a FIFO env file without waiting for a writer' do
|
|
332
|
+
FileUtils.mkdir_p(File.join(dir, '.raider/flows'))
|
|
333
|
+
File.mkfifo(File.join(dir, '.raider/flows/env'), 0o600)
|
|
334
|
+
|
|
335
|
+
output, code = Timeout.timeout(60) { run_flow('ok') }
|
|
336
|
+
|
|
337
|
+
expect(code).to eq(0)
|
|
338
|
+
expect(output).to include('ignored .raider/flows/env: it must be a regular file you own')
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
it 'redacts the longest secret first' do
|
|
342
|
+
write_env_file("RAIDER_SECRET_A=hunt\nRAIDER_SECRET_TOKEN=hunter2\n", 0o600)
|
|
343
|
+
|
|
344
|
+
run_flow('leaky')
|
|
345
|
+
|
|
346
|
+
expect(log('leaky').last['failed_step']['title']).to eq('types [secret] somewhere')
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
it 'redacts the value in use when the shell already set the key' do
|
|
350
|
+
write_env_file("RAIDER_SECRET_TOKEN=hunter2\n", 0o600)
|
|
351
|
+
|
|
352
|
+
run_flow('leaky', 'RAIDER_SECRET_TOKEN' => 'shell-secret')
|
|
353
|
+
|
|
354
|
+
expect(log('leaky').last['failed_step']['title']).to eq('types [secret] somewhere')
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
it 'never prunes through a linked flow-results folder' do
|
|
358
|
+
elsewhere = File.join(dir, 'elsewhere')
|
|
359
|
+
Array.new(25) { |i| FileUtils.mkdir_p(File.join(elsewhere, format('20200101T0000%02dZ', i))) }
|
|
360
|
+
FileUtils.mkdir_p(File.join(dir, 'flow-results'))
|
|
361
|
+
File.symlink(elsewhere, File.join(dir, 'flow-results/ok'))
|
|
362
|
+
|
|
363
|
+
run_flow('ok')
|
|
364
|
+
|
|
365
|
+
expect(Dir.children(elsewhere).grep(/\A2020/).size).to eq(25)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
it 'keeps the last 50 log lines and the last 20 results folders, and only stamp-named folders' do
|
|
369
|
+
FileUtils.mkdir_p(File.join(dir, '.raider/flows/ok'))
|
|
370
|
+
File.write(File.join(dir, '.raider/flows/ok/runs.jsonl'), Array.new(60) { |i| "#{JSON.generate('n' => i)}\n" }.join)
|
|
371
|
+
old = Array.new(25) { |i| format('flow-results/ok/20200101T0000%02dZ', i) }
|
|
372
|
+
(old + ['flow-results/ok/keep-me']).each { |folder| FileUtils.mkdir_p(File.join(dir, folder)) }
|
|
373
|
+
|
|
374
|
+
run_flow('ok')
|
|
375
|
+
|
|
376
|
+
lines = log('ok')
|
|
377
|
+
stamped = Dir.children(File.join(dir, 'flow-results/ok')).grep(/\A\d{8}T/)
|
|
378
|
+
expect([lines.size, lines.first['n'], lines.last['status']]).to eq([50, 11, 'completed'])
|
|
379
|
+
expect(stamped.size).to eq(20)
|
|
380
|
+
expect(stamped).to include(File.basename(lines.last['results_dir']))
|
|
381
|
+
expect(File).to be_directory(File.join(dir, 'flow-results/ok/keep-me'))
|
|
382
|
+
end
|
|
383
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tmpdir'
|
|
4
|
+
require_relative '../../../lib/utilities/flows/next_run'
|
|
5
|
+
|
|
6
|
+
# Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
|
|
7
|
+
RSpec.describe RubyRaider::Flows::NextRun do # rubocop:disable RSpec/FilePath
|
|
8
|
+
let(:dir) { File.realpath(Dir.mktmpdir('flows-next-run')) }
|
|
9
|
+
let(:project) { RubyRaider::Flows::Project.new(dir) }
|
|
10
|
+
|
|
11
|
+
around do |example|
|
|
12
|
+
zone = ENV.fetch('TZ', nil)
|
|
13
|
+
ENV['TZ'] = 'Europe/Copenhagen'
|
|
14
|
+
example.run
|
|
15
|
+
ensure
|
|
16
|
+
ENV['TZ'] = zone
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
after { FileUtils.rm_rf(dir) }
|
|
20
|
+
|
|
21
|
+
def rule(every, at = '08:00', on: nil)
|
|
22
|
+
RubyRaider::Flows::Schedule.build(project: project, name: 'report', every: every, at: at, on: on)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def next_after(schedule, *local)
|
|
26
|
+
described_class.after(schedule, Time.local(*local))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# [wall clock, UTC offset in hours] of a local time
|
|
30
|
+
def wall(time)
|
|
31
|
+
[time.strftime('%F %R'), time.utc_offset / 3600]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
describe 'every day' do
|
|
35
|
+
it 'runs later today, or tomorrow once the time has passed' do
|
|
36
|
+
expect([wall(next_after(rule('day'), 2026, 11, 9, 7, 59)), wall(next_after(rule('day'), 2026, 11, 9, 8, 0))]).to eq(
|
|
37
|
+
[['2026-11-09 08:00', 1], ['2026-11-10 08:00', 1]]
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'is strictly after now, by seconds too' do
|
|
42
|
+
expect(wall(next_after(rule('day'), 2026, 11, 9, 8, 0, 1))).to eq(['2026-11-10 08:00', 1])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'crosses a month and a year' do
|
|
46
|
+
expect(wall(next_after(rule('day'), 2026, 12, 31, 9, 0))).to eq(['2027-01-01 08:00', 1])
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
describe 'every weekday' do
|
|
51
|
+
it 'skips the weekend' do
|
|
52
|
+
# 2026-11-13 is a Friday
|
|
53
|
+
expect(wall(next_after(rule('weekday'), 2026, 11, 13, 9, 0))).to eq(['2026-11-16 08:00', 1])
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
describe 'every week' do
|
|
58
|
+
it 'runs on its day, a week later once that has passed' do
|
|
59
|
+
# 2026-11-09 is a Monday
|
|
60
|
+
expect([wall(next_after(rule('week', on: 'wed'), 2026, 11, 9, 9, 0)), wall(next_after(rule('week', on: 'mon'), 2026, 11, 9, 8, 0))]).to eq(
|
|
61
|
+
[['2026-11-11 08:00', 1], ['2026-11-16 08:00', 1]]
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
describe 'every hour' do
|
|
67
|
+
it 'runs at the next matching minute' do
|
|
68
|
+
expect([wall(next_after(rule('hour', ':15'), 2026, 11, 9, 10, 20)), wall(next_after(rule('hour', ':15'), 2026, 11, 9, 10, 14, 59))]).to eq(
|
|
69
|
+
[['2026-11-09 11:15', 1], ['2026-11-09 10:15', 1]]
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'crosses midnight' do
|
|
74
|
+
expect(wall(next_after(rule('hour', ':00'), 2026, 11, 9, 23, 30))).to eq(['2026-11-10 00:00', 1])
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Copenhagen: 2026-03-29 02:00 CET jumps to 03:00 CEST; 2026-10-25 03:00 CEST falls back to 02:00 CET
|
|
79
|
+
describe 'the last Sunday of March' do
|
|
80
|
+
it 'runs a time in the skipped hour at the first minute after the gap' do
|
|
81
|
+
expect(wall(next_after(rule('day', '02:30'), 2026, 3, 29, 0, 0))).to eq(['2026-03-29 03:00', 2])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'goes back to the normal time the next day' do
|
|
85
|
+
expect(wall(next_after(rule('day', '02:30'), 2026, 3, 29, 3, 0))).to eq(['2026-03-30 02:30', 2])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'keeps a time outside the gap' do
|
|
89
|
+
expect(wall(next_after(rule('day'), 2026, 3, 29, 0, 0))).to eq(['2026-03-29 08:00', 2])
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'runs every hour at the next real matching minute' do
|
|
93
|
+
expect(wall(next_after(rule('hour', ':30'), 2026, 3, 29, 1, 45))).to eq(['2026-03-29 03:30', 2])
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
describe 'the last Sunday of October' do
|
|
98
|
+
it 'runs a repeated time at its first occurrence' do
|
|
99
|
+
expect(wall(next_after(rule('day', '02:30'), 2026, 10, 25, 0, 0))).to eq(['2026-10-25 02:30', 2])
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it 'does not run again at the second occurrence' do
|
|
103
|
+
first = next_after(rule('day', '02:30'), 2026, 10, 25, 0, 0)
|
|
104
|
+
|
|
105
|
+
expect(wall(described_class.after(rule('day', '02:30'), first))).to eq(['2026-10-26 02:30', 1])
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'runs every hour in both repeated hours' do
|
|
109
|
+
first = next_after(rule('hour', ':30'), 2026, 10, 25, 1, 45)
|
|
110
|
+
second = described_class.after(rule('hour', ':30'), first)
|
|
111
|
+
|
|
112
|
+
expect([wall(first), wall(second), second - first]).to eq([['2026-10-25 02:30', 2], ['2026-10-25 02:30', 1], 3600.0])
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'runs a week rule on the changeover day' do
|
|
116
|
+
expect(wall(next_after(rule('week', '02:30', on: 'sun'), 2026, 10, 20, 12, 0))).to eq(['2026-10-25 02:30', 2])
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'stringio'
|
|
4
|
+
require_relative '../../../lib/utilities/flows/notifier'
|
|
5
|
+
|
|
6
|
+
# Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
|
|
7
|
+
RSpec.describe RubyRaider::Flows::Notifier do # rubocop:disable RSpec/FilePath
|
|
8
|
+
# Records each command instead of running it: no real osascript, notify-send or PowerShell
|
|
9
|
+
let(:runner) do
|
|
10
|
+
Class.new do
|
|
11
|
+
attr_reader :calls
|
|
12
|
+
attr_accessor :exitstatus
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@calls = []
|
|
16
|
+
@exitstatus = 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(*argv, env: {}, timeout: nil, **)
|
|
20
|
+
@calls << { argv: argv, env: env, timeout: timeout }
|
|
21
|
+
RubyRaider::Flows::Os::CommandRunner::Result.new(stdout: '', stderr: 'no notification server', exitstatus: exitstatus)
|
|
22
|
+
end
|
|
23
|
+
end.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
let(:err) { StringIO.new }
|
|
27
|
+
let(:hostile) do
|
|
28
|
+
["'; rm -rf ~; '", '" & do shell script "id" & "', '$(id)', '</text>', '-u critical', '$env:USERPROFILE', '`whoami`']
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
let(:powershell) { 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' }
|
|
32
|
+
|
|
33
|
+
def notifier(host_os) = described_class.new(runner: runner, host_os: host_os, err: err, powershell: powershell)
|
|
34
|
+
|
|
35
|
+
it 'passes the title and message to osascript as argv items of a fixed script on macOS' do
|
|
36
|
+
expect(notifier('darwin25').notify('Ruby Raider flow report', 'Failed at step 3 (Open report)')).to be(true)
|
|
37
|
+
|
|
38
|
+
expect(runner.calls).to eq([{ argv: ['/usr/bin/osascript', '-e', 'on run argv', '-e',
|
|
39
|
+
'display notification (item 2 of argv) with title (item 1 of argv)', '-e', 'end run', '--',
|
|
40
|
+
'Ruby Raider flow report', 'Failed at step 3 (Open report)'], env: {}, timeout: 15 }])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'ends osascript option parsing before the title, so -e in a title is not a script' do
|
|
44
|
+
notifier('darwin25').notify('-e', 'property p : (1 + 2)')
|
|
45
|
+
|
|
46
|
+
expect(runner.calls.first[:argv].last(3)).to eq(['--', '-e', 'property p : (1 + 2)'])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'keeps hostile text out of the script on every OS' do
|
|
50
|
+
hosts = %w[darwin25 linux-gnu mingw32]
|
|
51
|
+
scripts = hosts.to_h do |host|
|
|
52
|
+
hostile.each { |text| notifier(host).notify(text, text) }
|
|
53
|
+
calls = runner.calls.pop(hostile.size)
|
|
54
|
+
[host, calls.map { |call| host == 'mingw32' ? call[:argv] : call[:argv][0...-2] }.uniq]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
expect(scripts.values.map(&:size)).to eq([1, 1, 1])
|
|
58
|
+
expect(scripts.values.flatten.join("\n")).not_to include(*hostile)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'passes hostile text unchanged as the last argv items on macOS' do
|
|
62
|
+
hostile.each { |text| notifier('darwin25').notify(text, text) }
|
|
63
|
+
|
|
64
|
+
expect(runner.calls.map { |call| call[:argv].last(2) }).to eq(hostile.map { |text| [text, text] })
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'stops notify-send option parsing before the title and escapes markup in the message on Linux' do
|
|
68
|
+
notifier('linux-gnu').notify('-u critical', 'a < b & c > d')
|
|
69
|
+
|
|
70
|
+
expect(runner.calls.first[:argv]).to eq(['notify-send', '--app-name=Ruby Raider', '--', '-u critical', 'a < b & c > d'])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'passes the title and message to a fixed PowerShell script through the environment on Windows' do
|
|
74
|
+
notifier('mswin64').notify('$env:USERPROFILE', '`whoami`')
|
|
75
|
+
|
|
76
|
+
expect(runner.calls.first).to eq(
|
|
77
|
+
argv: [powershell, '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', described_class::TOAST_SCRIPT],
|
|
78
|
+
env: { 'RAIDER_NOTIFY_TITLE' => '$env:USERPROFILE', 'RAIDER_NOTIFY_MESSAGE' => '`whoami`' }, timeout: 15
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it 'finds PowerShell under SystemRoot by default' do
|
|
83
|
+
allow(ENV).to receive(:fetch).and_call_original
|
|
84
|
+
allow(ENV).to receive(:fetch).with('SystemRoot', 'C:\\Windows').and_return('D:\\Win')
|
|
85
|
+
described_class.new(runner: runner, host_os: 'mingw32', err: err).notify('t', 'm')
|
|
86
|
+
|
|
87
|
+
expect(runner.calls.first[:argv].first).to eq(File.join('D:\\Win', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it 'builds the toast from the environment, never from text joined into XML' do
|
|
91
|
+
script = described_class::TOAST_SCRIPT
|
|
92
|
+
|
|
93
|
+
expect(script).to include('$env:RAIDER_NOTIFY_TITLE', '$env:RAIDER_NOTIFY_MESSAGE', 'CreateTextNode', 'ToastText02')
|
|
94
|
+
expect(script).not_to include('"', "\n", 'EncodedCommand')
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'makes control characters printable and caps the message at 200 characters' do
|
|
98
|
+
notifier('darwin25').notify("flow\e[2J", "#{'x' * 250}\n")
|
|
99
|
+
|
|
100
|
+
expect(runner.calls.first[:argv].last(2)).to eq(['flow?[2J', 'x' * 200])
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it 'does nothing on another OS' do
|
|
104
|
+
expect([notifier('solaris2').notify('t', 'm'), runner.calls]).to eq([false, []])
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'returns false and warns once when the notification fails, without raising' do
|
|
108
|
+
runner.exitstatus = 1
|
|
109
|
+
sender = notifier('linux-gnu')
|
|
110
|
+
|
|
111
|
+
expect([sender.notify('t', 'm'), sender.notify('t', 'm')]).to eq([false, false])
|
|
112
|
+
expect(err.string).to eq("Could not show a desktop notification: no notification server\n")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'returns false when the runner raises' do
|
|
116
|
+
allow(runner).to receive(:call).and_raise(IOError, 'closed stream')
|
|
117
|
+
|
|
118
|
+
expect(notifier('darwin25').notify('t', 'm')).to be(false)
|
|
119
|
+
expect(err.string).to include('closed stream')
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rbconfig'
|
|
4
|
+
require_relative '../../../../lib/utilities/flows/os/command_runner'
|
|
5
|
+
|
|
6
|
+
# Flow specs live under spec/utilities/flows, next to the code in lib/utilities/flows
|
|
7
|
+
RSpec.describe RubyRaider::Flows::Os::CommandRunner do # rubocop:disable RSpec/FilePath
|
|
8
|
+
let(:runner) { described_class.new }
|
|
9
|
+
let(:ruby) { RbConfig.ruby }
|
|
10
|
+
|
|
11
|
+
it 'captures stdout, stderr and the exit status' do
|
|
12
|
+
result = runner.call(ruby, '-e', '$stdout.print "out"; $stderr.print "err"; exit 3')
|
|
13
|
+
|
|
14
|
+
expect([result.stdout, result.stderr, result.exitstatus, result.success?]).to eq(['out', 'err', 3, false])
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'succeeds on exit 0' do
|
|
18
|
+
expect(runner.call(ruby, '-e', 'exit 0').success?).to be(true)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'feeds stdin' do
|
|
22
|
+
expect(runner.call(ruby, '-e', 'print $stdin.read.upcase', stdin_data: "a b\n").stdout).to eq("A B\n")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'adds env values to the command environment' do
|
|
26
|
+
result = runner.call(ruby, '-e', 'print ENV["RAIDER_NOTIFY_TITLE"]', env: { 'RAIDER_NOTIFY_TITLE' => '$(id)' })
|
|
27
|
+
|
|
28
|
+
expect(result.stdout).to eq('$(id)')
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'stops a command that runs past the timeout' do
|
|
32
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
33
|
+
result = runner.call(ruby, '-e', 'sleep 10', timeout: 0.3)
|
|
34
|
+
|
|
35
|
+
expect([result.success?, result.stderr]).to eq([false, "#{ruby} took longer than 0.3 s"])
|
|
36
|
+
expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 5
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'passes arguments without a shell' do
|
|
40
|
+
hostile = ["'; rm -rf ~; '", '$(id)', '%h', '</string>']
|
|
41
|
+
|
|
42
|
+
expect(runner.call(ruby, '-e', 'print ARGV.join("|")', *hostile).stdout).to eq(hostile.join('|'))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'never runs a single string through a shell' do
|
|
46
|
+
result = runner.call('echo $(id)')
|
|
47
|
+
|
|
48
|
+
expect([result.success?, result.stdout]).to eq([false, ''])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'returns a failed result for a missing executable' do
|
|
52
|
+
result = runner.call('/no/such/raider-command', 'x')
|
|
53
|
+
|
|
54
|
+
expect([result.success?, result.exitstatus, result.stderr]).to match([false, 127, /raider-command/])
|
|
55
|
+
end
|
|
56
|
+
end
|