inferno_core 1.1.1 → 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.
- checksums.yaml +4 -4
- data/lib/inferno/apps/cli/execute_script.rb +918 -0
- data/lib/inferno/apps/cli/main.rb +46 -0
- data/lib/inferno/apps/cli/session/cancel_run.rb +47 -0
- data/lib/inferno/apps/cli/session/connection.rb +47 -0
- data/lib/inferno/apps/cli/session/create_session.rb +159 -0
- data/lib/inferno/apps/cli/session/errors.rb +45 -0
- data/lib/inferno/apps/cli/session/session_compare.rb +390 -0
- data/lib/inferno/apps/cli/session/session_data.rb +39 -0
- data/lib/inferno/apps/cli/session/session_details.rb +27 -0
- data/lib/inferno/apps/cli/session/session_results.rb +39 -0
- data/lib/inferno/apps/cli/session/session_status.rb +69 -0
- data/lib/inferno/apps/cli/session/start_run.rb +245 -0
- data/lib/inferno/apps/cli/session_commands.rb +66 -0
- data/lib/inferno/apps/cli/templates/%library_name%.gemspec.tt +1 -1
- data/lib/inferno/apps/cli/templates/.gitignore +4 -0
- data/lib/inferno/apps/cli/templates/README.md.tt +14 -0
- data/lib/inferno/apps/cli/templates/Rakefile.tt +13 -0
- data/lib/inferno/apps/cli/templates/execution_scripts/%library_name%_script.yaml.tt +20 -0
- data/lib/inferno/apps/cli/templates/execution_scripts/%library_name%_script_expected.json.tt +244 -0
- data/lib/inferno/apps/cli/templates/execution_scripts/README.md.tt +16 -0
- data/lib/inferno/apps/web/serializers/test_group.rb +1 -0
- data/lib/inferno/dsl/fhir_resource_navigation.rb +145 -27
- data/lib/inferno/dsl/must_support_assessment.rb +93 -23
- data/lib/inferno/dsl/must_support_metadata_extractor.rb +139 -21
- data/lib/inferno/dsl/resume_test_route.rb +4 -3
- data/lib/inferno/exceptions.rb +6 -0
- data/lib/inferno/public/bundle.js +9 -9
- data/lib/inferno/repositories/test_sessions.rb +3 -0
- data/lib/inferno/utils/execution_script_runner.rb +90 -0
- data/lib/inferno/utils/preset_processor.rb +2 -0
- data/lib/inferno/version.rb +1 -1
- 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
|