ruby_raider 4.0.1 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +59 -1
- data/RELEASE.md +12 -0
- data/lib/commands/flow_commands.rb +104 -0
- data/lib/generators/templates/flows/flow_helper.tt +42 -0
- data/lib/generators/templates/flows/run.tt +332 -0
- data/lib/ruby_raider.rb +16 -0
- data/lib/utilities/flows/next_run.rb +61 -0
- data/lib/utilities/flows/notifier.rb +76 -0
- data/lib/utilities/flows/os/command_runner.rb +73 -0
- data/lib/utilities/flows/os/cron.rb +137 -0
- data/lib/utilities/flows/os/launchd.rb +164 -0
- data/lib/utilities/flows/os/linux.rb +63 -0
- data/lib/utilities/flows/os/systemd.rb +178 -0
- data/lib/utilities/flows/os/task_scheduler.rb +179 -0
- data/lib/utilities/flows/project.rb +170 -0
- data/lib/utilities/flows/run_log.rb +61 -0
- data/lib/utilities/flows/runner.rb +206 -0
- data/lib/utilities/flows/schedule.rb +221 -0
- data/lib/utilities/flows/schedule_store.rb +135 -0
- data/lib/utilities/flows/scheduler.rb +187 -0
- data/lib/version +1 -1
- data/sig/commands/flow_commands.rbs +13 -0
- data/sig/ruby_raider.rbs +2 -0
- data/sig/utilities/flows/next_run.rbs +15 -0
- data/sig/utilities/flows/notifier.rbs +29 -0
- data/sig/utilities/flows/os/command_runner.rbs +27 -0
- data/sig/utilities/flows/os/cron.rbs +34 -0
- data/sig/utilities/flows/os/launchd.rbs +45 -0
- data/sig/utilities/flows/os/linux.rbs +27 -0
- data/sig/utilities/flows/os/systemd.rbs +43 -0
- data/sig/utilities/flows/os/task_scheduler.rbs +53 -0
- data/sig/utilities/flows/project.rbs +46 -0
- data/sig/utilities/flows/run_log.rbs +22 -0
- data/sig/utilities/flows/runner.rbs +35 -0
- data/sig/utilities/flows/schedule.rbs +61 -0
- data/sig/utilities/flows/schedule_store.rbs +34 -0
- data/sig/utilities/flows/scheduler.rbs +42 -0
- data/spec/commands/flow_commands_spec.rb +128 -0
- data/spec/generators/flow_helper_spec.rb +40 -0
- data/spec/integration/content/gemfile_content_spec.rb +2 -2
- data/spec/integration/end_to_end_spec.rb +63 -0
- data/spec/integration/flows/flow_project.rb +62 -0
- data/spec/integration/flows/run_command_spec.rb +91 -0
- data/spec/integration/flows/run_lifecycle_spec.rb +383 -0
- data/spec/utilities/flows/next_run_spec.rb +119 -0
- data/spec/utilities/flows/notifier_spec.rb +121 -0
- data/spec/utilities/flows/os/command_runner_spec.rb +56 -0
- data/spec/utilities/flows/os/cron_spec.rb +287 -0
- data/spec/utilities/flows/os/launchd_spec.rb +293 -0
- data/spec/utilities/flows/os/linux_spec.rb +149 -0
- data/spec/utilities/flows/os/systemd_spec.rb +329 -0
- data/spec/utilities/flows/os/task_scheduler_spec.rb +505 -0
- data/spec/utilities/flows/project_spec.rb +178 -0
- data/spec/utilities/flows/run_log_spec.rb +47 -0
- data/spec/utilities/flows/runner_spec.rb +313 -0
- data/spec/utilities/flows/schedule_spec.rb +233 -0
- data/spec/utilities/flows/schedule_store_spec.rb +137 -0
- data/spec/utilities/flows/scheduler_spec.rb +329 -0
- metadata +53 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: df32b6a642777030f39db11a96cc9a85a5f59fce1528e9d096f2acd5e6bca3f9
|
|
4
|
+
data.tar.gz: 49713a619c80897c37b68e91c0c3b5acff9917fe9d850cbfde2fa6827345914b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|