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,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require_relative '../../../lib/utilities/schedules/schedule'
5
+ require_relative '../../../lib/utilities/schedules/os/launchd'
6
+ require_relative '../../../lib/utilities/schedules/os/task_scheduler'
7
+ require_relative '../../../lib/utilities/schedules/os/systemd'
8
+ require_relative '../../../lib/utilities/schedules/os/cron'
9
+
10
+ # The OS entry of a test schedule holds its name only: the selection, retries, notify and show_browser
11
+ # are read from schedules.yml at run time, so no file name or tag can reach a plist, XML, unit or crontab.
12
+ RSpec.describe 'A test schedule OS entry' do
13
+ let(:tmp) { File.realpath(Dir.mktmpdir('schedules-entry')) }
14
+ let(:root) { File.join(tmp, 'project') }
15
+ let(:runner) { instance_double(RubyRaider::Schedules::Os::CommandRunner) }
16
+ let(:now) { Time.utc(2026, 11, 9, 10, 0, 0) }
17
+ let(:hostile) { %q(spec/$(id) & <x> "q" 'p' %PATH% `w`; rm -rf ~_spec.rb) }
18
+
19
+ before do
20
+ FileUtils.mkdir_p(File.join(root, 'spec'))
21
+ [hostile, 'spec/a_spec.rb'].each { |file| File.write(File.join(root, file), '') }
22
+ end
23
+
24
+ after { FileUtils.rm_rf(tmp) }
25
+
26
+ def schedule(**options)
27
+ project = RubyRaider::Schedules::Project.new(root)
28
+ RubyRaider::Schedules::Schedule.build(project: project, name: 'nightly', every: 'weekday', at: '07:30', now: now, host_os: 'darwin24', **options)
29
+ end
30
+
31
+ def kind = RubyRaider::Schedules::Kind::TESTS
32
+
33
+ def entries(item)
34
+ clock = Struct.new(:now).new(Time.new(2026, 11, 9, 10, 30, 0))
35
+ systemd = RubyRaider::Schedules::Os::Systemd.new(kind: kind, runner: runner, home: tmp, config_home: nil)
36
+ [RubyRaider::Schedules::Os::Launchd.new(kind: kind, runner: runner, home: tmp, uid: 501).plist(item),
37
+ RubyRaider::Schedules::Os::TaskScheduler.new(kind: kind, runner: runner, clock: clock, schtasks: 'C:\\schtasks.exe').task_xml(item),
38
+ systemd.service(item), systemd.timer(item), RubyRaider::Schedules::Os::Cron.new(kind: kind, runner: runner).line(item)]
39
+ end
40
+
41
+ it 'is byte-identical for two different selections, retries, notify and show_browser' do
42
+ files = RubyRaider::Schedules::Selection.parse(files: [hostile, 'spec/a_spec.rb'], tag: nil, root: root, host_os: 'darwin24')
43
+ all_tests = entries(schedule)
44
+ picked = entries(schedule(selection: files, retries: 3, notify: true, show_browser: true))
45
+ tagged = entries(schedule(selection: RubyRaider::Schedules::Selection.parse(files: [], tag: 'zz_tag_sentinel', root: root, host_os: 'darwin24')))
46
+
47
+ expect([picked, tagged]).to eq([all_tests, all_tests])
48
+ expect(all_tests.join).not_to include('rm -rf', '%PATH%', 'a_spec.rb', 'zz_tag_sentinel', '--retries', '--notify', 'show-browser', 'show_browser')
49
+ end
50
+
51
+ it 'names the test schedule kind and the schedule in every entry' do
52
+ expect(entries(schedule)).to all(include('nightly', 'schedule'))
53
+ end
54
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require_relative '../../../lib/utilities/schedules/os_enumerator'
5
+ require_relative '../../../lib/utilities/schedules/schedule'
6
+
7
+ RSpec.describe RubyRaider::Schedules::OsEnumerator do # rubocop:disable RSpec/FilePath
8
+ # Answers list_ids with what it was given, recording the prefix asked for
9
+ let(:fake_adapter) do
10
+ Class.new do
11
+ attr_reader :asked
12
+
13
+ def initialize(ids = [], error: nil)
14
+ @ids = ids
15
+ @error = error
16
+ end
17
+
18
+ def list_ids(prefix)
19
+ @asked = prefix
20
+ raise @error if @error
21
+
22
+ @ids
23
+ end
24
+ end
25
+ end
26
+
27
+ let(:root) { '/projects/shop' }
28
+ let(:prefix) { RubyRaider::Schedules::Schedule.os_id_prefix_for(root) }
29
+ let(:other) { RubyRaider::Schedules::Schedule.os_id_prefix_for('/projects/other') }
30
+ let(:legacy) { Class.new(RubyRaider::Schedules::Rule) { def self.kind = RubyRaider::Schedules::Kind::FLOW_LEGACY }.os_id_prefix_for(root) }
31
+
32
+ def enumerator(adapter) = described_class.new(adapter, schedule_class: RubyRaider::Schedules::Schedule, root: root)
33
+
34
+ it "asks for this project's prefix and returns the schedule names, sorted and once each" do
35
+ adapter = fake_adapter.new(["#{prefix}smoke", "#{prefix}nightly", "#{prefix}smoke"])
36
+
37
+ expect(enumerator(adapter).names).to eq(%w[nightly smoke])
38
+ expect(adapter.asked).to eq(prefix)
39
+ end
40
+
41
+ it "ignores what an adapter should not have returned: another project's, a flow's and hostile ids" do
42
+ hostile = ["#{other}nightly", "#{legacy}nightly", "#{prefix}../../x", "#{prefix}a;rm -rf ~", "#{prefix}Bad", nil, 42,
43
+ "#{prefix}ok\nx", "#{prefix}a\xFF".dup.force_encoding(Encoding::UTF_8), "#{prefix}#{'a' * 61}"]
44
+
45
+ expect(enumerator(fake_adapter.new(hostile + ["#{prefix}ok"])).names).to eq(%w[ok])
46
+ end
47
+
48
+ it 'is empty when the OS scheduler can not be read, or the adapter can not enumerate' do
49
+ failing = fake_adapter.new(error: RubyRaider::Schedules::Error.new('task list unreadable'))
50
+
51
+ expect([enumerator(failing).names, enumerator(Object.new).names, enumerator(fake_adapter.new(nil)).names]).to eq([[], [], []])
52
+ end
53
+
54
+ it 'lets an unexpected error through' do
55
+ expect { enumerator(fake_adapter.new(error: ArgumentError.new('bug'))).names }.to raise_error(ArgumentError, 'bug')
56
+ end
57
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'tmpdir'
5
+ require_relative '../../../lib/utilities/schedules/project'
6
+
7
+ RSpec.describe RubyRaider::Schedules::Project do # rubocop:disable RSpec/FilePath
8
+ let(:dir) { File.realpath(Dir.mktmpdir('raider-project')) }
9
+ let(:outside) { File.realpath(Dir.mktmpdir('raider-outside')) }
10
+ let(:project) { described_class.new(dir) }
11
+ let(:helper) { File.join(dir, 'helpers', 'driver_helper.rb') }
12
+ let(:runtime_helper) { "# frozen_string_literal: true\n\nrequire 'yaml'\nrequire 'ruby_raider/runtime'\n" }
13
+
14
+ before { FileUtils.mkdir_p(File.dirname(helper)) }
15
+
16
+ after { FileUtils.rm_rf([dir, outside]) }
17
+
18
+ describe '#runtime_driver?' do
19
+ it 'is true when helpers/driver_helper.rb requires the runtime' do
20
+ File.write(helper, runtime_helper)
21
+ expect(project.runtime_driver?).to be(true)
22
+ end
23
+
24
+ it 'is true for a helper with bytes that are not UTF-8' do
25
+ File.binwrite(helper, "# \xFF\xFE\n#{runtime_helper}")
26
+ expect(project.runtime_driver?).to be(true)
27
+ end
28
+
29
+ it 'is true when the helper links to a file inside the project' do
30
+ File.write(File.join(dir, 'real_helper.rb'), runtime_helper)
31
+ File.symlink(File.join(dir, 'real_helper.rb'), helper)
32
+ expect(project.runtime_driver?).to be(true)
33
+ end
34
+
35
+ it 'is false when the helper is missing, made before 4.0, or only mentions the runtime in a comment' do
36
+ results = [project.runtime_driver?]
37
+ File.write(helper, "require 'selenium-webdriver'\nrequire 'yaml'\n")
38
+ results << project.runtime_driver?
39
+ File.write(helper, "# require 'ruby_raider/runtime'\n")
40
+ results << project.runtime_driver?
41
+ expect(results).to eq([false, false, false])
42
+ end
43
+
44
+ it 'is false when the helper links out of the project' do
45
+ File.write(File.join(outside, 'driver_helper.rb'), runtime_helper)
46
+ File.symlink(File.join(outside, 'driver_helper.rb'), helper)
47
+ expect(project.runtime_driver?).to be(false)
48
+ end
49
+
50
+ it 'is false when helpers/ links out of the project' do
51
+ FileUtils.mkdir_p(File.join(outside, 'helpers'))
52
+ File.write(File.join(outside, 'helpers', 'driver_helper.rb'), runtime_helper)
53
+ FileUtils.rm_rf(File.dirname(helper))
54
+ File.symlink(File.join(outside, 'helpers'), File.dirname(helper))
55
+ expect(project.runtime_driver?).to be(false)
56
+ end
57
+
58
+ it 'is false when the helper is not a regular file' do
59
+ File.mkfifo(helper)
60
+ expect(project.runtime_driver?).to be(false)
61
+ end
62
+ end
63
+
64
+ describe '#check_show_browser!' do
65
+ it 'refuses a project made before Ruby Raider 4.0' do
66
+ File.write(helper, "require 'selenium-webdriver'\n")
67
+ expect { project.check_show_browser! }
68
+ .to raise_error(RubyRaider::Schedules::Error, 'Show the browser needs a project made with Ruby Raider 4.0 or later')
69
+ end
70
+
71
+ it 'accepts a project whose driver_helper requires the runtime' do
72
+ File.write(helper, runtime_helper)
73
+ expect { project.check_show_browser! }.not_to raise_error
74
+ end
75
+ end
76
+
77
+ describe '#check_raider_version!' do
78
+ own = RubyRaider::Schedules::Project::OWN_VERSION
79
+ needs = "Scheduling needs ruby_raider #{own} or later in this project"
80
+
81
+ def lock(*spec_lines)
82
+ File.write(File.join(dir, 'Gemfile'), "gem 'ruby_raider'\n")
83
+ File.write(File.join(dir, 'Gemfile.lock'), "GEM\n remote: https://rubygems.org/\n specs:\n#{spec_lines.join("\n")}\n\nDEPENDENCIES\n ruby_raider\n")
84
+ end
85
+
86
+ it 'reads this gem\'s version from lib/version' do
87
+ expect(own).to eq(File.read(File.expand_path('../../../lib/version', __dir__)).strip)
88
+ end
89
+
90
+ it 'accepts a lock at this version or later, from rubygems, a path or git' do
91
+ [' ruby_raider (%s)', ' ruby_raider (99.0.0)'].each do |line|
92
+ lock(format(line, own), ' selenium-webdriver (4.52.0)')
93
+ expect { project.check_raider_version! }.not_to raise_error
94
+ end
95
+ File.write(File.join(dir, 'Gemfile.lock'), "PATH\n remote: ../ruby_raider\n specs:\n ruby_raider (#{own})\n thor\n")
96
+ expect { project.check_raider_version! }.not_to raise_error
97
+ File.write(File.join(dir, 'Gemfile.lock'), "GIT\n remote: https://github.com/RaiderHQ/ruby_raider\n specs:\n ruby_raider (#{own})\n")
98
+ expect { project.check_raider_version! }.not_to raise_error
99
+ end
100
+
101
+ it 'reads a lock with Windows line endings' do
102
+ lock(" ruby_raider (#{own})")
103
+ File.write(File.join(dir, 'Gemfile.lock'), File.read(File.join(dir, 'Gemfile.lock')).gsub("\n", "\r\n"))
104
+ expect { project.check_raider_version! }.not_to raise_error
105
+ end
106
+
107
+ it 'refuses a lock that resolves an older ruby_raider, or none' do
108
+ lock(' ruby_raider (4.0.1)')
109
+ expect { project.check_raider_version!('4.2.0') }.to raise_error(RubyRaider::Schedules::Error, 'Scheduling needs ruby_raider 4.2.0 or later in this project: bundle update ruby_raider')
110
+ lock(' rspec (3.13.0)', ' ruby_raider (= 9.0.0)')
111
+ expect { project.check_raider_version! }.to raise_error(RubyRaider::Schedules::Error, "#{needs}: bundle update ruby_raider")
112
+ end
113
+
114
+ it 'refuses a project with no Gemfile, or no Gemfile.lock yet' do
115
+ expect { project.check_raider_version! }.to raise_error(RubyRaider::Schedules::Error, "#{needs}: no Gemfile found")
116
+ File.write(File.join(dir, 'Gemfile'), "gem 'ruby_raider'\n")
117
+ expect { project.check_raider_version! }.to raise_error(RubyRaider::Schedules::Error, "#{needs}: run bundle install first")
118
+ end
119
+
120
+ it 'never evaluates the lock' do
121
+ lock(" ruby_raider (\#{`id`})", " ruby_raider (\#{exit!})")
122
+ expect { project.check_raider_version! }.to raise_error(RubyRaider::Schedules::Error, "#{needs}: bundle update ruby_raider")
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'tmpdir'
5
+ require_relative '../../../lib/utilities/schedules/run_lock'
6
+ require_relative '../../../lib/utilities/schedules/project'
7
+
8
+ # The lock, its folder and running.json sit next to the project they belong to
9
+ # rubocop:disable RSpec/MultipleMemoizedHelpers
10
+ RSpec.describe RubyRaider::Schedules::RunLock do # rubocop:disable RSpec/FilePath
11
+ let(:dir) { File.realpath(Dir.mktmpdir('schedules-lock')) }
12
+ let(:project) { RubyRaider::Schedules::Project.new(dir) }
13
+ let(:lock) { described_class.new(project) }
14
+ let(:folder) { File.join(dir, '.raider/schedules') }
15
+ let(:running) { File.join(folder, 'running.json') }
16
+ let(:lock_file) { File.join(folder, 'run.lock') }
17
+ let(:started_at) { '2026-11-11T08:00:00.000Z' }
18
+
19
+ after do
20
+ lock.release
21
+ FileUtils.rm_rf(dir)
22
+ end
23
+
24
+ def acquire = lock.acquire(name: 'nightly', run_id: 'r1', started_at: started_at)
25
+
26
+ def dead_pid
27
+ pid = Process.spawn(RbConfig.ruby, '-e', 'exit 0')
28
+ Process.wait(pid)
29
+ pid
30
+ end
31
+
32
+ def write_running(pid)
33
+ FileUtils.mkdir_p(folder)
34
+ File.write(running, JSON.generate('pid' => pid, 'name' => 'other', 'run_id' => 'x', 'started_at' => started_at))
35
+ end
36
+
37
+ it 'takes the lock and writes running.json for the desktop, private' do
38
+ expect(acquire).to be(true)
39
+ expect(JSON.parse(File.read(running))).to eq('pid' => Process.pid, 'name' => 'nightly', 'run_id' => 'r1', 'started_at' => started_at)
40
+ expect([File.stat(running).mode & 0o777, File.stat(lock_file).mode & 0o777, File.stat(folder).mode & 0o777]).to eq([0o600, 0o600, 0o700])
41
+ end
42
+
43
+ it 'removes running.json and frees the lock on release, keeping run.lock' do
44
+ acquire
45
+ lock.release
46
+
47
+ expect([File.exist?(running), File.exist?(lock_file)]).to eq([false, true])
48
+ expect(described_class.new(project).acquire(name: 'nightly', run_id: 'r2', started_at: started_at)).to be(true)
49
+ end
50
+
51
+ it 'is busy while another run holds the lock, and writes nothing' do
52
+ FileUtils.mkdir_p(folder)
53
+ File.open(lock_file, File::RDWR | File::CREAT, 0o600) do |held|
54
+ held.flock(File::LOCK_EX)
55
+
56
+ expect(acquire).to be(false)
57
+ expect(File.exist?(running)).to be(false)
58
+ end
59
+ end
60
+
61
+ it 'is busy while running.json names a live process, and leaves that file alone' do
62
+ write_running(Process.ppid)
63
+ before = File.read(running)
64
+
65
+ expect(acquire).to be(false)
66
+ lock.release
67
+ expect(File.read(running)).to eq(before)
68
+ end
69
+
70
+ it 'replaces a running.json whose process is gone' do
71
+ write_running(dead_pid)
72
+
73
+ expect(acquire).to be(true)
74
+ expect(JSON.parse(File.read(running))['run_id']).to eq('r1')
75
+ end
76
+
77
+ [0, -1, 'abc', nil, Process.pid].each do |pid|
78
+ it "treats a running.json with pid #{pid.inspect} as stale" do
79
+ write_running(pid)
80
+
81
+ expect(acquire).to be(true)
82
+ end
83
+ end
84
+
85
+ it 'treats a running.json that is not JSON as stale' do
86
+ FileUtils.mkdir_p(folder)
87
+ File.write(running, 'not json')
88
+
89
+ expect(acquire).to be(true)
90
+ end
91
+
92
+ it 'never follows a linked running.json' do
93
+ FileUtils.mkdir_p(folder)
94
+ target = File.join(dir, 'target.json')
95
+ File.write(target, JSON.generate('pid' => Process.ppid))
96
+ File.symlink(target, running)
97
+
98
+ expect(acquire).to be(true)
99
+ expect([File.symlink?(running), File.read(target)]).to eq([false, JSON.generate('pid' => Process.ppid)])
100
+ end
101
+
102
+ it 'closes run.lock when it is not a regular file' do
103
+ FileUtils.mkdir_p(folder)
104
+ File.mkfifo(lock_file)
105
+ open_before = Dir.children('/dev/fd').size
106
+
107
+ expect { acquire }.to raise_error(RubyRaider::Schedules::Error, /run.lock must be a file/)
108
+ expect(Dir.children('/dev/fd').size).to eq(open_before)
109
+ end
110
+
111
+ it 'is busy, and keeps the new file, when running.json is replaced between the check and the removal' do
112
+ write_running(dead_pid)
113
+ real_rename = File.method(:rename)
114
+ allow(File).to receive(:rename) do |from, to|
115
+ real_rename.call(from, to)
116
+ # The desktop writes its own running.json right after ours moved the stale one aside
117
+ File.write(running, JSON.generate('pid' => Process.ppid, 'run_id' => 'desktop')) if to.end_with?('.stale') && !File.exist?(running)
118
+ end
119
+ seen = File.lstat(running)
120
+ allow(File).to receive(:lstat).and_call_original
121
+ allow(File).to receive(:lstat).with(a_string_ending_with('.stale')).and_return(instance_double(File::Stat, dev: seen.dev, ino: seen.ino + 1))
122
+
123
+ expect(acquire).to be(false)
124
+ expect([JSON.parse(File.read(running))['run_id'], Dir.children(folder).grep(/stale/)]).to eq(['desktop', []])
125
+ end
126
+
127
+ it 'refuses a linked run.lock' do
128
+ FileUtils.mkdir_p(folder)
129
+ File.write(File.join(dir, 'target.lock'), '')
130
+ File.symlink(File.join(dir, 'target.lock'), lock_file)
131
+
132
+ expect { acquire }.to raise_error(RubyRaider::Schedules::Error, /run.lock/)
133
+ end
134
+
135
+ it 'leaves a running.json that another run wrote after ours' do
136
+ acquire
137
+ File.write(running, JSON.generate('pid' => Process.ppid, 'run_id' => 'theirs'))
138
+ lock.release
139
+
140
+ expect(JSON.parse(File.read(running))['run_id']).to eq('theirs')
141
+ end
142
+ end
143
+ # rubocop:enable RSpec/MultipleMemoizedHelpers
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'tmpdir'
5
+ require_relative '../../../lib/utilities/schedules/run_log'
6
+ require_relative '../../../lib/utilities/schedules/project'
7
+
8
+ RSpec.describe RubyRaider::Schedules::RunLog do # rubocop:disable RSpec/FilePath
9
+ let(:dir) { File.realpath(Dir.mktmpdir('schedules-log')) }
10
+ let(:log) { described_class.new(RubyRaider::Schedules::Project.new(dir), 'nightly') }
11
+ let(:folder) { File.join(dir, '.raider/schedules/nightly') }
12
+ let(:file) { File.join(folder, 'runs.jsonl') }
13
+
14
+ after { FileUtils.rm_rf(dir) }
15
+
16
+ it 'appends one JSON line per run in a private folder' do
17
+ log.append({ 'run_id' => 'a', 'status' => 'passed' })
18
+ log.append({ 'run_id' => 'b', 'status' => 'failed' })
19
+
20
+ expect(File.readlines(file).map { |line| JSON.parse(line)['run_id'] }).to eq(%w[a b])
21
+ expect([File.stat(folder).mode & 0o777, File.stat(file).mode & 0o777]).to eq([0o700, 0o600])
22
+ expect(log.find('a')['status']).to eq('passed')
23
+ end
24
+
25
+ it 'keeps the last 50 lines' do
26
+ 52.times { |n| log.append({ 'run_id' => n.to_s }) }
27
+
28
+ ids = log.lines.map { |line| line['run_id'] }
29
+ expect([ids.size, ids.first, ids.last]).to eq([50, '2', '51'])
30
+ # No temp file left behind: only the log and its lock
31
+ expect(Dir.children(folder).sort).to eq(%w[runs.jsonl runs.jsonl.lock])
32
+ end
33
+
34
+ it 'waits for runs.jsonl.lock, so a busy line never overwrites a trim in progress' do
35
+ 50.times { |n| log.append({ 'run_id' => n.to_s }) }
36
+ File.open(File.join(folder, 'runs.jsonl.lock'), File::RDWR) do |held|
37
+ held.flock(File::LOCK_EX)
38
+ busy = Thread.new { log.append({ 'run_id' => 'busy' }) }
39
+
40
+ expect([busy.join(0.3), log.lines.size]).to eq([nil, 50])
41
+ held.flock(File::LOCK_UN)
42
+ busy.join
43
+ end
44
+
45
+ expect(log.lines.map { |line| line['run_id'] }.values_at(0, -1)).to eq(%w[1 busy])
46
+ end
47
+
48
+ it 'refuses a linked lock and leaves its target alone' do
49
+ FileUtils.mkdir_p(folder)
50
+ target = File.join(dir, 'elsewhere.lock')
51
+ File.symlink(target, File.join(folder, 'runs.jsonl.lock'))
52
+
53
+ expect { log.append({ 'run_id' => 'a' }) }.to raise_error(RubyRaider::Schedules::Error, /runs.jsonl.lock must be a file, not a link/)
54
+ expect([File.exist?(target), File.exist?(file)]).to eq([false, false])
55
+ end
56
+
57
+ it 'refuses a linked log and leaves its target alone' do
58
+ FileUtils.mkdir_p(folder)
59
+ target = File.join(dir, 'target.jsonl')
60
+ File.write(target, "keep\n")
61
+ File.symlink(target, file)
62
+
63
+ expect { log.append({ 'run_id' => 'a' }) }.to raise_error(RubyRaider::Schedules::Error, /runs.jsonl/)
64
+ expect(File.read(target)).to eq("keep\n")
65
+ end
66
+
67
+ it 'refuses a linked schedule folder' do
68
+ other = File.join(dir, 'elsewhere')
69
+ FileUtils.mkdir_p([other, File.join(dir, '.raider/schedules')])
70
+ File.symlink(other, folder)
71
+
72
+ expect { log.append({ 'run_id' => 'a' }) }.to raise_error(RubyRaider::Schedules::Error, /not a link/)
73
+ expect(Dir.children(other)).to eq([])
74
+ end
75
+
76
+ it 'reads a log of another kind from its own folder' do
77
+ flow_log = File.join(dir, '.raider/flows/report/runs.jsonl')
78
+ FileUtils.mkdir_p(File.dirname(flow_log))
79
+ File.write(flow_log, %({"status":"completed"}\n))
80
+
81
+ expect(described_class.new(RubyRaider::Schedules::Project.new(dir), 'report', dir: '.raider/flows').last['status']).to eq('completed')
82
+ end
83
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'tmpdir'
5
+ require_relative '../../../lib/utilities/schedules/run_report'
6
+ require_relative '../../../lib/utilities/schedules/project'
7
+
8
+ RSpec.describe RubyRaider::Schedules::RunReport do # rubocop:disable RSpec/FilePath
9
+ let(:dir) { File.realpath(Dir.mktmpdir('schedules-report')) }
10
+ let(:project) { RubyRaider::Schedules::Project.new(dir) }
11
+ let(:results) { File.join(dir, 'raider-results') }
12
+ let(:spawned_at) { Time.utc(2026, 11, 11, 8, 0, 0) }
13
+ let(:last) { File.join(dir, '.raider/schedules/nightly/last') }
14
+
15
+ before { FileUtils.mkdir_p(File.join(dir, '.raider/schedules/nightly')) }
16
+
17
+ after { FileUtils.rm_rf(dir) }
18
+
19
+ def write_run(statuses, started_at: '2026-11-11T08:00:01.000Z')
20
+ FileUtils.mkdir_p(results)
21
+ run = started_at ? { 'started_at' => started_at } : {}
22
+ File.write(File.join(results, 'run.json'), JSON.generate('run' => run, 'results' => statuses.map { |status| { 'status' => status } }))
23
+ end
24
+
25
+ def load = described_class.load(project, since: spawned_at)
26
+
27
+ it 'counts results: quarantined as failed, pending as skipped' do
28
+ write_run(%w[passed failed quarantined pending skipped passed])
29
+
30
+ expect(load.counts).to eq('total' => 6, 'passed' => 2, 'failed' => 2, 'skipped' => 2)
31
+ end
32
+
33
+ it 'is nil without a run.json' do
34
+ expect(load).to be_nil
35
+ end
36
+
37
+ it 'ignores a run.json left by an earlier run' do
38
+ write_run(%w[passed], started_at: '2026-11-11T07:00:00.000Z')
39
+
40
+ expect(load).to be_nil
41
+ end
42
+
43
+ it 'accepts a run that started up to 2 s before the spawn' do
44
+ write_run(%w[passed], started_at: '2026-11-11T07:59:58.500Z')
45
+
46
+ expect(load.counts['total']).to eq(1)
47
+ end
48
+
49
+ it 'falls back to the file time when run.json has no start time' do
50
+ write_run(%w[passed], started_at: nil)
51
+ file = File.join(results, 'run.json')
52
+ File.utime(spawned_at + 5, spawned_at + 5, file)
53
+ fresh = load
54
+
55
+ File.utime(spawned_at - 1, spawned_at - 1, file)
56
+ slack = load
57
+ File.utime(spawned_at - 60, spawned_at - 60, file)
58
+
59
+ expect([fresh&.counts&.fetch('total'), slack&.counts&.fetch('total'), load]).to eq([1, 1, nil])
60
+ end
61
+
62
+ it 'is nil for a run.json that is not a report' do
63
+ FileUtils.mkdir_p(results)
64
+ ['not json', '[1]', '{"results":"x"}'].each do |text|
65
+ File.write(File.join(results, 'run.json'), text)
66
+ expect(load).to be_nil
67
+ end
68
+ end
69
+
70
+ it 'never follows a linked run.json or results folder' do
71
+ other = File.join(dir, 'other')
72
+ FileUtils.mkdir_p(other)
73
+ File.write(File.join(other, 'run.json'), JSON.generate('run' => { 'started_at' => '2026-11-11T08:00:01Z' }, 'results' => []))
74
+ File.symlink(other, results)
75
+ linked_folder = load
76
+ FileUtils.rm(results)
77
+ FileUtils.mkdir_p(results)
78
+ File.symlink(File.join(other, 'run.json'), File.join(results, 'run.json'))
79
+
80
+ expect([linked_folder, load]).to eq([nil, nil])
81
+ end
82
+
83
+ describe '#copy_to' do
84
+ before do
85
+ write_run(%w[passed])
86
+ File.write(File.join(results, 'index.html'), '<html>')
87
+ FileUtils.mkdir_p(File.join(results, 'attachments/sub'))
88
+ File.write(File.join(results, 'attachments/0-shot.png'), 'png')
89
+ File.write(File.join(results, 'attachments/sub/trace.zip'), 'zip')
90
+ end
91
+
92
+ it 'copies run.json, index.html and the attachments, replacing the last copy' do
93
+ FileUtils.mkdir_p(last)
94
+ File.write(File.join(last, 'old.txt'), 'old')
95
+
96
+ expect(load.copy_to(last)).to be(true)
97
+ expect(Dir.glob('**/*', base: last).sort).to eq(%w[attachments attachments/0-shot.png attachments/sub attachments/sub/trace.zip index.html run.json])
98
+ expect(File.read(File.join(last, 'attachments/sub/trace.zip'))).to eq('zip')
99
+ expect(Dir.children(File.dirname(last))).to eq(['last'])
100
+ expect(File.stat(last).mode & 0o777).to eq(0o700)
101
+ end
102
+
103
+ it 'leaves out the attachments when there are too many files or folders too deep' do
104
+ stub_const("#{described_class}::MAX_ATTACHMENT_FILES", 1)
105
+ load.copy_to(last)
106
+ many = Dir.children(last).sort
107
+ FileUtils.rm_rf(last)
108
+ stub_const("#{described_class}::MAX_ATTACHMENT_FILES", 1000)
109
+ stub_const("#{described_class}::MAX_ATTACHMENT_DEPTH", 0)
110
+ load.copy_to(last)
111
+
112
+ expect([many, Dir.children(last).sort]).to eq([%w[index.html run.json], %w[index.html run.json]])
113
+ end
114
+
115
+ it 'copies no more of a file than was counted for it' do
116
+ target = File.join(dir, 'copy.png')
117
+ load.send(:copy_file, File.join(results, 'attachments/0-shot.png'), target, 2)
118
+
119
+ expect(File.read(target)).to eq('pn')
120
+ end
121
+
122
+ it 'leaves out the attachments when they are over the cap' do
123
+ stub_const("#{described_class}::MAX_ATTACHMENT_BYTES", 5)
124
+
125
+ load.copy_to(last)
126
+
127
+ expect(Dir.children(last).sort).to eq(%w[index.html run.json])
128
+ end
129
+
130
+ it 'copies regular files only, never what a link points at' do
131
+ secret = File.join(dir, 'secret.txt')
132
+ File.write(secret, 'secret')
133
+ File.symlink(secret, File.join(results, 'attachments/leak.txt'))
134
+ File.symlink(File.dirname(secret), File.join(results, 'attachments/leakdir'))
135
+ FileUtils.rm(File.join(results, 'index.html'))
136
+ File.symlink(secret, File.join(results, 'index.html'))
137
+
138
+ load.copy_to(last)
139
+
140
+ expect(Dir.glob('**/*', base: last).sort).to eq(%w[attachments attachments/0-shot.png attachments/sub attachments/sub/trace.zip run.json])
141
+ end
142
+
143
+ it 'leaves raider-results as it was' do
144
+ before = Dir.glob('**/*', base: results).sort.to_h { |path| [path, File.file?(File.join(results, path)) && File.read(File.join(results, path))] }
145
+ load.copy_to(last)
146
+
147
+ expect(Dir.glob('**/*', base: results).sort.to_h { |path| [path, File.file?(File.join(results, path)) && File.read(File.join(results, path))] }).to eq(before)
148
+ end
149
+ end
150
+ end