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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +59 -1
  3. data/RELEASE.md +12 -0
  4. data/lib/commands/flow_commands.rb +104 -0
  5. data/lib/generators/templates/flows/flow_helper.tt +42 -0
  6. data/lib/generators/templates/flows/run.tt +332 -0
  7. data/lib/ruby_raider.rb +16 -0
  8. data/lib/utilities/flows/next_run.rb +61 -0
  9. data/lib/utilities/flows/notifier.rb +76 -0
  10. data/lib/utilities/flows/os/command_runner.rb +73 -0
  11. data/lib/utilities/flows/os/cron.rb +137 -0
  12. data/lib/utilities/flows/os/launchd.rb +164 -0
  13. data/lib/utilities/flows/os/linux.rb +63 -0
  14. data/lib/utilities/flows/os/systemd.rb +178 -0
  15. data/lib/utilities/flows/os/task_scheduler.rb +179 -0
  16. data/lib/utilities/flows/project.rb +170 -0
  17. data/lib/utilities/flows/run_log.rb +61 -0
  18. data/lib/utilities/flows/runner.rb +206 -0
  19. data/lib/utilities/flows/schedule.rb +221 -0
  20. data/lib/utilities/flows/schedule_store.rb +135 -0
  21. data/lib/utilities/flows/scheduler.rb +187 -0
  22. data/lib/version +1 -1
  23. data/sig/commands/flow_commands.rbs +13 -0
  24. data/sig/ruby_raider.rbs +2 -0
  25. data/sig/utilities/flows/next_run.rbs +15 -0
  26. data/sig/utilities/flows/notifier.rbs +29 -0
  27. data/sig/utilities/flows/os/command_runner.rbs +27 -0
  28. data/sig/utilities/flows/os/cron.rbs +34 -0
  29. data/sig/utilities/flows/os/launchd.rbs +45 -0
  30. data/sig/utilities/flows/os/linux.rbs +27 -0
  31. data/sig/utilities/flows/os/systemd.rbs +43 -0
  32. data/sig/utilities/flows/os/task_scheduler.rbs +53 -0
  33. data/sig/utilities/flows/project.rbs +46 -0
  34. data/sig/utilities/flows/run_log.rbs +22 -0
  35. data/sig/utilities/flows/runner.rbs +35 -0
  36. data/sig/utilities/flows/schedule.rbs +61 -0
  37. data/sig/utilities/flows/schedule_store.rbs +34 -0
  38. data/sig/utilities/flows/scheduler.rbs +42 -0
  39. data/spec/commands/flow_commands_spec.rb +128 -0
  40. data/spec/generators/flow_helper_spec.rb +40 -0
  41. data/spec/integration/content/gemfile_content_spec.rb +2 -2
  42. data/spec/integration/end_to_end_spec.rb +63 -0
  43. data/spec/integration/flows/flow_project.rb +62 -0
  44. data/spec/integration/flows/run_command_spec.rb +91 -0
  45. data/spec/integration/flows/run_lifecycle_spec.rb +383 -0
  46. data/spec/utilities/flows/next_run_spec.rb +119 -0
  47. data/spec/utilities/flows/notifier_spec.rb +121 -0
  48. data/spec/utilities/flows/os/command_runner_spec.rb +56 -0
  49. data/spec/utilities/flows/os/cron_spec.rb +287 -0
  50. data/spec/utilities/flows/os/launchd_spec.rb +293 -0
  51. data/spec/utilities/flows/os/linux_spec.rb +149 -0
  52. data/spec/utilities/flows/os/systemd_spec.rb +329 -0
  53. data/spec/utilities/flows/os/task_scheduler_spec.rb +505 -0
  54. data/spec/utilities/flows/project_spec.rb +178 -0
  55. data/spec/utilities/flows/run_log_spec.rb +47 -0
  56. data/spec/utilities/flows/runner_spec.rb +313 -0
  57. data/spec/utilities/flows/schedule_spec.rb +233 -0
  58. data/spec/utilities/flows/schedule_store_spec.rb +137 -0
  59. data/spec/utilities/flows/scheduler_spec.rb +329 -0
  60. metadata +53 -2
