inferno_core 1.1.2 → 1.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/lib/inferno/apps/cli/execute_script.rb +918 -0
  3. data/lib/inferno/apps/cli/main.rb +46 -0
  4. data/lib/inferno/apps/cli/session/cancel_run.rb +47 -0
  5. data/lib/inferno/apps/cli/session/connection.rb +47 -0
  6. data/lib/inferno/apps/cli/session/create_session.rb +159 -0
  7. data/lib/inferno/apps/cli/session/errors.rb +45 -0
  8. data/lib/inferno/apps/cli/session/session_compare.rb +390 -0
  9. data/lib/inferno/apps/cli/session/session_data.rb +39 -0
  10. data/lib/inferno/apps/cli/session/session_details.rb +27 -0
  11. data/lib/inferno/apps/cli/session/session_results.rb +39 -0
  12. data/lib/inferno/apps/cli/session/session_status.rb +69 -0
  13. data/lib/inferno/apps/cli/session/start_run.rb +245 -0
  14. data/lib/inferno/apps/cli/session_commands.rb +66 -0
  15. data/lib/inferno/apps/cli/templates/%library_name%.gemspec.tt +1 -1
  16. data/lib/inferno/apps/cli/templates/.gitignore +4 -0
  17. data/lib/inferno/apps/cli/templates/README.md.tt +14 -0
  18. data/lib/inferno/apps/cli/templates/Rakefile.tt +13 -0
  19. data/lib/inferno/apps/cli/templates/execution_scripts/%library_name%_script.yaml.tt +20 -0
  20. data/lib/inferno/apps/cli/templates/execution_scripts/%library_name%_script_expected.json.tt +244 -0
  21. data/lib/inferno/apps/cli/templates/execution_scripts/README.md.tt +16 -0
  22. data/lib/inferno/dsl/fhir_resource_navigation.rb +145 -27
  23. data/lib/inferno/dsl/must_support_assessment.rb +93 -23
  24. data/lib/inferno/dsl/must_support_metadata_extractor.rb +139 -21
  25. data/lib/inferno/dsl/resume_test_route.rb +4 -3
  26. data/lib/inferno/exceptions.rb +6 -0
  27. data/lib/inferno/repositories/test_sessions.rb +3 -0
  28. data/lib/inferno/utils/execution_script_runner.rb +90 -0
  29. data/lib/inferno/utils/preset_processor.rb +2 -0
  30. data/lib/inferno/version.rb +1 -1
  31. metadata +18 -2
