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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d516f44eddaa410a882fb64a08755338486c30d9eca4755f97c1716f5b5754ca
4
- data.tar.gz: de1a0fd5c654301482a09830072991d89793e117092bd23515b44e3e8f428c90
3
+ metadata.gz: df32b6a642777030f39db11a96cc9a85a5f59fce1528e9d096f2acd5e6bca3f9
4
+ data.tar.gz: 49713a619c80897c37b68e91c0c3b5acff9917fe9d850cbfde2fa6827345914b
5
5
  SHA512:
6
- metadata.gz: f580ff7a46dd54bc6c792d273045e1041e5bd29dceb302468f909661b4f52fbb837786765e3cb65d701526d7c3ab75aacbfa730ae677a0d8443448a292b667c7
7
- data.tar.gz: bf9a38d296a6b08ff3f2154e35168d75045431076c65a4a9ea376a4c3a76d928c163d5d5b840dc22ab7444c88e2021a7f66157b2760c84eb40a064a9941b98d6
6
+ metadata.gz: 02fea0f644525a60e0cbdf2fbac17194272480915cc57f486dade1c12f2ca6ecf64b021d120aaf25fa84a9bad085c5e1e958b1537e9f2e979361fbb0353aae65
7
+ data.tar.gz: 1da4cd4c6161a49663c9198f67847724edf1c7baa4c0988df86ea5c80361ad411af3ff83a0cdf9ed6e34a76d22208c782d44bd451e04c15d3d72bda582cb7186
data/README.md CHANGED
@@ -198,6 +198,63 @@ Example projects log in once and restore the saved session for examples tagged `
198
198
  - The state stays in git-ignored `.raider/auth/`.
199
199
  - Skeletons (`examples:false`) get `SessionState#reuse` to wire your own login.
200
200
 