@@ -0,0 +1,34 @@
1
+ module RubyRaider
2
+ module Flows
3
+ class ScheduleStore
4
+ DIR: String
5
+ FILE: String
6
+ NOFOLLOW: Integer
7
+ NONBLOCK: Integer
8
+
9
+ class Removed
10
+ attr_reader name: String
11
+ attr_reader os_id: String
12
+
13
+ def initialize: (String name, String os_id) -> void
14
+ end
15
+
16
+ def initialize: (Project project) -> void
17
+ def all: () -> Array[Schedule]
18
+ def find: (String name) -> Schedule?
19
+ def save: (Schedule schedule) -> Schedule
20
+ def writable!: () -> nil
21
+ def delete: (String name) -> (Schedule | Removed)?
22
+
23
+ private
24
+
25
+ def from_entry: (untyped entry) -> Schedule?
26
+ def named?: (untyped entry, String name) -> bool
27
+ def read_entries: () -> Array[untyped]?
28
+ def writable_entries: () -> Array[untyped]
29
+ def read_text: () -> (String | false)?
30
+ def write: (Array[untyped] entries) -> void
31
+ def private_dir: () -> String
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,42 @@
1
+ module RubyRaider
2
+ module Flows
3
+ class Scheduler
4
+ NOT_SET_UP: String
5
+ RUBY_NOT_FOUND: String
6
+ FLOW_NOT_FOUND: String
7
+ LAST: Array[String]
8
+ TIME: String
9
+
10
+ type row = { json: Hash[String, untyped], text: String }
11
+
12
+ @project: Project
13
+ @adapter: untyped
14
+ @store: ScheduleStore
15
+ @out: IO | StringIO
16
+ @err: IO | StringIO
17
+ @now: ^() -> Time
18
+
19
+ def self.adapter: (?runner: Os::CommandRunner, ?host_os: String) -> (Os::Launchd | Os::TaskScheduler | Os::Linux)
20
+
21
+ def initialize: (Project project, ?adapter: untyped, ?store: ScheduleStore, ?out: IO | StringIO, ?err: IO | StringIO, ?now: ^() -> Time) -> void
22
+ def schedule: (String name, every: String?, ?at: String?, ?on: String?, ?retries: untyped, ?inputs: Array[String], ?notify: bool) -> void
23
+ def unschedule: (String name) -> void
24
+ def schedules: (?json: bool) -> void
25
+
26
+ private
27
+
28
+ def adapter: () -> untyped
29
+ def save: (Schedule schedule) -> Schedule
30
+ def delete: (String name, String label) -> (Schedule | ScheduleStore::Removed)?
31
+ def flow_dir: (String name) -> void
32
+ def private_dir: (String relative) -> void
33
+ def row: (Schedule schedule, Time now) -> row
34
+ def unlisted_rows: (Array[String] listed) -> Array[row]
35
+ def present?: (String os_id) -> bool
36
+ def os_state: (Schedule schedule) -> String
37
+ def rule: (Schedule schedule) -> String
38
+ def last_run: (String name) -> Hash[String, untyped]?
39
+ def line: (String name, String rule, String next_run, Hash[String, untyped]? last, String state) -> String
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'tmpdir'
5
+ require_relative '../../lib/ruby_raider'
6
+
7
+ RSpec.describe FlowCommands do
8
+ let(:raider_bin) { File.expand_path('../../bin/raider', __dir__) }
9
+
10
+ def run_raider(*args)
11
+ Open3.capture3(RbConfig.ruby, raider_bin, *args)
12
+ end
13
+
14
+ it 'lists init, run and list in raider flow help' do
15
+ out, _err, status = run_raider('flow', 'help')
16
+
17
+ expect(status.exitstatus).to eq(0)
18
+ expect(out).to include('flow init', 'flow run NAME', 'flow list')
19
+ end
20
+
21
+ it 'maps raider f to the flow commands' do
22
+ out, _err, status = run_raider('f', 'help')
23
+
24
+ expect(status.exitstatus).to eq(0)
25
+ expect(out).to include('flow run NAME')
26
+ end
27
+
28
+ it 'is listed in raider help' do
29
+ out, = run_raider('help')
30
+
31
+ expect(out).to include('raider flow')
32
+ end
33
+
34
+ describe 'init' do
35
+ it 'sets up flows in the --project folder from any working directory' do
36
+ Dir.mktmpdir('flow-init') do |dir|
37
+ File.write(File.join(dir, 'Gemfile'), "gem 'ruby_raider', '~> 4.0'\ngem 'selenium-webdriver'\n")
38
+ FileUtils.mkdir_p(File.join(dir, 'helpers'))
39
+ File.write(File.join(dir, 'helpers/driver_helper.rb'), "require 'ruby_raider/runtime'\n")
40
+
41
+ out, _err, status = run_raider('flow', 'init', '--project', dir)
42
+
43
+ expect(status.exitstatus).to eq(0)
44
+ expect(out).to include('Created flows/flow_helper.rb', 'Run bundle install')
45
+ end
46
+ end
47
+
48
+ it 'exits 2 for a project that cannot hold flows' do
49
+ Dir.mktmpdir('flow-init') do |dir|
50
+ _out, err, status = run_raider('flow', 'init', '--project', dir)
51
+
52
+ expect(status.exitstatus).to eq(2)
53
+ expect(err).to include('Flows need a Selenium web project made with Ruby Raider 4.0 or later')
54
+ end
55
+ end
56
+
57
+ it 'exits 2 for a missing project folder' do
58
+ _out, err, status = run_raider('flow', 'init', '--project', '/nonexistent/raider')
59
+
60
+ expect(status.exitstatus).to eq(2)
61
+ expect(err).to include('Project folder not found')
62
+ end
63
+ end
64
+
65
+ it 'offers --notify on flow run and flow schedule' do
66
+ run_help, = run_raider('flow', 'help', 'run')
67
+ schedule_help, = run_raider('flow', 'help', 'schedule')
68
+
69
+ expect([run_help, schedule_help]).to all(include('--notify'))
70
+ end
71
+
72
+ describe 'schedules' do
73
+ # Only paths refused before the OS scheduler is asked: this machine's launchd is real
74
+ it 'lists schedule, unschedule and schedules in raider flow help' do
75
+ out, _err, status = run_raider('flow', 'help')
76
+
77
+ expect(status.exitstatus).to eq(0)
78
+ expect(out).to include('flow schedule NAME', 'flow unschedule NAME', 'flow schedules')
79
+ end
80
+
81
+ it 'dispatches raider flow schedule NAME to schedule, not schedules' do
82
+ Dir.mktmpdir('flow-schedule') do |dir|
83
+ _out, err, status = run_raider('flow', 'schedule', 'x', '--every', 'day', '--at', '08:00', '--project', dir)
84
+
85
+ expect(status.exitstatus).to eq(2)
86
+ expect(err).to include('No flow x: flows/x_flow.rb not found')
87
+ end
88
+ end
89
+
90
+ it 'exits 2 through the rule check when --every is left out' do
91
+ Dir.mktmpdir('flow-schedule') do |dir|
92
+ %w[flows/flow_helper.rb flows/support/run.rb flows/report_flow.rb].each do |file|
93
+ FileUtils.mkdir_p(File.dirname(File.join(dir, file)))
94
+ File.write(File.join(dir, file), '')
95
+ end
96
+ _out, err, status = run_raider('flow', 'schedule', 'report', '--at', '08:00', '--project', dir)
97
+
98
+ expect(status.exitstatus).to eq(2)
99
+ expect(err).to include('Invalid every nil: use hour, day, weekday or week')
100
+ end
101
+ end
102
+
103
+ it 'exits 2 for an invalid name to unschedule' do
104
+ Dir.mktmpdir('flow-schedule') do |dir|
105
+ _out, err, status = run_raider('flow', 'unschedule', 'Bad-Name', '--project', dir)
106
+
107
+ expect(status.exitstatus).to eq(2)
108
+ expect(err).to include('Invalid flow name "Bad-Name"')
109
+ end
110
+ end
111
+
112
+ it 'exits 2 with a message for an argument that is not valid UTF-8' do
113
+ _out, err, status = run_raider('flow', 'schedule', "a\xFF".b, '--every', 'day')
114
+
115
+ expect(status.exitstatus).to eq(2)
116
+ expect(err).to eq("Invalid argument \"a\\xFF\": use valid UTF-8\n")
117
+ end
118
+
119
+ it 'says there are no schedules, as text and as JSON' do
120
+ Dir.mktmpdir('flow-schedule') do |dir|
121
+ text, = run_raider('flow', 'schedules', '--project', dir)
122
+ json, = run_raider('flow', 'schedules', '--json', '--project', dir)
123
+
124
+ expect([text, json]).to eq(["No schedules yet: raider flow schedule <name> --every day --at 08:00\n", "[]\n"])
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+
5
+ # The flow templates are copied into projects by `raider flow init`
6
+ RSpec.describe 'flow templates' do
7
+ let(:templates) { File.expand_path('../../lib/generators/templates', __dir__) }
8
+ let(:helper) { render('flows/flow_helper.tt') }
9
+
10
+ def render(relative) = ERB.new(File.read(File.join(templates, relative)), trim_mode: '-').result
11
+
12
+ it 'starts the run before loading spec_helper, so the lock and HEADLESS come before the driver' do
13
+ expect(helper.index('RaiderFlows::Run.start!')).to be < helper.index("require_relative '../helpers/spec_helper'")
14
+ end
15
+
16
+ it 'sends the report to the flow results folder without cleaning it, even when start! refuses the run' do
17
+ expect(helper).to match(/begin\n RaiderFlows::Run.start!\nensure\n RaiderFlows::Run.configure_reporter\nend/)
18
+ expect(render('flows/run.tt')).to include('reporter.output_dir = results_dir ||', 'reporter.clean_output_dir = false')
19
+ end
20
+
21
+ it 'never retries a flow inside one run' do
22
+ expect(helper).to include('config.default_retry_count = 0')
23
+ end
24
+
25
+ it 'marks the start of the steps after spec_helper set up the driver' do
26
+ expect(helper).to include('config.append_before(:each) { RaiderFlows::Run.steps_started! }')
27
+ end
28
+
29
+ it 'never sets the reporter output folder from spec_helper, so a test run cannot wipe flow results' do
30
+ spec_helper = File.read(File.join(templates, 'helpers/spec_helper.tt'))
31
+
32
+ expect(spec_helper).not_to match(/output_dir|clean_output_dir/)
33
+ end
34
+
35
+ it 'renders Ruby that parses' do
36
+ [helper, render('flows/run.tt')].each do |source|
37
+ expect { RubyVM::InstructionSequence.compile(source) }.not_to raise_error
38
+ end
39
+ end
40
+ end
@@ -85,8 +85,8 @@ describe 'Gemfile content' do
85
85
  expect(gemfile).to include("gem 'ruby_raider', '~> #{major_minor}'")