@@ -0,0 +1,918 @@
1
+ require 'English'
2
+ require 'yaml'
3
+ require_relative 'session/create_session'
4
+ require_relative 'session/start_run'
5
+ require_relative 'session/cancel_run'
6
+ require_relative 'session/session_status'
7
+ require_relative 'session/session_results'
8
+ require_relative 'session/session_compare'
9
+ require_relative 'session/session_details'
10
+ require_relative 'session/session_data'
11
+
12
+ module Inferno
13
+ module CLI
14
+ # Orchestrates multi-session Inferno test runs from a YAML configuration file.
15
+ #
16
+ # YAML format:
17
+ #
18
+ # sessions: # Controls the creation of Inferno sessions for the execution
19
+ # # a session for each indicated suite will be created and
20
+ # # a successful run will have the expected results for all sessions.
21
+ # - suite: my_suite # internal ID, title, or short title
22
+ # name: my_name # optional; used as key in multi-session
23
+ # preset: my-preset # optional; internal ID or title
24
+ # suite_options: # optional; option_key and option_value can be the
25
+ # option_key: option_value # internal values or the displayed titles
26
+ #
27
+ #
28
+ # comparison_config: # optional; Controls the comparison of actual results during a run
29
+ # # to the expected results for each created Inferno session.
30
+ # # When the configured expected results file for a session does
31
+ # # not exist, the run will be considered a failure and the expected
32
+ # # results file will be generated using the results from the run.
33
+ # # Developers are responsible for verifying that the results match
34
+ # # their expectations before committing those expected results.
35
+ # # When expected results are present, they must match for the run
36
+ # # to be successful. When the results for a session do not match
37
+ # # the expected results, an actual results JSON file and a CSV diff
38
+ # # are written to the directory of the expected results file for
39
+ # # use in evaluating the failure.
40
+ # normalized_strings: # optional; global normalization rules applied to
41
+ # - "http://my-server.example.com" # both expected and actual before comparing.
42
+ # - "http://other-value.example.com" # plain string: replaced with <NORMALIZED>;
43
+ # # URL-encoded form is also replaced automatically.
44
+ # - "/code_challenge=[A-Za-z0-9+\\/=_-]{20,}/" # regex string (wrapped in /…/): compiled
45
+ # # to a Regexp and replaced with <NORMALIZED>.
46
+ # # Supports flags: /pattern/i, /pattern/m, etc.
47
+ # # URL-encoded form is NOT auto-replaced for regex.
48
+ # - pattern: '/code_challenge=[A-Za-z0-9+\\/=_-]{20,}/' # hash form: use when you need
49
+ # replacement: '<CODE_CHALLENGE>' # a named placeholder instead of <NORMALIZED>.
50
+ # - patterns: # 'patterns' (plural) shares one replacement
51
+ # - '/code_challenge=[A-Za-z0-9+\\/=_-]{20,}/' # across multiple patterns.
52
+ # - '/code_verifier=[A-Za-z0-9+\\/=_-]{20,}/'
53
+ # replacement: '<PKCE_VALUE>'
54
+ #
55
+ # # Single-session scripts: expected file config lives directly under comparison_config.
56
+ # expected_results_file: expected.json # optional; relative to yaml file; defaults to
57
+ # # <yaml basename>_expected.json
58
+ # alternate_expected_files: # optional; tried in order; first matching wins
59
+ # - file: alt_expected.json # required; relative to yaml file
60
+ # when: # required; all conditions must match (AND logic)
61
+ # - field: inputs.url # required: can be inputs.<name>, configuration_messages,
62
+ # # or inferno_base_url
63
+ # matches: ^http:// # other values are top-level session detail fields.
64
+ # # Evaluated against GET api/test_sessions/{id}.
65
+ #
66
+ # # Multi-session scripts: per-session config is nested under sessions.<name>.
67
+ # sessions:
68
+ # my_name: # matches session name key (sessions[*].name or suite)
69
+ # expected_results_file: expected.json # optional; defaults to <yaml basename>_<name>_expected.json
70
+ # alternate_expected_files: # optional; same structure as single-session above
71
+ # - file: alt_expected.json
72
+ # when:
73
+ # - field: inputs.url
74
+ # matches: ^http://
75
+ #
76
+ # steps: # Details the steps taken in the execution of the script
77
+ # - status: created|done|waiting # required; other status values cannot be matched on
78
+ # last_completed: "1.01" # optional; required unless the status is created
79
+ # # can be a full ID, short ID (e.g. "1.01"), or 'suite'
80
+ # session: my_name # optional; required when multiple sessions to indicate which session
81
+ # # can match this step
82
+ # action: END_SCRIPT|NOOP|WAIT # optional; built-in action (case-insensitive)
83
+ # # (mutually exclusive with command/start_run)
84
+ # # OR
85
+ # command: "bundle exec ..." # optional; arbitrary shell command (requires
86
+ # # --allow-commands CLI flag)
87
+ # # OR
88
+ # start_run:
89
+ # session: "my_name" # optional; session name or template token
90
+ # # (e.g. {session_id}); defaults to current session
91
+ # runnable: "1.01" # required; can be a short id from the UI,
92
+ # # an internal long id, or 'suite'
93
+ # inputs: # optional; the key must be the internal name
94
+ # input_name: "value" # for the input
95
+ # input_name: "@path/to/file.txt" # prefix value with @ to read from a file;
96
+ # # relative paths are resolved from the
97
+ # # directory containing this script file
98
+ # input_name: # YAML mappings and sequences are
99
+ # key: value # automatically JSON-serialized, useful
100
+ # input_name: # for auth_info and other JSON inputs
101
+ # - item1
102
+ # timeout: 300 # optional; seconds to wait for next match (Default is 120)
103
+ # next_poll_session: other_name # optional; switch polling target after command
104
+ # state_description: "..." # optional; logged when step is matched
105
+ # action_description: "..." # optional; logged when step is matched
106
+ #
107
+ # Built-in action values:
108
+ # END_SCRIPT — terminate script successfully
109
+ # NOOP — no-op; keep polling with (optionally updated) timeout or session
110
+ # WAIT — keep polling without breaking out of the current poll loop
111
+ # (unlike noop, does not restart the loop with a new timeout or session)
112
+ #
113
+ # Security note:
114
+ # Steps using command: execute arbitrary shell commands via the system() call. This is
115
+ # intentional for use cases like browser automation (e.g. Selenium) that must interact
116
+ # with Inferno's waiting state mid-test. Because of this risk, command: steps are blocked
117
+ # by default and require the --allow-commands CLI flag to run. Scripts that contain no
118
+ # command: steps are unaffected and do not need the flag.
119
+ #
120
+ # Template tokens in command strings and start_run input values:
121
+ # {session_id} — current session's Inferno session ID
122
+ # {NAME.session_id} — named session's ID
123
+ # {result_message} — current session's wait_result_message
124
+ # {NAME.result_message} — named session's wait_result_message
125
+ # {wait_outputs.KEY} — current session's wait output by name
126
+ # {NAME.wait_outputs.KEY} — named session's wait output by name
127
+ # {inferno_base_url} — the Inferno base URL (--inferno-base-url option)
128
+ class ExecuteScript
129
+ SHORT_ID_PATTERN = /\A[0-9][0-9.]*\z/
130
+ # Seconds subtracted from the initial last_log_time so the first
131
+ # active-status line is logged immediately rather than after one interval.
132
+ LOG_INTERVAL_SECONDS = 30
133
+
134
+ ScriptSession = Struct.new(
135
+ :key, :suite_id, :session_id, :short_id_map,
136
+ keyword_init: true
137
+ )
138
+
139
+ ExecutionStatus = Struct.new(
140
+ :done, :failed, :timed_out, :current_session, :current_timeout, :last_log_time,
141
+ :cross_session_status, :last_step_signatures
142
+ )
143
+
144
+ attr_accessor :yaml_file, :options, :execution_status
145
+
146
+ def initialize(yaml_file, options)
147
+ self.yaml_file = yaml_file
148
+ self.options = options
149
+ validate_yaml_file!
150
+ validate_commands_allowed!
151
+ self.execution_status = ExecutionStatus.new(
152
+ done: false,
153
+ failed: false,
154
+ timed_out: false,
155
+ current_session: sessions.first,
156
+ current_timeout: options[:default_poll_timeout],
157
+ cross_session_status: {},
158
+ last_step_signatures: {}
159
+ )
160
+ end
161
+
162
+ def run
163
+ exit(orchestrate)
164
+ end
165
+
166
+ private
167
+
168
+ def script_config
169
+ @script_config ||= YAML.safe_load_file(File.expand_path(yaml_file))
170
+ end
171
+
172
+ def sessions
173
+ @sessions ||= create_sessions
174
+ end
175
+
176
+ def session_name_to_id_map
177
+ @session_name_to_id_map ||= sessions.to_h { |session| [session.key, session.session_id] }
178
+ end
179
+
180
+ def steps
181
+ @steps ||= scripted_steps || []
182
+ end
183
+
184
+ def multi_session_script?
185
+ script_config['sessions'].present? &&
186
+ (script_config['sessions'].length > 1)
187
+ end
188
+
189
+ def yaml_directory
190
+ @yaml_directory ||= File.dirname(File.expand_path(yaml_file))
191
+ end
192
+
193
+ def yaml_basename
194
+ @yaml_basename ||= File.basename(yaml_file, '.yaml')
195
+ end
196
+
197
+ def validate_yaml_file!
198
+ unless File.exist?(yaml_file)
199
+ puts JSON.pretty_generate({ errors: "File not found: #{yaml_file}" })
200
+ exit(1)
201
+ end
202
+ return if yaml_file.end_with?('.yaml', '.yml')
203
+
204
+ puts JSON.pretty_generate(
205
+ { errors: "'#{yaml_file}' does not appear to be a YAML file (.yaml or .yml extension required)." }
206
+ )
207
+ exit(1)
208
+ end
209
+
210
+ def validate_commands_allowed!
211
+ return if options[:allow_commands]
212
+ return if Array(script_config['steps']).none? { |step| step['command'].present? }
213
+
214
+ puts JSON.pretty_generate(
215
+ { errors: "Script contains 'command' steps but --allow-commands was not set. " \
216
+ 'Re-run with --allow-commands to permit arbitrary shell command execution.' }
217
+ )
218
+ exit(1)
219
+ end
220
+
221
+ # ---------------------------------------------------------------------------
222
+ # Session creation
223
+ # ---------------------------------------------------------------------------
224
+
225
+ def create_sessions
226
+ script_config['sessions']&.map do |session_config|
227
+ create_session_from_config(session_config)
228
+ end || []
229
+ end
230
+
231
+ def create_session_from_config(session_config)
232
+ suite = session_config['suite']
233
+ warn "Creating '#{suite}' session..."
234
+ creator = Session::CreateSession.new(suite, session_create_options(session_config))
235
+ session_details = creator.create_session
236
+ key = session_config['name'] || suite
237
+ warn "Session created: #{session_details['id']}"
238
+ ScriptSession.new(
239
+ key: key,
240
+ suite_id: session_details['test_suite_id'],
241
+ session_id: session_details['id'],
242
+ short_id_map: extract_short_ids_from_session_details(session_details)
243
+ )
244
+ end
245
+
246
+ def session_create_options(session_config)
247
+ {
248
+ preset: session_config['preset'],
249
+ suite_options: session_config['suite_options'],
250
+ inferno_base_url: options[:inferno_base_url]
251
+ }
252
+ end
253
+
254
+ def extract_short_ids_from_session_details(session_details)
255
+ results = {}
256
+ results['suite'] = session_details['test_suite_id']
257
+ suite_details = session_details['test_suite']
258
+ suite_details['test_groups']&.each do |group|
259
+ extract_short_ids(group, results)
260
+ end
261
+ suite_details['tests']&.each do |test|
262
+ extract_short_ids(test, results)
263
+ end
264
+
265
+ results
266
+ end
267
+
268
+ # Recursive runnable walk collecting { short_id => full_id } pairs.
269
+ def extract_short_ids(runnable, results)
270
+ results[runnable['short_id']] = runnable['id'] if runnable['short_id'] && runnable['id']
271
+ runnable['test_groups']&.each do |group|
272
+ extract_short_ids(group, results)
273
+ end
274
+ runnable['tests']&.each do |test|
275
+ extract_short_ids(test, results)
276
+ end
277
+ end
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # Extract Steps - resolve short ids in the last_completed entries
281
+ # ---------------------------------------------------------------------------
282
+
283
+ def scripted_steps
284
+ script_config['steps']&.map do |step|
285
+ last_completed = step['last_completed'].to_s
286
+ next step unless last_completed.match?(SHORT_ID_PATTERN) || last_completed == 'suite'
287
+
288
+ resolved_test_id = resolve_short_id(last_completed, step['session'])
289
+ warn "Resolved short ID \"#{last_completed}\" -> \"#{resolved_test_id}\""
290
+ step.merge('last_completed' => resolved_test_id)
291
+ end
292
+ end
293
+
294
+ def resolve_short_id(short_id, session_name)
295
+ if multi_session_script? && session_name.blank?
296
+ puts JSON.pretty_generate(
297
+ {
298
+ errors: "Short ID '#{short_id}' used in step 'last_completed' " \
299
+ "without a 'session' in a multi-session script."
300
+ }
301
+ )
302
+ exit(3)
303
+ end
304
+
305
+ target_session = session_for_name(session_name)
306
+ unless target_session
307
+ puts JSON.pretty_generate(
308
+ { errors: "Session '#{session_name}' not found in script sessions" }
309
+ )
310
+ exit(3)
311
+ end
312
+
313
+ test_id = target_session.short_id_map&.dig(short_id)
314
+ unless test_id
315
+ puts JSON.pretty_generate(
316
+ { errors: "Short ID '#{short_id}' not found in session '#{target_session.key}'" }
317
+ )
318
+ exit(3)
319
+ end
320
+ test_id
321
+ end
322
+
323
+ def session_for_name(session_name)
324
+ if session_name.present?
325
+ sessions.find { |session| session.key == session_name }
326
+ else
327
+ sessions.first
328
+ end
329
+ end
330
+
331
+ # ---------------------------------------------------------------------------
332
+ # Orchestration loop
333
+ # ---------------------------------------------------------------------------
334
+
335
+ def orchestrate
336
+ loop do
337
+ matched_step = poll_for_next_step
338
+ execution_status.done = true if [nil, 'END_SCRIPT'].include?(matched_step[:command])
339
+ break if execution_status.done
340
+
341
+ take_step(matched_step)
342
+ break if execution_status.done
343
+ end
344
+
345
+ warn ''
346
+ warn "All runs complete #{execution_status.failed ? 'with errors' : 'successfully'}."
347
+
348
+ if results_match_expected?(sessions)
349
+ execution_status.failed ? 3 : 0
350
+ else
351
+ 3
352
+ end
353
+ end
354
+
355
+ # perform the specified command to continue the script
356
+ def take_step(matched_step)
357
+ execution_status.current_timeout = next_step_timeout(matched_step)
358
+ if matched_step[:next_poll_session].present?
359
+ execution_status.current_session = session_for_name(matched_step[:next_poll_session])
360
+ end
361
+ return if matched_step[:command] == 'NOOP'
362
+
363
+ if matched_step[:command] == 'START_RUN'
364
+ target_session_id = start_run_target_session(matched_step[:start_run])
365
+ warn "Executing: #{start_run_description(matched_step[:start_run], session_id: target_session_id)}"
366
+ execute_start_run(matched_step[:start_run], target_session_id)
367
+ else
368
+ warn "Executing: #{matched_step[:command]}"
369
+ execution_status.failed = true unless execute_command(matched_step[:command])
370
+ execution_status.done = execution_status.failed
371
+ end
372
+ end
373
+
374
+ def next_step_timeout(matched_step)
375
+ matched_step[:timeout].present? ? matched_step[:timeout] : options[:default_poll_timeout]
376
+ end
377
+
378
+ def start_run_target_session(start_run_details)
379
+ if start_run_details['session'].present?
380
+ if session_name_to_id_map[start_run_details['session']].present?
381
+ session_name_to_id_map[start_run_details['session']]
382
+ else
383
+ start_run_details['session']
384
+ end
385
+ elsif multi_session_script?
386
+ puts JSON.pretty_generate({ errors: 'Start run steps must have `session` defined when multiple sessions.' })
387
+ exit(3)
388
+ else
389
+ execution_status.current_session.session_id
390
+ end
391
+ end
392
+
393
+ # templates already resolved
394
+ def execute_start_run(start_run_config, target_session_id)
395
+ start_run_options = {
396
+ runnable: start_run_config['runnable'],
397
+ inputs: start_run_config['inputs'],
398
+ inferno_base_url: options[:inferno_base_url]
399
+ }
400
+
401
+ runner = Session::StartRun.new(target_session_id, start_run_options)
402
+ runner.start_run
403
+ end
404
+
405
+ # ---------------------------------------------------------------------------
406
+ # Polling
407
+ # ---------------------------------------------------------------------------
408
+
409
+ def poll_for_next_step
410
+ session = execution_status.current_session
411
+ timeout = execution_status.current_timeout
412
+
413
+ warn ''
414
+ warn "Polling session: #{session.key} (#{session.session_id}) timeout=#{timeout}s"
415
+ deadline = Time.now + timeout
416
+ execution_status.last_log_time = Time.now - LOG_INTERVAL_SECONDS
417
+
418
+ loop do
419
+ status = fetch_session_status(session.session_id)
420
+ run_status = status['status']
421
+
422
+ case run_status
423
+ when 'running', 'queued', 'cancelling'
424
+ log_poll_if_needed(status, session.key)
425
+ when 'waiting', 'done', 'created'
426
+ execution_status.cross_session_status = {} # reset per poll cycle
427
+ execution_status.cross_session_status[session.key] = status
428
+ result = handle_actionable_status(status, session, timeout)
429
+ return result if result
430
+ end
431
+
432
+ if Time.now >= deadline
433
+ warn "Timeout after #{timeout}s: session=#{session.key} status=#{run_status}"
434
+ execution_status.failed = true
435
+ execution_status.timed_out = true
436
+ return { command: nil, timeout: timeout, next_poll_session: nil }
437
+ end
438
+
439
+ sleep options[:poll_interval]
440
+ end
441
+ end
442
+
443
+ # Returns a step hash to act on, or nil to keep polling.
444
+ def handle_actionable_status(status, session, timeout)
445
+ matched_step = match_step(status, session.key)
446
+
447
+ if matched_step
448
+ handle_matched_step(matched_step, status, session, timeout)
449
+ elsif status['status'] == 'waiting'
450
+ handle_unmatched_wait(status, session)
451
+ else
452
+ handle_unmatched_status(status, session, timeout)
453
+ end
454
+ end
455
+
456
+ def handle_matched_step(matched_step, status, session, timeout)
457
+ return nil if matched_step[:command] == 'WAIT'
458
+
459
+ verify_step(matched_step, status, session, timeout)
460
+ end
461
+
462
+ def handle_unmatched_wait(status, session)
463
+ last_completed = format_last_completed(last_completed_from_status(status), session.key)
464
+ warn "UNHANDLED WAIT - Canceling: session=#{session.key} last_completed=#{last_completed}"
465
+ execution_status.failed = true
466
+ attempt_cancel(session.session_id, status)
467
+ nil
468
+ end
469
+
470
+ def handle_unmatched_status(status, session, timeout)
471
+ run_status = status['status']
472
+ last_completed = format_last_completed(last_completed_from_status(status), session.key)
473
+ warn "UNMATCHED: session=#{session.key} status=#{run_status} last_completed=#{last_completed}"
474
+ execution_status.failed = true
475
+ { command: nil, timeout: timeout, next_poll_session: nil }
476
+ end
477
+
478
+ # Checks for a repeated steps that aren't NOOPs; returns nil to keep polling or the step to act on.
479
+ def verify_step(matched_step, status, session, timeout)
480
+ run_status = status['status']
481
+ last_completed = last_completed_from_status(status)
482
+ step_sig = [run_status, last_completed]
483
+
484
+ if step_sig == execution_status.last_step_signatures[session.key] && matched_step[:command] != 'NOOP'
485
+ execution_status.failed = true
486
+ if run_status == 'waiting'
487
+ warn "Loop detected - Canceling: session=#{session.key} last_completed=#{last_completed}"
488
+ attempt_cancel(session.session_id, status)
489
+ return nil
490
+ else
491
+ warn "Loop detected: session=#{session.key} status=#{run_status} " \
492
+ "last_completed=#{last_completed}"
493
+ return { command: nil, timeout: timeout, next_poll_session: nil }
494
+ end
495
+ end
496
+
497
+ execution_status.last_step_signatures[session.key] = step_sig
498
+ matched_step
499
+ end
500
+
501
+ def log_poll_if_needed(status, session_key)
502
+ return unless Time.now - execution_status.last_log_time >= 30
503
+
504
+ last_completed = last_completed_from_status(status)
505
+ poll_status_last_test =
506
+ last_completed.present? ? " - last test: #{format_last_completed(last_completed, session_key)}" : ''
507
+ warn " [#{session_key}] #{status['status']}#{poll_status_last_test}"
508
+ execution_status.last_log_time = Time.now
509
+ end
510
+
511
+ def fetch_session_status(session_id)
512
+ Session::SessionStatus.new(session_id, options).status_for_session
513
+ end
514
+
515
+ def attempt_cancel(session_id, status)
516
+ Session::CancelRun.new(session_id, options).cancel_run(status)
517
+ end
518
+
519
+ # ---------------------------------------------------------------------------
520
+ # Step matching
521
+ # ---------------------------------------------------------------------------
522
+
523
+ def match_step(status, session_key)
524
+ run_status = status['status']
525
+ last_completed = last_completed_from_status(status)
526
+
527
+ matched = find_matching_step(run_status, last_completed, session_key)
528
+ return nil unless matched
529
+
530
+ log_matched_step(matched, last_completed, session_key)
531
+ step_details = resolve_command(matched, status, session_key)
532
+ step_details[:timeout] = matched['timeout'].to_i if matched['timeout'].present?
533
+ step_details[:next_poll_session] = matched['next_poll_session'] if matched['next_poll_session'].present?
534
+
535
+ step_details
536
+ end
537
+
538
+ # when status['status'] is done, return the executed runnable that was completed
539
+ # otherwise, return last_test_executed
540
+ def last_completed_from_status(status)
541
+ if status['status'] == 'done'
542
+ (status['test_id'] || status['test_group_id'] || status['test_suite_id']).to_s
543
+ else
544
+ status['last_test_executed'].to_s
545
+ end
546
+ end
547
+
548
+ def log_matched_step(matched, last_completed, session_key)
549
+ warn 'Matched step:'
550
+ warn " State: #{matched['state_description']}" if matched['state_description'].present?
551
+ warn " status=#{matched['status']} last_completed=#{format_last_completed(last_completed, session_key)}"
552
+ warn " Command: #{step_command_description(matched)}"
553
+ matched_step_optional_lines(matched)
554
+ end
555
+
556
+ def matched_step_optional_lines(matched)
557
+ warn " Action: #{matched['action_description']}" if matched['action_description'].present?
558
+ warn " Next poll session: #{matched['next_poll_session']}" if matched['next_poll_session'].present?
559
+ warn " Timeout: #{matched['timeout']}" if matched['timeout'].present?
560
+ end
561
+
562
+ def format_last_completed(last_completed, session_key)
563
+ return '(none)' if last_completed.empty?
564
+
565
+ short_id = session_for_name(session_key)&.short_id_map&.key(last_completed)
566
+ short_id ? "#{last_completed} (#{short_id})" : last_completed
567
+ end
568
+
569
+ def find_matching_step(run_status, last_completed, session_key)
570
+ steps.find do |step|
571
+ step['status'] == run_status &&
572
+ step['last_completed'].to_s == last_completed &&
573
+ (step['session'].blank? || step['session'] == session_key)
574
+ end
575
+ end
576
+
577
+ # ---------------------------------------------------------------------------
578
+ # Command resolution
579
+ # ---------------------------------------------------------------------------
580
+
581
+ # Returns a human-readable description of the step's command for logging.
582
+ def step_command_description(step)
583
+ if step.key?('start_run')
584
+ start_run_description(step['start_run'] || {})
585
+ elsif step.key?('action')
586
+ "action: #{step['action']}"
587
+ else
588
+ step['command'].to_s
589
+ end
590
+ end
591
+
592
+ def start_run_description(start_run_details, session_id: nil)
593
+ session_token =
594
+ if session_id.present?
595
+ session_id
596
+ elsif start_run_details['session'].present?
597
+ "{#{start_run_details['session']}.session_id}"
598
+ else
599
+ '{session_id}'
600
+ end
601
+ runnable_token = start_run_details['runnable'].present? ? " '#{start_run_details['runnable']}'" : ''
602
+ parts = ["bundle exec inferno session start_run '#{session_token}'#{runnable_token}"]
603
+ if start_run_details['inputs'].present?
604
+ parts << "-i #{start_run_details['inputs'].map do |k, v|
605
+ "#{k}:#{v}"
606
+ end.join(' ')}"
607
+ end
608
+ parts.join(' ')
609
+ end
610
+
611
+ def resolve_command(step, status, session_key)
612
+ if step.key?('start_run')
613
+ start_run = apply_templates_to_start_run(step['start_run'] || {}, status, session_key)
614
+ { command: 'START_RUN', start_run: }
615
+ elsif step.key?('action')
616
+ { command: step['action'].upcase }
617
+ else
618
+ cmd = step['command'].to_s
619
+ { command: cmd.include?('{') ? apply_templates(cmd, status, session_key) : cmd }
620
+ end
621
+ end
622
+
623
+ # ---------------------------------------------------------------------------
624
+ # Template token substitution
625
+ # ---------------------------------------------------------------------------
626
+
627
+ # Substitutes all {token} placeholders in +str+.
628
+ # Cross-session tokens trigger an on-demand status fetch (cached in
629
+ # execution_status.cross_session_status, reset each poll cycle in poll_for_next_step).
630
+ #
631
+ # Returns the resolved string or exits 3 if a token cannot be resolved.
632
+ def apply_templates(str, status, session_key)
633
+ result = str.dup
634
+
635
+ # {inferno_base_url} — the Inferno base URL
636
+ result.gsub!('{inferno_base_url}') { options[:inferno_base_url] }
637
+
638
+ # {session_id} — current session
639
+ result.gsub!('{session_id}') { session_name_to_id_map[session_key] }
640
+
641
+ # {NAME.session_id} — named session
642
+ result.gsub!(/\{([^}.]+)\.session_id\}/) do
643
+ name = Regexp.last_match(1)
644
+ id = session_name_to_id_map[name]
645
+ unless id
646
+ puts JSON.pretty_generate({ errors: "Unknown session name '#{name}' in token {#{name}.session_id}" })
647
+ exit(3)
648
+ end
649
+ id
650
+ end
651
+
652
+ # {result_message} — current session wait result message
653
+ result.gsub!('{result_message}') do
654
+ msg = status['wait_result_message']
655
+ unless msg
656
+ puts JSON.pretty_generate({ errors: 'Token {result_message} used but session is not in waiting state' })
657
+ exit(3)
658
+ end
659
+ msg
660
+ end
661
+
662
+ # {NAME.result_message} — another session's wait result message
663
+ result.gsub!(/\{([^}.]+)\.result_message\}/) do
664
+ name = Regexp.last_match(1)
665
+ other_stat = cross_session_status(name)
666
+ msg = other_stat['wait_result_message']
667
+ unless msg
668
+ puts JSON.pretty_generate(
669
+ { errors: "Token {#{name}.result_message} used but session '#{name}' is not in waiting state" }
670
+ )
671
+ exit(3)
672
+ end
673
+ msg
674
+ end
675
+
676
+ # {wait_outputs.KEY} — current session wait output
677
+ result.gsub!(/\{wait_outputs\.([^}]+)\}/) do
678
+ key = Regexp.last_match(1)
679
+ output = find_wait_output(status['wait_outputs'], key)
680
+ unless output
681
+ puts JSON.pretty_generate({ errors: "Wait output '#{key}' not found in current session" })
682
+ exit(3)
683
+ end
684
+ output
685
+ end
686
+
687
+ # {NAME.wait_outputs.KEY} — another session's wait output
688
+ result.gsub!(/\{([^}.]+)\.wait_outputs\.([^}]+)\}/) do
689
+ name = Regexp.last_match(1)
690
+ key = Regexp.last_match(2)
691
+ other_stat = cross_session_status(name)
692
+ output = find_wait_output(other_stat['wait_outputs'], key)
693
+ unless output
694
+ puts JSON.pretty_generate(
695
+ { errors: "Wait output '#{key}' not found in session '#{name}'" }
696
+ )
697
+ exit(3)
698
+ end
699
+ output
700
+ end
701
+
702
+ result
703
+ end
704
+
705
+ def find_wait_output(wait_outputs, key)
706
+ Array(wait_outputs).find { |o| o['name'] == key }&.dig('value')
707
+ end
708
+
709
+ # Returns the cached enriched status for a named session, fetching it on
710
+ # first access within a poll cycle.
711
+ def cross_session_status(name)
712
+ execution_status.cross_session_status[name] ||= begin
713
+ session_id = session_name_to_id_map[name]
714
+ unless session_id
715
+ puts JSON.pretty_generate({ errors: "Unknown session name '#{name}'" })
716
+ exit(3)
717
+ end
718
+ fetch_session_status(session_id)
719
+ end
720
+ end
721
+
722
+ def apply_templates_to_start_run(start_run, status, session_key)
723
+ if start_run['session'].present?
724
+ start_run['session'] = apply_templates(start_run['session'], status, session_key)
725
+ end
726
+ start_run['inputs']&.each_key do |input_name|
727
+ raw = start_run['inputs'][input_name]
728
+ raw = raw.to_json if raw.is_a?(Array) || raw.is_a?(Hash)
729
+ value = apply_templates(raw.to_s, status, session_key)
730
+ start_run['inputs'][input_name] = expand_file_input_path(value)
731
+ end
732
+
733
+ start_run
734
+ end
735
+
736
+ def expand_file_input_path(value)
737
+ return value unless value.start_with?('@')
738
+
739
+ path = value[1..]
740
+ expanded = Pathname.new(path).absolute? ? path : File.expand_path(path, File.dirname(yaml_file))
741
+
742
+ unless File.exist?(expanded)
743
+ puts JSON.pretty_generate({ errors: "File input not found: #{expanded}" })
744
+ exit(3)
745
+ end
746
+
747
+ "@#{expanded}"
748
+ end
749
+
750
+ # ---------------------------------------------------------------------------
751
+ # Command execution
752
+ # ---------------------------------------------------------------------------
753
+
754
+ def execute_command(cmd)
755
+ system(cmd)
756
+ $CHILD_STATUS.success?
757
+ end
758
+
759
+ # ---------------------------------------------------------------------------
760
+ # check results
761
+ # ---------------------------------------------------------------------------
762
+
763
+ def results_match_expected?(sessions)
764
+ sessions.map do |session|
765
+ warn "Checking results for #{session.key} session (#{session.session_id})"
766
+ warn " View session at #{session_display_url(session)}"
767
+
768
+ if execution_status.timed_out
769
+ warn ' Session timed out - skipping comparison'
770
+ false
771
+ elsif any_error_results?(session)
772
+ warn ' Session contained execution errors - skipping comparison'
773
+ false
774
+ else
775
+ compare_session(session)
776
+ end
777
+ end.all?
778
+ end
779
+
780
+ def session_display_url(session)
781
+ base_url = (options[:inferno_base_url].presence || Inferno::Application['base_url']).to_s.delete_suffix('/')
782
+ "#{base_url}/#{session.suite_id}/#{session.session_id}"
783
+ end
784
+
785
+ def any_error_results?(session)
786
+ results = Session::SessionResults.new(session.session_id, options).results_for_session(session.session_id)
787
+ results.any? { |result| result['result'] == 'error' }
788
+ end
789
+
790
+ # ---------------------------------------------------------------------------
791
+ # Compare / save results
792
+ # ---------------------------------------------------------------------------
793
+
794
+ def resolve_expected_file(key)
795
+ session_cfg = comparison_session_config(key)
796
+ if session_cfg['expected_results_file'].present?
797
+ File.expand_path(session_cfg['expected_results_file'], yaml_directory)
798
+ elsif multi_session_script?
799
+ File.join(yaml_directory, "#{yaml_basename}_#{key}_expected.json")
800
+ else
801
+ File.join(yaml_directory, "#{yaml_basename}_expected.json")
802
+ end
803
+ end
804
+
805
+ def comparison_session_config(key)
806
+ if multi_session_script?
807
+ (comparison_config['sessions'] || {})[key] || {}
808
+ else
809
+ comparison_config
810
+ end
811
+ end
812
+
813
+ def compare_session(session)
814
+ expected_file = resolve_expected_file_for_comparison(session)
815
+
816
+ if File.exist?(expected_file)
817
+ cmp = Session::SessionCompare.new(session.session_id, compare_options(expected_file))
818
+ matched = cmp.results_match?
819
+ warn " Comparing against #{File.basename(expected_file)}"
820
+ warn " Actual results matched expected results? #{matched}"
821
+ unless matched
822
+ cmp.save_actual_results_to_file
823
+ cmp.save_comparison_csv_to_file
824
+ end
825
+ matched
826
+ else
827
+ warn " Expected results file not found; writing actual results to #{expected_file}"
828
+ results = Session::SessionResults.new(session.session_id, options).results_for_session(session.session_id)
829
+ File.write(expected_file, results.to_json)
830
+ false
831
+ end
832
+ end
833
+
834
+ def resolve_expected_file_for_comparison(session)
835
+ default_file = resolve_expected_file(session.key)
836
+ alternates = Array(comparison_session_config(session.key)['alternate_expected_files'])
837
+ return default_file if alternates.empty?
838
+
839
+ session_details = Session::SessionDetails.new(session.session_id, options).details_for_session
840
+ session_inputs = Session::SessionData.new(session.session_id, options).session_data
841
+
842
+ alternates.each do |alt|
843
+ conditions = Array(alt['when'])
844
+ next if conditions.empty?
845
+ unless conditions.all? { |cond| session_detail_condition_matches?(cond, session_details, session_inputs) }
846
+ next
847
+ end
848
+
849
+ file = File.expand_path(alt['file'], yaml_directory)
850
+ return file
851
+ end
852
+
853
+ default_file
854
+ end
855
+
856
+ def session_detail_condition_matches?(condition, session_details, session_inputs)
857
+ return false unless condition_has_field_and_pattern?(condition)
858
+
859
+ field = condition['field']
860
+ matches_pattern = condition['matches']
861
+ not_matches_pattern = condition['not_matches']
862
+
863
+ if field == 'configuration_messages'
864
+ configuration_messages_match?(session_details['test_suite'] || {}, matches_pattern, not_matches_pattern)
865
+ elsif field == 'inferno_base_url'
866
+ string_matches?(options[:inferno_base_url].to_s, matches_pattern, not_matches_pattern)
867
+ elsif field.start_with?('inputs.')
868
+ input_name = field.delete_prefix('inputs.')
869
+ test_suite_input_matches?(session_inputs || [], input_name, matches_pattern, not_matches_pattern)
870
+ else
871
+ false
872
+ end
873
+ end
874
+
875
+ def condition_has_field_and_pattern?(condition)
876
+ condition['field'].present? && (condition['matches'].present? || condition['not_matches'].present?)
877
+ end
878
+
879
+ def string_matches?(value, matches_pattern, not_matches_pattern)
880
+ return false if matches_pattern && !Regexp.new(matches_pattern).match?(value)
881
+ return false if not_matches_pattern && Regexp.new(not_matches_pattern).match?(value)
882
+
883
+ true
884
+ end
885
+
886
+ def configuration_messages_match?(test_suite, matches_pattern, not_matches_pattern)
887
+ messages = Array(test_suite['configuration_messages'])
888
+ if matches_pattern && messages.none? { |msg| Regexp.new(matches_pattern).match?(msg['message'].to_s) }
889
+ return false
890
+ end
891
+ if not_matches_pattern && messages.any? { |msg| Regexp.new(not_matches_pattern).match?(msg['message'].to_s) }
892
+ return false
893
+ end
894
+
895
+ true
896
+ end
897
+
898
+ def test_suite_input_matches?(suite_data, input_name, matches_pattern, not_matches_pattern)
899
+ value = suite_data.find { |input| input['name'] == input_name }&.dig('value')
900
+ string_matches?(value.to_s, matches_pattern, not_matches_pattern)
901
+ end
902
+
903
+ def comparison_config
904
+ @comparison_config ||= script_config['comparison_config'] || {}
905
+ end
906
+
907
+ def compare_options(expected_file)
908
+ {
909
+ expected_results_file: expected_file,
910
+ compare_messages: options[:compare_messages],
911
+ compare_result_message: options[:compare_result_message],
912
+ inferno_base_url: options[:inferno_base_url],
913
+ normalized_strings: Array(comparison_config['normalized_strings'])
914
+ }
915
+ end
916
+ end
917
+ end
918
+ end