201
+ ### Flows: run without assertions (web)
202
+
203
+ A flow is a recorded chore with no checks, such as logging in and downloading a report. It is done when every step ran. Flows are RSpec files under `flows/`, so they use the same driver, locators, waiting, screenshots, video and trace as your tests.
204
+
205
+ ```
206
+ raider flow init # adds flows/flow_helper.rb, flows/support/run.rb and .gitignore lines
207
+ raider flow run timesheet # runs flows/timesheet_flow.rb headless
208
+ raider flow run timesheet --headed --retries 2 --input hours=7
209
+ raider flow list [--json] # each flow and its last result
210
+ ```
211
+
212
+ ```ruby
213
+ # flows/timesheet_flow.rb
214
+ require_relative 'flow_helper'
215
+
216
+ RSpec.describe 'Fill the timesheet' do
217
+ it 'saves the hours' do
218
+ driver.navigate.to 'https://example.com/timesheet'
219
+ driver.find_element(label: 'Hours').send_keys(flow_input('hours', '8'))
220
+ driver.find_element(by_role('button', name: 'Save')).click
221
+ end
222
+ end
223
+ ```
224
+
225
+ - **Exit codes:** 0 completed, 1 a step failed, 2 setup failed (bundle, driver, missing helper), 3 the flow is already running. Each flow holds its own lock in `.raider/flows/`, and a second run exits 3 straight away.
226
+ - **Results:** each run gets its own `flow-results/<name>/<UTC time>/` (report, failure screenshot, video and trace), and `.raider/flows/<name>/runs.jsonl` gains one line: `started_at`, `finished_at`, `status` (`completed`, `failed`, `setup_error`, `busy`), `failed_step`, `attempt`, `trigger`, `results_dir`. The last 20 result folders and 50 lines are kept. Flow runs never touch `raider-results/`, and test runs never touch `flow-results/`.
227
+ - **Inputs:** `flow_input('name', 'default')` reads `--input name=value` (`RAIDER_INPUT_NAME`). Inputs show up in your shell history and process list, so keep secrets out of them.
228
+ - **Secrets:** put them in `.raider/flows/env` as `KEY=value` lines (`#` comments, no interpolation). The file is loaded only when you own it and it has permissions 600 (`chmod 600 .raider/flows/env`); otherwise the run warns and goes on without it. Values are never written to the run log (they are replaced with `[secret]`), but your flow's own output and errors are printed as they are. On Windows there are no permission bits, so the folder's access rules protect the file: keep the project in your user folder.
229
+ - **The same record every way:** `bundle exec rspec flows/timesheet_flow.rb` writes the same run log as `raider flow run`; `raider flow run` adds retries (whole flow, 30 s apart) and the exit codes. Set `RAIDER_FLOW_HEADED=1` to watch a plain rspec run (`headless: true` in `config/config.yml` still wins).
230
+ - Projects made with 4.0 can add flows: `raider flow init` raises the Gemfile's `ruby_raider` pin to `~> 4.1`; run `bundle install` after it.
231
+
232
+ ### Scheduling flows
233
+
234
+ Your computer's own scheduler runs the flow, so nothing needs to stay open. Scheduling never asks for sudo or admin rights.
235
+
236
+ ```bash
237
+ raider flow schedule timesheet --every weekday --at 17:30 --input hours=7 --notify
238
+ raider flow schedule backup --every hour --at :15
239
+ raider flow schedule report --every week --on fri --at 09:00 --retries 2
240
+ raider flow unschedule timesheet
241
+ raider flow schedules [--json] # each schedule, its next run, last result and OS state
242
+ ```
243
+
244
+ - **Rules:** `--every hour|day|weekday|week`. `--at HH:MM` (24h, local time) is required for `day`, `weekday` and `week`; for `hour` use `--at :MM` (default `:00`). `--on mon|tue|wed|thu|fri|sat|sun` is required for `week` and refused otherwise. `--retries N` (0 to 3) and `--input name=value` are passed to `raider flow run`. Scheduling a flow again replaces its schedule.
245
+ - **Schedulers:** macOS uses launchd (`~/Library/LaunchAgents/dev.rubyraider.flow.<hash>.<name>.plist`). Windows uses Task Scheduler (a task in the `\RubyRaider\` folder). Linux uses a systemd user timer in `~/.config/systemd/user`, or, when `systemctl --user` does not answer, one tagged line in your crontab. Times are local time (cron uses the system time zone).
246
+ - **Missed runs:** launchd runs a run missed while the Mac slept once on wake. Task Scheduler runs it as soon as it can. systemd (`Persistent=true`) runs it once your user manager is back, at the next boot or login. cron skips it, and `raider flow schedules` says so.
247
+ - **Logged in:** on macOS (a launchd agent in your GUI session) and Windows (a task that runs only while you are logged on) a run happens only while you are logged in.
248
+ - **Linux:** systemd user timers stop when you log out unless lingering is on: `loginctl enable-linger $USER` (raider does not run it). A systemd schedule can only be removed from a session where `systemctl --user` works; until then `unschedule` refuses and keeps the entry.
249
+ - **Per machine:** schedules live in `.raider/flows/schedules.yml`, which `raider flow init` git-ignores, so a fresh clone has none. schedules.yml is the source of truth, and the next run is worked out from the rule. Each OS entry is tied to the project's path: unschedule before you move the project.
250
+ - **States:** `ok`, `missing from launchd` (or `Task Scheduler`, `systemd`, `cron`) when the OS entry is gone, `missing from schedules.yml` when the OS still runs a flow the file does not list, `Ruby not found: re-schedule`, `flow not found: unschedule` when `flows/<name>_flow.rb` was deleted or renamed, and `unknown (...)` when the scheduler could not be asked.
251
+ - **Ruby:** the OS entry runs the absolute `ruby` and `bundle` paths captured when you scheduled, plus `GEM_HOME` and `GEM_PATH` on macOS and Linux. After a Ruby upgrade or move, `schedules` says `Ruby not found: re-schedule`: run `raider flow schedule` again.
252
+ - **Logs:** on macOS and Linux the scheduler's output goes to `.raider/flows/<name>/scheduler.log`. Windows tasks run ruby.exe directly with your user environment and keep no scheduler.log; each run's result is in `runs.jsonl` as always.
253
+ - **Notify:** `--notify` shows a desktop notification only when a run ends failed or could not start (not when it completed, was already running, or was stopped). macOS uses `osascript`, Linux `notify-send`, Windows a PowerShell toast. When a run could not start, the notification points at `.raider/flows/<name>/scheduler.log` for a scheduled run on macOS and Linux, and at the run output otherwise. A notification that can't be shown prints a warning and never changes the exit code. On Linux `notify-send` needs your desktop session bus: under cron, and in a systemd user session that has not imported it, it usually can't show, and only the warning lands in scheduler.log. `raider flow run --notify` does the same for a manual run.
254
+ - **Secrets:** keep them in `.raider/flows/env`, never in `--input`: inputs are stored in plain text in the OS entry and in schedules.yml.
255
+ - **Windows refusals:** Task Scheduler expands `%NAME%` with no way to escape it, so an input or path holding `%...%` is refused. An argument holding both `'` and one of `* ? [ {` is refused too, because ruby.exe would glob it.
256
+ - **Tested on:** macOS (launchd) is tested against the real scheduler. Task Scheduler, systemd and cron are covered by unit tests with a fake scheduler only.
257
+
201
258
  ### Also included
202
259
 
203
260
  - **Page Object Model:** base page and component classes with example pages
@@ -270,11 +327,12 @@ The skeleton keeps the helpers, config, CI and README, and its empty suite passe
270
327
  raider new [PROJECT_NAME] # Create a new framework project
271
328
  raider generate # Access scaffolding commands
272
329
  raider utility # Access utility commands
330
+ raider flow # Run flows: init, run, list, schedule
273
331
  raider version # Show current version
274
332
  raider help [COMMAND] # Describe available commands
275
333
  ```
276
334
 
277
- Shortcuts: `n` (new), `g` (generate), `u` (utility), `v` (version)
335
+ Shortcuts: `n` (new), `g` (generate), `u` (utility), `f` (flow), `v` (version)
278
336
 
279
337
  ### Scaffolding Commands
280
338
 
data/RELEASE.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## Release Notes
4
4
 
5
+ ### 4.1.0 (unreleased)
6
+
7
+ - Flows: recordings without assertions that run on their own. `raider flow init` adds `flows/flow_helper.rb` and `flows/support/run.rb` to a Selenium web project made with 4.0 or later (and raises a `~> 4.0` Gemfile pin to `~> 4.1`); mobile and 3.x projects are refused with exit 2.
8
+ - `raider flow run NAME` runs `flows/NAME_flow.rb` headless with `bundle exec rspec`, exiting 0 completed, 1 a step failed, 2 setup failed, 3 already running. `--headed`, `--retries N` (0 to 3, whole flow, 30 s apart), `--input name=value` (read with `flow_input`) and `--trigger manual|schedule|desktop`. `--project PATH` works from any folder.
9
+ - Each run takes a per-flow lock, writes its report, screenshot, video and trace to `flow-results/<name>/<UTC time>/` (never cleaned between runs, last 20 kept) and appends one line to `.raider/flows/<name>/runs.jsonl` (last 50 kept). Flow runs never touch `raider-results/`.
10
+ - Secrets for flows go in `.raider/flows/env` (`KEY=value`), loaded only from a file you own with permissions 600 and kept out of the run log.
11
+ - `raider flow list [--json]` shows each flow and its last result. `raider f` is short for `raider flow`.
12
+ - `raider flow schedule NAME --every hour|day|weekday|week [--at HH:MM | :MM] [--on mon..sun] [--retries N] [--input name=value] [--notify]` runs a flow with the computer's own scheduler: launchd on macOS, Task Scheduler (`\RubyRaider\` folder) on Windows, a systemd user timer on Linux, or a crontab line when `systemctl --user` does not answer. No sudo or admin rights. The entry stores the absolute ruby and bundle paths (and `GEM_HOME`/`GEM_PATH` on macOS and Linux) captured when you schedule.
13
+ - `raider flow unschedule NAME` removes the OS entry, then the `.raider/flows/schedules.yml` entry. `raider flow schedules [--json]` lists each schedule with its rule, next run, last result and OS state (`ok`, `missing from <scheduler>`, `missing from schedules.yml`, `Ruby not found: re-schedule`, `unknown`).
14
+ - `--notify` (on `schedule` and on `raider flow run`) shows a desktop notification when a run fails or can not start: osascript on macOS, notify-send on Linux, a PowerShell toast on Windows.
15
+ - Only launchd has been run for real (a scheduled run fired on macOS and was unscheduled cleanly). Task Scheduler, systemd and cron are covered by unit tests only: no real Windows or Linux run before this release.
16
+
5
17
  ### 4.0.1
6
18
 
7
19
  - Failure video works for web projects without `RAIDER_FFMPEG_PATH`: the RaiderHQ `ffmpeg-7.1` release is published and `RubyRaider::Ffmpeg::MANIFEST` pins the SHA-256 of its four binaries (macOS arm64 and x64, Linux x64, Windows x64). The first failing test, or `raider u video install`, downloads the one for your platform, checks it and caches it in `~/.cache/raider` (`%LOCALAPPDATA%\raider` on Windows).
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+ require 'json'
5
+ require_relative '../utilities/flows/runner'
6
+ require_relative '../utilities/flows/scheduler'
7
+
8
+ # raider flow: recordings without assertions that run on their own.
9
+ class FlowCommands < Thor
10
+ def self.exit_on_failure? = true
11
+
12
+ class_option :project, type: :string, desc: 'The project folder (default: the current folder)'
13
+
14
+ desc 'init', 'Adds flows/flow_helper.rb and the .gitignore lines to a Selenium web project'
15
+
16
+ def init
17
+ code = project.init
18
+ exit(code) unless code.zero?
19
+ rescue RubyRaider::Flows::Error => e
20
+ stop(e)
21
+ end
22
+
23
+ # `run` is a Thor reserved word, so the method has another name
24
+ desc 'run NAME', 'Runs flows/NAME_flow.rb headless with its own results folder and run log'
25
+ option :headed, type: :boolean, desc: 'Show the browser'
26
+ option :retries, type: :numeric, default: 0, desc: 'Re-run the whole flow up to N times (0-3), 30 s apart'
27
+ option :input, type: :array, desc: 'Flow inputs as name=value (read with flow_input)'
28
+ option :trigger, type: :string, default: 'manual', desc: 'manual, schedule or desktop'
29
+ option :notify, type: :boolean, desc: 'Show a desktop notification when the flow fails'
30
+
31
+ def run_flow(name)
32
+ runner = RubyRaider::Flows::Runner.new(project)
33
+ exit(runner.run(name, headed: options[:headed], retries: options[:retries], inputs: options[:input],
34
+ trigger: options[:trigger], notify: options[:notify] || false))
35
+ rescue RubyRaider::Flows::Error => e
36
+ stop(e)
37
+ end
38
+
39
+ map 'run' => :run_flow
40
+
41
+ desc 'list', 'Lists the flows with their last result'
42
+ option :json, type: :boolean, desc: 'Print JSON for the desktop app'
43
+
44
+ def list
45
+ summaries = RubyRaider::Flows::RunLog.summaries(project)
46
+ return puts(JSON.generate(summaries)) if options[:json]
47
+ return puts('No flows yet: write one in flows/<name>_flow.rb') if summaries.empty?
48
+
49
+ summaries.each { |flow| puts(list_line(flow)) }
50
+ rescue RubyRaider::Flows::Error => e
51
+ stop(e)
52
+ end
53
+
54
+ desc 'schedule NAME', 'Runs a flow on a schedule with the OS scheduler'
55
+ option :every, type: :string, desc: 'hour, day, weekday or week'
56
+ option :at, type: :string, desc: 'HH:MM (24h, local time); for every hour :MM'
57
+ option :on, type: :string, desc: 'mon to sun, for every week'
58
+ option :retries, type: :numeric, default: 0, desc: 'Re-run the whole flow up to N times (0-3)'
59
+ option :input, type: :array, desc: 'Flow inputs as name=value'
60
+ option :notify, type: :boolean, desc: 'Show a desktop notification when a scheduled run fails'
61
+
62
+ def schedule(name)
63
+ scheduler.schedule(name, every: options[:every], at: options[:at], on: options[:on], retries: options[:retries],
64
+ inputs: options[:input] || [], notify: options[:notify] || false)
65
+ rescue RubyRaider::Flows::Error => e
66
+ stop(e)
67
+ end
68
+
69
+ desc 'unschedule NAME', 'Removes the schedule of a flow'
70
+
71
+ def unschedule(name)
72
+ scheduler.unschedule(name)
73
+ rescue RubyRaider::Flows::Error => e
74
+ stop(e)
75
+ end
76
+
77
+ desc 'schedules', 'Lists the scheduled flows with their next run and last result'
78
+ option :json, type: :boolean, desc: 'Print JSON for the desktop app'
79
+
80
+ def schedules
81
+ scheduler.schedules(json: options[:json] || false)
82
+ rescue RubyRaider::Flows::Error => e
83
+ stop(e)
84
+ end
85
+
86
+ no_commands do
87
+ def project
88
+ RubyRaider::Flows::Project.new(options[:project] || Dir.pwd)
89
+ end
90
+
91
+ def scheduler = RubyRaider::Flows::Scheduler.new(project)
92
+
93
+ def list_line(flow)
94
+ last = flow['last']
95
+ result = last ? RubyRaider::Flows.printable("#{last['status']} #{last['finished_at']}") : 'never run'
96
+ "#{flow['name'].ljust(30)} #{result}"
97
+ end
98
+
99
+ def stop(error)
100
+ warn(error.message)
101
+ exit(error.exit_code)
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Every flow (flows/<name>_flow.rb) requires this file. A flow is an RSpec file without assertions:
4
+ # it completes when every step ran. Run one with `raider flow run <name>`, or `bundle exec rspec flows/<name>_flow.rb`.
5
+ # Generated by `raider flow init`.
6
+
7
+ require_relative 'support/run'
8
+
9
+ # Before the driver starts: take the flow's lock, pick its results folder, load .raider/flows/env, go headless.
10
+ # The report goes to this run's folder even when start! refuses the run, so a flow never cleans raider-results.
11
+ begin
12
+ RaiderFlows::Run.start!
13
+ ensure
14
+ RaiderFlows::Run.configure_reporter
15
+ end
16
+
17
+ require_relative '../helpers/spec_helper'
18
+
19
+ # Helpers available inside a flow.
20
+ module FlowHelper
21
+ # A value passed with `raider flow run <name> --input name=value`, or the recorded default.
22
+ def flow_input(name, default = nil)
23
+ key = name.to_s
24
+ raise ArgumentError, "invalid flow input name #{key.inspect}" unless RaiderFlows::Run::INPUT.match?(key)
25
+
26
+ ENV.fetch("RAIDER_INPUT_#{key.upcase}", default)
27
+ end
28
+ end
29
+
30
+ # Again now that spec_helper is surely loaded
31
+ RaiderFlows::Run.configure_reporter
32
+
33
+ RSpec.configure do |config|
34
+ config.include(FlowHelper)
35
+ # Flows don't touch spec/examples.txt, so `rspec --only-failures` stays about tests
36
+ config.example_status_persistence_file_path = nil
37
+ # A flow is not idempotent mid-way: never re-run it from step 1 inside one run (raider flow run --retries re-runs it whole)
38
+ config.default_retry_count = 0 if config.respond_to?(:default_retry_count=)
39
+ config.define_derived_metadata(file_path: %r{/flows/[^/]+_flow\.rb\z}) { |metadata| metadata[:flow] = true }
40
+ # Runs after spec_helper's hooks started the browser: a failure before this is a setup error, not a step
41
+ config.append_before(:each) { RaiderFlows::Run.steps_started! }
42
+ end
@@ -0,0 +1,332 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ require 'fileutils'
5
+ require 'json'
6
+ require 'time'
7
+ require 'tmpdir'
8
+
9
+ # One run of a flow: a per-flow lock, a results folder and one line in .raider/flows/<name>/runs.jsonl.
10
+ # flows/flow_helper.rb starts it, so `raider flow run`, plain rspec, the desktop app and a schedule all
11
+ # leave the same record. Generated by `raider flow init`.
12
+ module RaiderFlows
13
+ module Run
14
+ NAME = /\A[a-z0-9_]{1,60}\z/.freeze
15
+ RESERVED = %w[env].freeze # .raider/flows/env is the secrets file, not a flow's log folder
16
+ INPUT = /\A[a-z][a-z0-9_]{0,39}\z/.freeze
17
+ ENV_KEY = /\A[A-Z_][A-Z0-9_]*\z/.freeze
18
+ RUN_ID = /\A[0-9a-f]{16}\z/.freeze
19
+ RESULTS_STAMP = /\A\d{8}T\d{6}Z(?:-\d+)?\z/.freeze
20
+ TRIGGERS = %w[manual schedule desktop].freeze
21
+ KEEP_LINES = 50
22
+ KEEP_RESULTS = 20
23
+ MAX_RUN_JSON_BYTES = 10 * 1024 * 1024
24
+ MAX_ENV_BYTES = 64 * 1024
25
+ MIN_SECRET = 4
26
+ ENV_FILE = '.raider/flows/env'
27
+ NOFOLLOW = defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0
28
+ NONBLOCK = defined?(File::NONBLOCK) ? File::NONBLOCK : 0
29
+ ROOT = File.expand_path('../..', __dir__)
30
+
31
+ class << self
32
+ attr_reader :name, :results_dir
33
+
34
+ # At require time, before the driver or the reporter start. Exits 3 when the flow is already running.
35
+ def start!
36
+ # `rspec --dry-run` runs nothing, so it takes no lock and leaves no record
37
+ return if @name || RSpec.configuration.dry_run?
38
+
39
+ root = File.realpath(ROOT)
40
+ @name = flow_name
41
+ @root = root
42
+ @started_at = now
43
+ @secrets = []
44
+ busy! unless lock
45
+ stop_on_interrupt
46
+ # Registered as soon as the lock is held, so even a failure below leaves its line. It runs after
47
+ # RSpec and the reporter finished (run.json is written), whatever way the run ended.
48
+ at_exit { finish!(exit_status($ERROR_INFO)) }
49
+ @results_dir = new_results_dir
50
+ load_env_file
51
+ headless
52
+ end
53
+
54
+ # RSpec would finish the whole flow on a first Ctrl-C and exit! (no run log line) on a second. The first
55
+ # one ends the current step instead, so the run is recorded and spec_helper still quits the driver;
56
+ # a second one is ignored while that happens, and a third quits at once (no run log line).
57
+ def stop_on_interrupt
58
+ presses = 0
59
+ trap('INT') do
60
+ presses += 1
61
+ raise Interrupt, 'flow stopped' if presses == 1
62
+
63
+ presses == 2 ? say('stopping: press Ctrl-C again to quit at once') : exit!(130)
64
+ end
65
+ end
66
+
67
+ # RAIDER_FLOW_HEADED=1 drops a HEADLESS left in the shell; config.yml headless: true still wins
68
+ def headless
69
+ if ENV['RAIDER_FLOW_HEADED'] == '1'
70
+ ENV.delete('HEADLESS')
71
+ else
72
+ ENV['HEADLESS'] ||= 'true'
73
+ end
74
+ end
75
+
76
+ # Flow results go to flow-results/<name>/<UTC stamp>, never raider-results, and are never cleaned
77
+ def configure_reporter
78
+ return unless defined?(RaiderReporter)
79
+
80
+ RaiderReporter.configure do |reporter|
81
+ reporter.output_dir = results_dir || (@fallback_dir ||= Dir.mktmpdir('raider-flow-'))
82
+ reporter.clean_output_dir = false
83
+ end
84
+ end
85
+
86
+ def steps_started!
87
+ @steps_started = true
88
+ end
89
+
90
+ def finish!(code)
91
+ return unless @lock
92
+
93
+ append(record(code))
94
+ prune
95
+ rescue StandardError => e
96
+ say("could not write the run log: #{e.class}")
97
+ ensure
98
+ release
99
+ end
100
+
101
+ private
102
+
103
+ def now
104
+ Time.now.utc
105
+ end
106
+
107
+ def say(message)
108
+ $stderr.puts("[raider flow] #{message}") # rubocop:disable Style/StderrPuts -- -W0 must not hide these
109
+ end
110
+
111
+ def flow_name
112
+ flows = RSpec.configuration.files_to_run.select { |file| file.end_with?('_flow.rb') }
113
+ raise ArgumentError, 'run one flow at a time: rspec flows/<name>_flow.rb' unless flows.one?
114
+
115
+ name = File.basename(flows.first, '_flow.rb')
116
+ raise ArgumentError, "invalid flow name #{name.inspect}" unless NAME.match?(name) && !RESERVED.include?(name)
117
+
118
+ wanted = ENV.fetch('RAIDER_FLOW_NAME', name)
119
+ raise ArgumentError, "RAIDER_FLOW_NAME is #{wanted.inspect} but the flow is #{name.inspect}" unless wanted == name
120
+
121
+ name
122
+ end
123
+
124
+ def lock
125
+ file = File.join(private_dir('.raider/flows'), "#{name}.lock")
126
+ handle = File.open(file, File::RDWR | File::CREAT | NOFOLLOW, 0o600)
127
+ return @lock = handle if handle.flock(File::LOCK_EX | File::LOCK_NB)
128
+
129
+ handle.close
130
+ nil
131
+ end
132
+
133
+ def release
134
+ @lock&.close
135
+ @lock = nil
136
+ end
137
+
138
+ def busy!
139
+ append(base_line.merge(finished_at: iso(now), status: 'busy', results_dir: nil))
140
+ say("#{name} is already running")
141
+ exit 3
142
+ end
143
+
144
+ # A 0700 folder inside the project that is not a link
145
+ def private_dir(relative)
146
+ dir = File.join(@root, relative)
147
+ FileUtils.mkdir_p(dir)
148
+ raise IOError, "#{relative} must be a folder, not a link" unless File.lstat(dir).directory?
149
+
150
+ File.chmod(0o700, dir)
151
+ dir
152
+ end
153
+
154
+ def new_results_dir
155
+ base = File.join(@root, 'flow-results', name, now.strftime('%Y%m%dT%H%M%SZ'))
156
+ dir = base
157
+ suffix = 1
158
+ dir = "#{base}-#{suffix += 1}" while File.exist?(dir)
159
+ FileUtils.mkdir_p(dir)
160
+ dir
161
+ end
162
+
163
+ # KEY=value lines, # comments, no interpolation. Loaded only from a regular file you own with mode 600.
164
+ # RAIDER_FLOW_* keys are the wrapper's, so the file can't set them.
165
+ def load_env_file
166
+ content = read_env_file
167
+ return unless content
168
+
169
+ content.each_line.with_index(1) do |raw, number|
170
+ line = raw.dup.force_encoding(Encoding::UTF_8)
171
+ line = line.valid_encoding? ? line.strip : nil
172
+ next if line&.empty? || line&.start_with?('#')
173
+
174
+ key, value = line&.split('=', 2)&.map(&:strip)
175
+ unless value && ENV_KEY.match?(key.to_s) && !key.to_s.start_with?('RAIDER_FLOW_')
176
+ say("#{ENV_FILE} line #{number} ignored: expected KEY=value")
177
+ next
178
+ end
179
+ set_env(key.to_s, unquote(value))
180
+ end
181
+ end
182
+
183
+ def read_env_file
184
+ File.open(File.join(@root, ENV_FILE), File::RDONLY | NONBLOCK | NOFOLLOW) do |file|
185
+ stat = file.stat
186
+ return say("ignored #{ENV_FILE}: it must be a regular file you own") unless owned_file?(stat)
187
+ return say("ignored #{ENV_FILE}: permissions must be 600") unless private_mode?(stat)
188
+
189
+ file.read(MAX_ENV_BYTES).to_s
190
+ end
191
+ rescue Errno::ENOENT
192
+ nil
193
+ rescue SystemCallError
194
+ say("ignored #{ENV_FILE}: it must be a regular file you own")
195
+ end
196
+
197
+ # Windows has no POSIX owner or mode bits, so there the folder's ACLs decide who can read the file:
198
+ # keep the project under your user profile, or restrict the file with icacls
199
+ def owned_file?(stat)
200
+ stat.file? && (Gem.win_platform? || stat.uid == Process.uid)
201
+ end
202
+
203
+ def private_mode?(stat)
204
+ Gem.win_platform? || (stat.mode & 0o077).zero?
205
+ end
206
+
207
+ # A value already in the environment wins; both are kept out of the run log
208
+ def set_env(key, value)
209
+ ENV[key] = value unless ENV.key?(key)
210
+ [value, ENV.fetch(key)].each { |secret| @secrets << secret if secret.length >= MIN_SECRET }
211
+ end
212
+
213
+ def unquote(value)
214
+ quoted = value.length >= 2 && value[0] == value[-1] && ['"', "'"].include?(value[0])
215
+ quoted ? value[1...-1].to_s : value
216
+ end
217
+
218
+ def exit_status(error)
219
+ case error
220
+ when nil then 0
221
+ when SystemExit then error.status
222
+ else 1
223
+ end
224
+ end
225
+
226
+ def base_line
227
+ {
228
+ started_at: iso(@started_at),
229
+ attempt: [Integer(ENV.fetch('RAIDER_FLOW_ATTEMPT', '1'), 10, exception: false) || 1, 1].max,
230
+ trigger: TRIGGERS.include?(ENV.fetch('RAIDER_FLOW_TRIGGER', nil)) ? ENV.fetch('RAIDER_FLOW_TRIGGER') : 'manual',
231
+ run_id: RUN_ID.match?(ENV.fetch('RAIDER_FLOW_RUN_ID', '')) ? ENV.fetch('RAIDER_FLOW_RUN_ID') : nil
232
+ }.compact
233
+ end
234
+
235
+ def record(code)
236
+ status = outcome(code)
237
+ line = base_line.merge(finished_at: iso(now), status: status, results_dir: relative(results_dir))
238
+ line[:failed_step] = failed_step if status == 'failed'
239
+ line
240
+ end
241
+
242
+ def outcome(code)
243
+ statuses = RSpec.world.all_examples.map { |example| example.execution_result.status }
244
+ return 'completed' if code.zero? && statuses.include?(:passed) && !statuses.include?(:failed)
245
+ return 'failed' if @steps_started && statuses.include?(:failed)
246
+
247
+ 'setup_error'
248
+ end
249
+
250
+ # The first failed example: its line in the flow file and the recorded step, when the desktop's sidecar named it
251
+ def failed_step
252
+ example = RSpec.world.all_examples.find { |each| each.execution_result.status == :failed }
253
+ return nil unless example
254
+
255
+ number, title = recorded_step(example)
256
+ { number: number, title: redact(title || example.description), line: flow_line(example) }.compact
257
+ end
258
+
259
+ def flow_line(example)
260
+ file = example.metadata[:absolute_file_path].to_s
261
+ frame = Array(example.exception&.backtrace).find { |each| each.start_with?("#{file}:") }
262
+ frame && frame.delete_prefix("#{file}:").to_i
263
+ end
264
+
265
+ def recorded_step(example)
266
+ return nil unless results_dir
267
+
268
+ report = File.join(results_dir, 'run.json')
269
+ return nil unless File.file?(report) && File.size(report) <= MAX_RUN_JSON_BYTES
270
+
271
+ file = example.metadata[:file_path].to_s.delete_prefix('./')
272
+ result = JSON.parse(File.read(report)).fetch('results', []).find do |each|
273
+ each['file_path'] == file && each['status'] == 'failed' && each['steps'].is_a?(Hash)
274
+ end
275
+ index = result&.dig('steps', 'failed')
276
+ return nil unless index.is_a?(Integer)
277
+
278
+ [index + 1, result.dig('steps', 'items', index, 'title')]
279
+ rescue StandardError
280
+ nil
281
+ end
282
+
283
+ def redact(text)
284
+ @secrets.uniq.sort_by { |secret| -secret.length }.reduce(text.to_s) { |result, secret| result.gsub(secret, '[secret]') }
285
+ end
286
+
287
+ def append(line)
288
+ File.open(log_file, File::WRONLY | File::APPEND | File::CREAT | NOFOLLOW, 0o600) do |file|
289
+ file.flock(File::LOCK_EX)
290
+ file.write("#{JSON.generate(line)}\n")
291
+ end
292
+ end
293
+
294
+ def prune
295
+ File.open(log_file, File::RDWR | NOFOLLOW) do |file|
296
+ file.flock(File::LOCK_EX)
297
+ lines = file.read.lines
298
+ if lines.size > KEEP_LINES
299
+ file.rewind
300
+ file.truncate(0)
301
+ file.write(lines.last(KEEP_LINES).join)
302
+ end
303
+ end
304
+ prune_results
305
+ end
306
+
307
+ # Only stamp-named folders directly under flow-results/<name>, and never this run's
308
+ def prune_results
309
+ dir = File.join(@root, 'flow-results', name)
310
+ return unless File.directory?(dir) && File.realpath(dir) == dir
311
+
312
+ runs = Dir.children(dir).select do |child|
313
+ RESULTS_STAMP.match?(child) && File.lstat(File.join(dir, child)).directory?
314
+ end
315
+ old = runs.sort - runs.sort.last(KEEP_RESULTS) - [results_dir && File.basename(results_dir)]
316
+ old.each { |child| FileUtils.rm_rf(File.join(dir, child)) }
317
+ end
318
+
319
+ def log_file
320
+ File.join(private_dir(".raider/flows/#{name}"), 'runs.jsonl')
321
+ end
322
+
323
+ def relative(path)
324
+ path&.delete_prefix("#{@root}/")
325
+ end
326
+
327
+ def iso(time)
328
+ time.iso8601(3)
329
+ end
330
+ end
331
+ end
332
+ end
data/lib/ruby_raider.rb CHANGED
@@ -5,11 +5,23 @@ require 'thor'
5
5
  # Lazy-load top-level command classes to reduce CLI startup time.
6
6
  autoload :ScaffoldingCommands, File.expand_path('commands/scaffolding_commands', __dir__)
7
7
  autoload :UtilityCommands, File.expand_path('commands/utility_commands', __dir__)
8
+ autoload :FlowCommands, File.expand_path('commands/flow_commands', __dir__)
8
9
 
9
10
  module RubyRaider
10
11
  class Raider < Thor
11
12
  def self.exit_on_failure? = true
12
13
 
14
+ # Thor runs a regex on every argument before any command, so invalid UTF-8 would crash it with a
15
+ # backtrace; a flow command says which argument instead and exits 2 like its other refusals
16
+ def self.start(given_args = ARGV, config = {})
17
+ bad = given_args.find { |arg| arg.is_a?(String) && !arg.valid_encoding? }
18
+ if bad && %w[flow f].include?(given_args.first)
19
+ warn("Invalid argument #{bad.inspect}: use valid UTF-8")
20
+ exit(2)
21
+ end
22
+ super
23
+ end
24
+
13
25
  no_tasks do
14
26
  def current_version = File.read(File.expand_path('version', __dir__)).strip
15
27
  end
@@ -51,6 +63,10 @@ module RubyRaider
51
63
  subcommand 'utility', UtilityCommands
52
64
  map 'u' => 'utility'
53
65
 
66
+ desc 'flow', 'Runs flows: recordings without assertions (init, run, list, schedule)'
67
+ subcommand 'flow', FlowCommands
68
+ map 'f' => 'flow'
69
+
54
70
  no_tasks do
55
71
  # -p values arrive as strings; examples must be true or false, in any case
56
72
  def generate_from_parameters(params)