86
86
  end
87
87
 
88
- it 'pins ruby_raider to the 4.0 release line' do
89
- expect(gemfile).to include("gem 'ruby_raider', '~> 4.0'")
88
+ it 'pins ruby_raider to the 4.1 release line' do
89
+ expect(gemfile).to include("gem 'ruby_raider', '~> 4.1'")
90
90
  end
91
91
  end
92
92
  end
@@ -447,6 +447,69 @@ describe 'End-to-End Project Generation and Execution' do
447
447
  end
448
448
  end
449
449
 
450
+ # raider flow init and raider flow run on a generated Selenium project, headless, against a local page
451
+ context 'Flows', :slow do
452
+ let(:project) { generate_video_project('rspec', 'selenium') }
453
+
454
+ def write_flows(project)
455
+ page = File.join(File.expand_path(project), 'flow_page.html')
456
+ File.write(page, <<~HTML)
457
+ <!doctype html><title>Timesheet</title>
458
+ <label for="hours">Hours</label><input id="hours">
459
+ <button onclick="document.getElementById('done').textContent = 'Saved ' + document.getElementById('hours').value">Save</button>
460
+ <p id="done"></p>
461
+ HTML
462
+ { 'timesheet' => "driver.find_element(label: 'Hours').send_keys(flow_input('hours', '8'))\n " \
463
+ "driver.find_element(by_role('button', name: 'Save')).click\n " \
464
+ "driver.find_element(text: \"Saved \#{flow_input('hours', '8')}\")",
465
+ 'export' => "driver.find_element(id: 'export').click" }.each do |name, steps|
466
+ File.write(File.join(project, "flows/#{name}_flow.rb"), <<~RUBY)
467
+ # frozen_string_literal: true
468
+
469
+ require_relative 'flow_helper'
470
+
471
+ RSpec.describe '#{name}' do
472
+ it 'runs' do
473
+ driver.navigate.to 'file://#{page}'
474
+ #{steps}
475
+ end
476
+ end
477
+ RUBY
478
+ end
479
+ end
480
+
481
+ def runs(project, name)
482
+ File.readlines(File.join(project, ".raider/flows/#{name}/runs.jsonl")).map { |line| JSON.parse(line) }
483
+ end
484
+
485
+ it 'runs a flow headless into flow-results and records completed and failed runs', :aggregate_failures do
486
+ skip 'Google Chrome is not installed' unless browser_installed?('chrome')
487
+ FileUtils.rm_rf([File.join(project, 'flows'), File.join(project, '.raider/flows'), File.join(project, 'flow-results')])
488
+
489
+ init = run_in_project(project, 'bundle exec raider flow init')
490
+ expect(init[:exit_code]).to eq(0), init[:stdout] + init[:stderr]
491
+ write_flows(project)
492
+ env = { 'VIDEO' => 'off', 'TRACE' => 'off', 'RAIDER_TOKEN' => nil, 'RAIDER_ENDPOINT' => nil }
493
+ completed = run_in_project(project, 'bundle exec raider flow run timesheet --input hours=7', env:)
494
+ failed = run_in_project(project, 'bundle exec raider flow run export', env: env.merge('RAIDER_TIMEOUT' => '1'))
495
+
496
+ expect(completed[:exit_code]).to eq(0), completed[:stdout] + completed[:stderr]
497
+ expect(completed[:stdout]).to match(%r{Flow timesheet completed in .* s\nResults: flow-results/timesheet/\d{8}T\d{6}Z})
498
+ expect(runs(project, 'timesheet').last).to include('status' => 'completed', 'trigger' => 'manual')
499
+ expect(failed[:exit_code]).to eq(1), failed[:stdout] + failed[:stderr]
500
+ expect(runs(project, 'export').last).to include('status' => 'failed', 'failed_step' => { 'title' => 'runs', 'line' => 8 })
501
+ expect(File).to exist(File.join(project, runs(project, 'export').last['results_dir'], 'run.json'))
502
+ expect(File).not_to exist(File.join(project, 'raider-results'))
503
+ end
504
+
505
+ it 'passes its own RuboCop with the flow files' do
506
+ FileUtils.rm_rf(File.join(project, 'flows'))
507
+ run_in_project(project, 'bundle exec raider flow init')
508
+
509
+ expect_project_rubocop_clean(project)
510
+ end
511
+ end
512
+
450
513
  # Trace replay against headless Chrome, and one example on headless Firefox. The page is served over HTTP from this process (never file://), so
