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,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbconfig'
4
+ require_relative 'rule'
5
+ require_relative 'selection'
6
+ require_relative 'project'
7
+
8
+ module RubyRaider
9
+ module Schedules
10
+ # One scheduled test run: the shared rule (Rule) plus which tests run and how (show_browser). The OS
11
+ # entry runs `raider schedule run NAME` only; the selection, retries, notify and show_browser are
12
+ # read from schedules.yml at run time. Immutable.
13
+ # :reek:TooManyInstanceVariables { max_instance_variables: 14 } -- the fields of a schedules.yml entry
14
+ class Schedule < Rule
15
+ KIND = Kind::TESTS
16
+ KEYS = %w[name every at on retries notify show_browser selection os_id ruby bundle gem_env created_at].freeze
17
+ LINUX = /linux/
18
+ NO_BROWSER_ON_LINUX = "Show the browser isn't available for scheduled runs on Linux"
19
+
20
+ attr_reader :show_browser, :selection
21
+
22
+ def self.kind = KIND
23
+
24
+ # Linux schedules run from systemd or cron, with no display: show_browser is refused there
25
+ def self.build(project:, name:, every:, at: nil, on: nil, retries: 0, notify: false, show_browser: false, selection: Selection.all,
26
+ now: Time.now, host_os: RbConfig::CONFIG['host_os'])
27
+ raise Error, NO_BROWSER_ON_LINUX if show_browser == true && host_os.match?(LINUX)
28
+
29
+ root = project.root
30
+ new(name: name, every: every, at: at, on: on, retries: retries, notify: notify, show_browser: show_browser, selection: selection,
31
+ os_id: os_id_for(root, name), ruby: RbConfig.ruby, bundle: bundle_path, gem_env: captured_gem_env,
32
+ project_root: root, created_at: now.getutc.iso8601)
33
+ end
34
+
35
+ # A schedules.yml entry; raises Error when it is not one this class wrote. A spec file that is gone
36
+ # still loads (selection.missing_file names it).
37
+ def self.from_h(hash, project_root, host_os: RbConfig::CONFIG['host_os'])
38
+ raise Error, 'A schedule entry must be a mapping' unless hash.is_a?(Hash)
39
+
40
+ fields = KEYS.to_h { |key| [key.to_sym, hash[key]] }
41
+ fields[:selection] = Selection.from_h(hash['selection'], root: project_root, host_os: host_os)
42
+ schedule = new(**fields, project_root: project_root)
43
+ # Strict: an entry copied from another project, or a moved project, is not this project's entry
44
+ raise Error, "Invalid os_id #{schedule.os_id.inspect} for #{schedule.name} in #{project_root}" unless schedule.os_id == os_id_for(project_root, schedule.name)
45
+
46
+ schedule
47
+ end
48
+
49
+ def initialize(name:, every:, at:, on:, retries:, selection:, os_id:, ruby:, bundle:, gem_env:, project_root:, created_at:, notify: false,
50
+ show_browser: false)
51
+ super()
52
+ @name = checked_name(name)
53
+ @every = checked_every(every)
54
+ @at = checked_at(at)
55
+ @on = checked_on(on)
56
+ @retries = checked_retries(retries)
57
+ @notify = checked_flag('notify', notify)
58
+ @show_browser = checked_flag('show_browser', show_browser)
59
+ @selection = checked_selection(selection)
60
+ @os_id = checked_os_id(os_id)
61
+ @ruby = checked_path('ruby', ruby)
62
+ @bundle = checked_path('bundle', bundle)
63
+ @gem_env = checked_gem_env(gem_env)
64
+ @project_root = checked_path('project_root', project_root)
65
+ @created_at = checked_text('created_at', created_at)
66
+ freeze
67
+ end
68
+
69
+ def to_h
70
+ { 'name' => name, 'every' => every, 'at' => at, 'on' => on, 'retries' => retries, 'notify' => notify, 'show_browser' => show_browser,
71
+ 'selection' => selection.to_h, 'os_id' => os_id, 'ruby' => ruby, 'bundle' => bundle, 'gem_env' => gem_env.dup, 'created_at' => created_at }
72
+ end
73
+
74
+ private
75
+
76
+ def checked_name(name)
77
+ return name.dup.freeze if name.is_a?(String) && KIND.valid_name?(name)
78
+
79
+ raise Error, "Invalid schedule name #{Schedules.printable(name.inspect)}: use 1 to 60 of a-z, 0-9 and _"
80
+ end
81
+
82
+ def checked_selection(selection)
83
+ return selection if selection.is_a?(Selection)
84
+
85
+ raise Error, 'Invalid selection: use a Selection'
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'store'
4
+ require_relative 'schedule'
5
+
6
+ module RubyRaider
7
+ module Schedules
8
+ # .raider/schedules/schedules.yml: the project's test schedules (Store with Schedule)
9
+ class ScheduleStore < Store
10
+ DIR = Schedule::KIND.dir
11
+ GITIGNORE_LINE = "#{DIR}/".freeze
12
+
13
+ def initialize(project)
14
+ super(project, Schedule)
15
+ end
16
+
17
+ # Appends .raider/schedules/ to the project's .gitignore (made when missing) unless that exact line is
18
+ # there; every byte already in the file is kept. The yml holds machine paths. true when it added it.
19
+ # Read and written through one handle that is not a link, so a linked .gitignore is never written through.
20
+ def ignore_in_git
21
+ File.open(gitignore, File::RDWR | File::APPEND | File::CREAT | NOFOLLOW | NONBLOCK, 0o644, binmode: true) do |file|
22
+ content = gitignore_text(file)
23
+ return false if ignored?(content)
24
+
25
+ prefix = content.empty? || content.end_with?("\n") ? '' : "\n"
26
+ file.write("#{prefix}#{GITIGNORE_LINE}\n")
27
+ end
28
+ true
29
+ rescue Errno::EACCES, Errno::EROFS
30
+ # A .gitignore we may not write is fine when it already has the line
31
+ return false if ignored?(File.open(gitignore, File::RDONLY | NOFOLLOW | NONBLOCK, binmode: true) { |file| gitignore_text(file) })
32
+
33
+ raise
34
+ rescue Errno::ELOOP, Errno::EMLINK
35
+ raise Error, ".gitignore is a link: add #{GITIGNORE_LINE} to it yourself"
36
+ rescue Errno::EISDIR
37
+ raise Error, '.gitignore must be a file'
38
+ end
39
+
40
+ private
41
+
42
+ def gitignore = project.path('.gitignore')
43
+
44
+ def gitignore_text(file)
45
+ raise Error, '.gitignore must be a file' unless file.stat.file?
46
+
47
+ file.read.to_s.b
48
+ end
49
+
50
+ def ignored?(content) = content.each_line.any? { |line| line.chomp == GITIGNORE_LINE }
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'rbconfig'
5
+ require_relative 'adapter'
6
+ require_relative 'next_run'
7
+ require_relative 'os_enumerator'
8
+ require_relative 'project'
9
+ require_relative 'run_log'
10
+ require_relative 'schedule'
11
+ require_relative 'schedule_store'
12
+ require_relative 'scheduler_steps'
13
+ require_relative 'selection'
14
+
15
+ module RubyRaider
16
+ module Schedules
17
+ # `raider schedule add | remove | list`: keeps the OS scheduler entries and .raider/schedules/schedules.yml
18
+ # in step (4.1's order). The OS entry is installed first and the yml written after, so the yml never names
19
+ # an entry the OS scheduler refused; remove takes the OS entry first, so a refusal keeps the yml entry to
20
+ # retry with. The OS entry is always built fresh (this ruby, bundle and gem env), never from the yml.
21
+ # :reek:TooManyInstanceVariables { max_instance_variables: 7 } -- the project, its store, the adapter, the streams, clock and OS
22
+ # :reek:TooManyConstants { max_constants: 7 } -- the kind, two time formats, the row keys and the list wording
23
+ class Scheduler
24
+ include SchedulerSteps
25
+
26
+ KIND = Schedule::KIND
27
+ TIME = '%a %H:%M'
28
+ LIST_TIME = '%a %-d %b %H:%M'
29
+ LAST = %w[status finished_at trigger counts].freeze
30
+ EMPTY = 'No schedules yet: raider schedule add <name> --every day --at 08:00'
31
+ UNLISTED = 'missing from schedules.yml'
32
+ RULE_KEYS = %w[every at on retries notify show_browser selection next_run_at].freeze
33
+
34
+ # The OS scheduler for this machine, for test schedules
35
+ def self.adapter(runner: Os::CommandRunner.new, host_os: RbConfig::CONFIG['host_os'])
36
+ Schedules.adapter(kind: KIND, runner: runner, host_os: host_os)
37
+ end
38
+
39
+ # adapter: nil picks the OS scheduler once add has checked everything else, so a refused command never touches it
40
+ def initialize(project, adapter: nil, store: ScheduleStore.new(project), out: $stdout, err: $stderr, now: -> { Time.now },
41
+ host_os: RbConfig::CONFIG['host_os'])
42
+ @project = project
43
+ @adapter = adapter
44
+ @store = store
45
+ @out = out
46
+ @err = err
47
+ @now = now
48
+ @host_os = host_os
49
+ end
50
+
51
+ # Adds the schedule, or replaces the one with its name. Every check runs before a folder, the yml or
52
+ # the OS scheduler is touched.
53
+ def add(name, every:, files: [], tag: nil, at: nil, on: nil, retries: 0, notify: false, show_browser: false)
54
+ checked_name(name)
55
+ now = @now.call
56
+ selection = Selection.parse(files: files, tag: tag, root: @project.root, host_os: @host_os)
57
+ schedule = Schedule.build(project: @project, name: name, every: every, at: at, on: on, retries: retries, notify: notify,
58
+ show_browser: show_browser, selection: selection, now: now, host_os: @host_os)
59
+ @project.check_show_browser! if schedule.show_browser
60
+ @project.check_raider_version!
61
+ current = adapter
62
+ schedule_dirs(name)
63
+ @store.writable!
64
+ old = @store.find(name)
65
+ current.install(schedule)
66
+ save(schedule)
67
+ ignore_in_git
68
+ @out.puts("#{old ? 'Replaced' : 'Scheduled'} #{name}: #{schedule.rule_text}, next run #{NextRun.after(schedule, now).strftime(TIME)}")
69
+ end
70
+
71
+ def remove(name)
72
+ checked_name(name)
73
+ label = adapter.label
74
+ was_there = adapter.remove(Schedule.os_id_for(@project.root, name))
75
+ removed = delete(name, label)
76
+ raise Error, "No schedule #{name}" unless removed || was_there
77
+
78
+ @out.puts(was_there ? "Removed #{name}" : "Removed #{name} (it was already gone from #{label})")
79
+ @err.puts(unreadable_warning(name, label)) if removed.is_a?(Store::Removed)
80
+ end
81
+
82
+ # notice: the 4.1 cleanup's line (Legacy) for the JSON; as text it is already on stderr
83
+ def list(json: false, notice: nil)
84
+ now = @now.call
85
+ listed = @store.all
86
+ rows = listed.map { |schedule| row(schedule, now) } + unlisted_rows(listed.map(&:name))
87
+ return @out.puts(JSON.generate({ 'notice' => notice, 'schedules' => rows.map { |entry| entry[:json] } })) if json
88
+
89
+ return @out.puts(EMPTY) if rows.empty?
90
+
91
+ rows.each { |entry| @out.puts(Schedules.printable(entry[:text])) }
92
+ note = missed_runs_note
93
+ @out.puts(note) if note
94
+ end
95
+
96
+ private
97
+
98
+ attr_reader :store
99
+
100
+ def adapter = @adapter ||= Scheduler.adapter(host_os: @host_os)
101
+
102
+ def checked_name(name)
103
+ return if name.is_a?(String) && KIND.valid_name?(name)
104
+
105
+ raise Error, "Invalid schedule name #{Schedules.printable(name.inspect)}: use 1 to 60 of a-z, 0-9 and _"
106
+ end
107
+
108
+ # .raider/schedules/ and .raider/schedules/<name>/ (scheduler.log): 0700, no part of either a link
109
+ def schedule_dirs(name)
110
+ RunLog.private_dir(@project, KIND.dir)
111
+ RunLog.private_dir(@project, KIND.dir, name)
112
+ rescue SystemCallError => e
113
+ raise Error, "Could not make #{KIND.dir}/#{name}: #{e.message}"
114
+ end
115
+
116
+ # The schedule is installed and saved by now, so a .gitignore that can't take the line only warns
117
+ def ignore_in_git
118
+ @store.ignore_in_git
119
+ rescue Error, SystemCallError => e
120
+ @err.puts("Could not add #{ScheduleStore::GITIGNORE_LINE} to .gitignore: #{Schedules.printable(e.message)}")
121
+ end
122
+
123
+ def row(schedule, now)
124
+ name = schedule.name
125
+ next_run = NextRun.after(schedule, now)
126
+ state = os_state(schedule)
127
+ last = last_run(name)
128
+ json = { 'name' => name, 'every' => schedule.every, 'at' => schedule.at, 'on' => schedule.on, 'retries' => schedule.retries,
129
+ 'notify' => schedule.notify, 'show_browser' => schedule.show_browser, 'selection' => schedule.selection.to_h,
130
+ 'next_run_at' => next_run.iso8601, 'os_state' => state, 'last' => last }
131
+ { json: json, text: line(name, rule(schedule), "next #{next_run.strftime(LIST_TIME)}", last, state) }
132
+ end
133
+
134
+ # This project's OS entries that schedules.yml has no entry for, found by enumerating the OS scheduler
135
+ def unlisted_rows(listed)
136
+ (unlisted_names - listed).map do |name|
137
+ last = last_run(name)
138
+ json = { 'name' => name, **RULE_KEYS.to_h { |key| [key, nil] }, 'os_state' => UNLISTED, 'last' => last }
139
+ { json: json, text: line(name, '-', '-', last, UNLISTED) }
140
+ end
141
+ end
142
+
143
+ def unlisted_names
144
+ OsEnumerator.new(adapter, schedule_class: Schedule, root: @project.root).names
145
+ rescue Error
146
+ []
147
+ end
148
+
149
+ # The ruby first, then the spec files (no need to ask the OS about a schedule that can't run), then the OS
150
+ def os_state(schedule)
151
+ return RUBY_NOT_FOUND unless ruby_found?(schedule)
152
+
153
+ missing = schedule.selection.missing_file(@project.root)
154
+ return "test file not found: #{missing}" if missing
155
+
156
+ os_entry_state(schedule.os_id)
157
+ rescue Error => e
158
+ "unknown (#{Schedules.printable(e.message)})"
159
+ end
160
+
161
+ def rule(schedule)
162
+ selection = schedule.selection
163
+ which = case selection.kind
164
+ when 'tag' then "tag #{selection.tag}"
165
+ when 'files' then selection.files.size == 1 ? selection.files.first : "#{selection.files.size} files"
166
+ else 'all tests'
167
+ end
168
+ flags = [('notify' if schedule.notify), ('browser shown' if schedule.show_browser)].compact
169
+ "#{which}, #{schedule.rule_text}#{" (#{flags.join(', ')})" unless flags.empty?}"
170
+ end
171
+
172
+ def last_run(name)
173
+ last = RunLog.new(@project, name, dir: KIND.dir).last_result
174
+ last && LAST.to_h { |key| [key, last[key]] }
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module RubyRaider
6
+ module Schedules
7
+ # The steps every scheduler shares (4.1 shared them too): the yml written after the OS entry with a
8
+ # rollback, the yml entry removed after the OS one, and the parts of a list row that ask the OS.
9
+ # The includer gives `adapter` and `store`.
10
+ module SchedulerSteps
11
+ RUBY_NOT_FOUND = 'Ruby not found: re-schedule'
12
+
13
+ private
14
+
15
+ # Writes the yml; when that fails the OS entry just installed is removed, so neither names the schedule
16
+ def save(schedule)
17
+ store.save(schedule)
18
+ rescue Error, SystemCallError => e
19
+ label = adapter.label
20
+ begin
21
+ adapter.remove(schedule.os_id)
22
+ rescue Error => remove_error
23
+ raise Error, "#{e.message} (and removing the #{label} entry failed: #{remove_error.message})"
24
+ end
25
+ raise Error, "#{e.message}: the #{label} entry was removed too, so #{schedule.name} is not scheduled"
26
+ end
27
+
28
+ # The OS entry is gone by now: say so when the yml can't be written
29
+ def delete(name, label)
30
+ store.delete(name)
31
+ rescue Error, SystemCallError => e
32
+ raise Error, "#{e.message}: the #{label} entry was already removed, but schedules.yml still lists #{name}"
33
+ end
34
+
35
+ def unreadable_warning(name, label)
36
+ "The schedules.yml entry for #{name} could not be read: a #{label} entry made from a different project path may remain"
37
+ end
38
+
39
+ # The OS entry runs `<ruby> <bundle> ...`: ruby is executed, bundle is only read by it
40
+ def ruby_found?(schedule)
41
+ ruby = schedule.ruby
42
+ bundle = schedule.bundle
43
+ File.file?(ruby) && File.executable?(ruby) && File.file?(bundle) && File.readable?(bundle)
44
+ end
45
+
46
+ def os_entry_state(os_id) = adapter.present?(os_id) ? 'ok' : "missing from #{adapter.label}"
47
+
48
+ # An entry whose state can't be read is left out: only a listed schedule gets an unknown row
49
+ def present?(os_id)
50
+ adapter.present?(os_id)
51
+ rescue Error
52
+ false
53
+ end
54
+
55
+ # nil also when there is no OS scheduler to ask (the rows already say unknown)
56
+ def missed_runs_note
57
+ current = adapter
58
+ current.respond_to?(:missed_runs_note) ? current.missed_runs_note : nil
59
+ rescue Error
60
+ nil
61
+ end
62
+
63
+ def line(name, rule, next_run, last, state)
64
+ result = last ? "#{last['status']} #{last['finished_at']}" : 'never run'
65
+ "#{name.ljust(30)} #{rule} #{next_run} #{result} #{state}"
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbconfig'
4
+ require_relative 'base'
5
+ require_relative 'os/task_scheduler'
6
+
7
+ module RubyRaider
8
+ module Schedules
9
+ # Which tests a schedule runs: all of them, one tag, or listed spec files. Immutable.
10
+ # Checking is in two steps: the shape and every path's syntax (new, from_h: a schedules.yml entry still
11
+ # loads when a file was deleted), and whether each file is there (parse for add, check! for run). A file
12
+ # that is there but resolves outside the project is refused by both.
13
+ # :reek:MissingSafeMethod { exclude: [ check! ] } -- a check that raises; missing_file is its safe twin
14
+ # :reek:TooManyConstants { max_constants: 7 } -- the kinds, the tag and file rules, the cap, Windows
15
+ class Selection
16
+ KINDS = %w[all tag files].freeze
17
+ KEYS = %w[kind tag files].freeze
18
+ TAG = /\A[A-Za-z0-9_:-]+\z/
19
+ MAX_FILES = 200
20
+ WINDOWS = /mswin|mingw|cygwin/
21
+ FILE_RULE = 'use a path inside the project ending in _spec.rb, with no .. and no leading - or /'
22
+ WINDOWS_RULE = 'Task Scheduler and ruby.exe would expand %...%, *, ?, [ or { in it on Windows: rename the file or use a tag'
23
+
24
+ attr_reader :kind, :tag, :files
25
+
26
+ def self.all = new(kind: 'all')
27
+
28
+ # What `raider schedule add` was given: no files and no tag is all tests; every file must be there
29
+ def self.parse(files:, tag:, root:, host_os: RbConfig::CONFIG['host_os'])
30
+ raise Error, 'Invalid spec files: use a list of paths' unless files.is_a?(Array)
31
+ raise Error, 'Use --tag or spec files, not both' if !tag.nil? && !files.empty?
32
+
33
+ selection = if !tag.nil? then new(kind: 'tag', tag: tag)
34
+ elsif files.empty? then all
35
+ else new(kind: 'files', files: files, host_os: host_os)
36
+ end
37
+ selection.check!(root)
38
+ end
39
+
40
+ # The selection of a schedules.yml entry; a missing file still loads (missing_file names it)
41
+ def self.from_h(hash, root:, host_os: RbConfig::CONFIG['host_os'])
42
+ raise Error, 'Invalid selection: use kind all, tag or files' unless hash.is_a?(Hash) && (hash.keys - KEYS).empty?
43
+
44
+ selection = new(kind: hash['kind'], tag: hash['tag'], files: hash['files'], host_os: host_os)
45
+ selection.missing_file(root)
46
+ selection
47
+ end
48
+
49
+ def initialize(kind:, tag: nil, files: nil, host_os: RbConfig::CONFIG['host_os'])
50
+ raise Error, "Invalid selection kind #{kind.inspect}: use all, tag or files" unless KINDS.include?(kind)
51
+
52
+ @kind = kind.dup.freeze
53
+ @tag = kind == 'tag' ? checked_tag(tag) : nothing('tag', tag)
54
+ @files = kind == 'files' ? checked_files(files, host_os.match?(WINDOWS)) : nothing('files', files) || [].freeze
55
+ freeze
56
+ end
57
+
58
+ # The first listed file that is not in the project (nil when all are there); raises when one resolves
59
+ # outside the project. root is the project's real path.
60
+ def missing_file(root) = files.reject { |file| present?(root, file) }.first
61
+
62
+ # Itself when every file is there, otherwise raises
63
+ def check!(root)
64
+ missing = missing_file(root)
65
+ raise Error, "#{missing} not found" if missing
66
+
67
+ self
68
+ end
69
+
70
+ def to_h
71
+ case kind
72
+ when 'tag' then { 'kind' => kind, 'tag' => tag }
73
+ when 'files' then { 'kind' => kind, 'files' => files.dup }
74
+ else { 'kind' => kind }
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def nothing(label, value)
81
+ raise Error, "Invalid selection: #{label} is only for kind #{label == 'tag' ? 'tag' : 'files'}" unless value.nil?
82
+
83
+ nil
84
+ end
85
+
86
+ def checked_tag(tag)
87
+ return tag.dup.freeze if tag.is_a?(String) && tag.valid_encoding? && TAG.match?(tag)
88
+
89
+ raise Error, "Invalid tag #{Schedules.printable(tag.inspect)}: use letters, digits, _, : and -"
90
+ end
91
+
92
+ # Duplicates removed, order kept, then counted
93
+ def checked_files(files, windows)
94
+ raise Error, 'Invalid spec files: use a list of one or more paths' unless files.is_a?(Array) && !files.empty?
95
+
96
+ unique = files.map { |file| checked_file(file, windows) }.uniq
97
+ raise Error, "Too many spec files (#{unique.size}): select at most #{MAX_FILES}, or use a tag" if unique.size > MAX_FILES
98
+
99
+ unique.freeze
100
+ end
101
+
102
+ def checked_file(file, windows)
103
+ raise Error, "Invalid spec file #{Schedules.printable(file.inspect)}: #{FILE_RULE}" unless spec_path?(file, windows)
104
+ raise Error, "Invalid spec file #{file.inspect}: #{WINDOWS_RULE}" if windows && file.match?(Regexp.union(Os::TaskScheduler::EXPANDS, Os::TaskScheduler::GLOB))
105
+
106
+ file.dup.freeze
107
+ end
108
+
109
+ def spec_path?(file, windows)
110
+ file.is_a?(String) && file.valid_encoding? && !file.match?(/[[:cntrl:]]/) && file.end_with?('_spec.rb') && relative?(file, windows)
111
+ end
112
+
113
+ def relative?(file, windows)
114
+ return false if file.start_with?('-', '/') || File.absolute_path?(file)
115
+ return false if windows && file.match?(/\A(?:\\|[A-Za-z]:)/)
116
+
117
+ !file.split(windows ? %r{[/\\]} : '/').include?('..')
118
+ end
119
+
120
+ # true when the file is in the project, false when it is not there; raises when it resolves outside
121
+ def present?(root, file)
122
+ real = File.realpath(File.join(root, file))
123
+ raise Error, "#{file} points outside the project" unless real.start_with?("#{root}#{File::SEPARATOR}")
124
+
125
+ File.file?(real)
126
+ rescue Errno::ENOENT, Errno::ENOTDIR
127
+ false
128
+ rescue SystemCallError => e
129
+ raise Error, "#{file} can't be read: #{e.message}"
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+
5
+ module RubyRaider
6
+ module Schedules
7
+ # Runs one rspec process with an argv array and no shell, under the project's own bundle rather
8
+ # than the one raider runs in, and stops it the way a person would: with exactly one INT.
9
+ #
10
+ # rspec stops gracefully on its first INT (it finishes the example, prints the summary and runs at_exit
11
+ # hooks, where raider_reporter writes run.json) and force-quits on a second one. The child stays in the
12
+ # terminal's process group (it can read the terminal, and a closed terminal ends it), so a Ctrl-C typed
13
+ # there already reached it. Hence:
14
+ # - TERM is always passed on as one INT;
15
+ # - INT is passed on only with forward_int (true when the INT can't have come from the terminal, e.g.
16
+ # stdin is not a TTY); otherwise the child is taken to have had it;
17
+ # - a signal before the child exists is passed on once it does;
18
+ # - never more than one INT per run. On Windows the console already sent Ctrl-C to the child.
19
+ module Spawn
20
+ # [exit status (nil when it could not start or died of a signal), true when INT or TERM stopped it]
21
+ def self.call(env, argv, chdir, forward_int: true)
22
+ state = { pid: nil, stopped: false, interrupted: false }
23
+ code = run(env, argv, chdir, state, forward_int)
24
+ [code, state[:stopped] == true]
25
+ end
26
+
27
+ # The exit status, with the traps set from before the spawn (so a stop in between is not lost) to the end
28
+ def self.run(env, argv, chdir, state, forward_int)
29
+ previous = %w[INT TERM].to_h { |signal| [signal, trap(signal) { stop(state, signal, forward_int) }] }
30
+ wait = lambda do
31
+ pid = state[:pid] = Process.spawn(env, *argv, chdir: chdir)
32
+ interrupt(state) if state[:stopped]
33
+ Process.wait(pid)
34
+ # Reaped: its pid may be reused, so a later signal must not reach it
35
+ state[:pid] = nil
36
+ $CHILD_STATUS.exitstatus
37
+ end
38
+ defined?(Bundler) ? Bundler.with_unbundled_env(&wait) : wait.call
39
+ rescue SystemCallError => e
40
+ warn("Could not start #{argv.first}: #{e.message}")
41
+ nil
42
+ ensure
43
+ previous&.each { |signal, handler| trap(signal, handler || 'DEFAULT') }
44
+ end
45
+
46
+ # Called from the trap: marks the run stopped and passes the signal on as INT when the child needs it
47
+ def self.stop(state, signal, forward_int)
48
+ state[:stopped] = true
49
+ return unless state[:pid] # passed on right after the spawn
50
+
51
+ # A terminal Ctrl-C reached the child too: count it as the child's one INT
52
+ return state[:interrupted] = true if signal == 'INT' && !forward_int
53
+
54
+ interrupt(state)
55
+ end
56
+
57
+ # INT, at most once per run
58
+ def self.interrupt(state)
59
+ return if state[:interrupted] || Gem.win_platform?
60
+
61
+ state[:interrupted] = true
62
+ Process.kill('INT', state[:pid])
63
+ rescue SystemCallError
64
+ nil
65
+ end
66
+ end
67
+ end
68
+ end
@@ -3,15 +3,15 @@
3
3
  require 'fileutils'
4
4
  require 'securerandom'
5
5
  require 'yaml'
6
- require_relative 'schedule'
6
+ require_relative 'base'
7
7
 
8
8
  module RubyRaider
9
- module Flows
10
- # .raider/flows/schedules.yml: the project's scheduled flows, sorted by name. The OS entries are made
9
+ module Schedules
10
+ # <kind dir>/schedules.yml: a project's schedules of one kind, sorted by name. The OS entries are made
11
11
  # from it; it is written whole through a temp file and a rename, so a reader never sees half a file.
12
+ # A subclass passes the schedule class (a Rule with from_h, to_h and os_id_for); its Kind names the folder.
12
13
  # :reek:MissingSafeMethod { exclude: [ writable! ] } -- a check that raises, with no safe twin to offer
13
- class ScheduleStore
14
- DIR = '.raider/flows'
14
+ class Store
15
15
  FILE = 'schedules.yml'
16
16
  NOFOLLOW = defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0
17
17
  NONBLOCK = defined?(File::NONBLOCK) ? File::NONBLOCK : 0
@@ -27,9 +27,11 @@ module RubyRaider
27
27
  end
28
28
  end
29
29
 
30
- def initialize(project)
30
+ def initialize(project, schedule_class)
31
31
  @project = project
32
- @file = project.path("#{DIR}/#{FILE}")
32
+ @schedule_class = schedule_class
33
+ @dir = schedule_class.kind.dir
34
+ @file = project.path("#{@dir}/#{FILE}")
33
35
  end
34
36
 
35
37
  # Every valid entry, sorted by name; a broken entry or file is skipped
@@ -62,13 +64,15 @@ module RubyRaider
62
64
  return nil if named.empty?
63
65
 
64
66
  write(kept)
65
- named.filter_map { |entry| from_entry(entry) }.first || Removed.new(name, Schedule.os_id_for(@project.root, name))
67
+ named.filter_map { |entry| from_entry(entry) }.first || Removed.new(name, @schedule_class.os_id_for(@project.root, name))
66
68
  end
67
69
 
68
70
  private
69
71
 
72
+ attr_reader :project
73
+
70
74
  def from_entry(entry)
71
- Schedule.from_h(entry, @project.root)
75
+ @schedule_class.from_h(entry, @project.root)
72
76
  rescue Error
73
77
  nil
74
78
  end
@@ -92,7 +96,7 @@ module RubyRaider
92
96
 
93
97
  # The entries to rewrite, keeping ones this version can't read; refuses to overwrite a broken file
94
98
  def writable_entries
95
- read_entries || raise(Error, "#{DIR}/#{FILE} is not a schedules file: fix or remove it, then try again")
99
+ read_entries || raise(Error, "#{@dir}/#{FILE} is not a schedules file: fix or remove it, then try again")
96
100
  end
97
101
 
98
102
  # Read through one handle that is not a link; nil when there is no file, false when it is a link,
@@ -121,11 +125,11 @@ module RubyRaider
121
125
  FileUtils.rm_f(temp) if temp && File.exist?(temp)
122
126
  end
123
127
 
124
- # A 0700 folder inside the project that is not a link, as flows/support/run.rb makes it
128
+ # A 0700 folder inside the project that is not a link
125
129
  def private_dir
126
- dir = @project.path(DIR)
130
+ dir = @project.path(@dir)
127
131
  FileUtils.mkdir_p(dir)
128
- raise Error, "#{DIR} must be a folder, not a link" unless File.lstat(dir).directory?
132
+ raise Error, "#{@dir} must be a folder, not a link" unless File.lstat(dir).directory?
129
133
 
130
134
  File.chmod(0o700, dir)
131
135
  dir
data/lib/version CHANGED
@@ -1 +1 @@
1
- 4.1.0
1
+ 4.2.0