451
514
  # WebDriver BiDi reports real network and console entries. raider_reporter ~> 1.0 resolves from RubyGems, or
452
515
  # from a checkout at RAIDER_REPORTER_PATH. The example with video on also needs RAIDER_FFMPEG_PATH.
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'fileutils'
5
+
6
+ # A project with the flow files `raider flow init` writes and a stand-in helpers/spec_helper.rb:
7
+ # no browser and no raider_reporter. FAKE_DRIVER_FAIL makes the driver fail in before(:each);
8
+ # FAKE_STEPS writes a run.json whose desktop step map names step 2 of flows/recorded_flow.rb.
9
+ module FlowProject
10
+ TEMPLATES = File.expand_path('../../../lib/generators/templates/flows', __dir__)
11
+
12
+ STUB_SPEC_HELPER = <<~RUBY
13
+ # frozen_string_literal: true
14
+
15
+ require 'json'
16
+ require 'rspec'
17
+
18
+ module RaiderReporter
19
+ Config = Struct.new(:output_dir, :clean_output_dir)
20
+ def self.configuration = (@configuration ||= Config.new(output_dir: 'raider-results', clean_output_dir: true))
21
+ def self.configure = yield(configuration)
22
+ end
23
+
24
+ # What the real reporter would write to and clean, for the specs to read
25
+ at_exit { File.write('reporter.json', JSON.generate(RaiderReporter.configuration.to_h)) }
26
+
27
+ module DriverHelper
28
+ def driver
29
+ raise 'the driver could not start' if ENV['FAKE_DRIVER_FAIL']
30
+
31
+ @driver ||= Object.new
32
+ end
33
+ end
34
+
35
+ RSpec.configure do |config|
36
+ config.include(DriverHelper)
37
+ config.before(:each) { driver }
38
+ config.after(:each) { File.write('driver_quit', '') }
39
+ # The real reporter writes run.json when it closes; the desktop sidecar names the failed step there
40
+ config.after(:suite) do
41
+ dir = RaiderReporter.configuration.output_dir
42
+ steps = { 'items' => [{ 'id' => 's1', 'title' => 'open' }, { 'id' => 's2', 'title' => 'click "Export"' }], 'failed' => 1 }
43
+ results = [{ 'file_path' => 'flows/recorded_flow.rb', 'status' => 'failed', 'steps' => steps }]
44
+ File.write(File.join(dir, 'run.json'), JSON.generate('results' => results)) if dir && ENV['FAKE_STEPS']
45
+ end
46
+ end
47
+ RUBY
48
+
49
+ module_function
50
+
51
+ def build(dir, flows)
52
+ FileUtils.mkdir_p(File.join(dir, 'flows/support'))
53
+ FileUtils.mkdir_p(File.join(dir, 'helpers'))
54
+ File.write(File.join(dir, 'flows/flow_helper.rb'), render('flow_helper.tt'))
55
+ File.write(File.join(dir, 'flows/support/run.rb'), render('run.tt'))
56
+ File.write(File.join(dir, 'helpers/spec_helper.rb'), STUB_SPEC_HELPER)
57
+ File.write(File.join(dir, '.rspec'), "-I .\n--require helpers/spec_helper\n")
58
+ flows.each { |name, source| File.write(File.join(dir, "flows/#{name}_flow.rb"), source) }
59
+ end
60
+
61
+ def render(name) = ERB.new(File.read(File.join(TEMPLATES, name)), trim_mode: '-').result
62
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'tmpdir'
6
+ require_relative 'flow_project'
7
+
8
+ # `raider flow run` and `raider flow list` against a project with its own Gemfile, from another folder.
9
+ RSpec.describe 'raider flow run and list' do
10
+ raider = File.expand_path('../../../bin/raider', __dir__)
11
+
12
+ flows = {
13
+ 'ok' => <<~RUBY,
14
+ require_relative 'flow_helper'
15
+
16
+ RSpec.describe 'ok' do
17
+ it('runs every step') { puts "who=\#{flow_input('who', 'nobody')} headless=\#{ENV.fetch('HEADLESS', 'unset')}" }
18
+ end
19
+ RUBY
20
+ 'broken' => <<~RUBY
21
+ require_relative 'flow_helper'
22
+
23
+ RSpec.describe 'broken' do
24
+ it 'downloads the report' do
25
+ raise 'no such element'
26
+ end
27
+ end
28
+ RUBY
29
+ }
30
+
31
+ # Built once: bundling the project is the slow part
32
+ dir = nil
33
+
34
+ before(:context) do # rubocop:disable RSpec/BeforeAfterAll
35
+ dir = File.realpath(Dir.mktmpdir('flow-command'))
36
+ FlowProject.build(dir, flows)
37
+ File.write(File.join(dir, 'Gemfile'), "source 'https://rubygems.org'\ngem 'rspec'\n")
38
+ _out, err, status = Bundler.with_unbundled_env do
39
+ Open3.capture3('bundle', 'install', '--prefer-local', '--quiet', chdir: dir)
40
+ end
41
+ raise "bundle install failed: #{err}" unless status.success?
42
+ end
43
+
44
+ after(:context) { FileUtils.rm_rf(dir) } # rubocop:disable RSpec/BeforeAfterAll
45
+
46
+ define_method(:raider_flow) do |*args|
47
+ out, err, status = Open3.capture3(RbConfig.ruby, raider, 'flow', *args, '--project', dir, chdir: Dir.tmpdir)
48
+ [out, err, status.exitstatus]
49
+ end
50
+
51
+ it 'runs a completed flow, passing inputs and headless through, and exits 0' do
52
+ out, _err, code = raider_flow('run', 'ok', '--input', 'who=ada')
53
+
54
+ expect(code).to eq(0)
55
+ expect(out).to include('who=ada headless=true', 'Flow ok completed in ')
56
+ expect(out).to match(%r{^Results: flow-results/ok/\d{8}T\d{6}Z(-\d+)?$})
57
+ end
58
+
59
+ it 'exits 1 and names the failed line' do
60
+ out, _err, code = raider_flow('run', 'broken')
61
+
62
+ expect(code).to eq(1)
63
+ expect(out).to include('Flow broken failed at line 5 (downloads the report)')
64
+ end
65
+
66
+ it 'exits 2 for a flow that does not exist' do
67
+ _out, err, code = raider_flow('run', 'missing')
68
+
69
+ expect(code).to eq(2)
70
+ expect(err).to include('No flow missing: flows/missing_flow.rb not found')
71
+ end
72
+
73
+ it 'lists every flow with its last result as JSON for the desktop' do
74
+ raider_flow('run', 'ok')
75
+ out, _err, code = raider_flow('list', '--json')
76
+ listed = JSON.parse(out)
77
+
78
+ expect(code).to eq(0)
79
+ expect(listed.map { |flow| flow['name'] }).to eq(%w[broken ok])
80
+ expect(listed.last).to include('file' => 'flows/ok_flow.rb')
81
+ expect(listed.last['last'].keys).to eq(%w[status finished_at failed_step results_dir])
82
+ expect(listed.last['last']['status']).to eq('completed')
83
+ end
84
+
85
+ it 'lists flows as text' do
86
+ raider_flow('run', 'ok')
87
+ out, = raider_flow('list')
88
+
89
+ expect(out).to match(/^ok\s+completed\s+\d{4}-\d\d-\d\dT/)
90
+ end
91
+ end