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,390 @@
1
+ require 'csv'
2
+ require 'cgi'
3
+ require_relative 'session_details'
4
+ require_relative 'session_results'
5
+
6
+ module Inferno
7
+ module CLI
8
+ module Session
9
+ class SessionCompare < SessionResults
10
+ COMMAND_OPTIONS = {
11
+ expected_results_session: {
12
+ aliases: ['-s'],
13
+ type: :string,
14
+ desc: 'Session ID on the same server. The results of this indicated ' \
15
+ 'session will be used as the expected results. When the compared ' \
16
+ "session's results do not match, comparison details will not be " \
17
+ 'written to file (use the `-f` option).'
18
+ },
19
+ expected_results_file: {
20
+ aliases: ['-f'],
21
+ type: :string,
22
+ desc: 'Path to a file that contains the expected results. When the session ' \
23
+ 'results do not match the expected results in the file, generated ' \
24
+ 'comparison files will be placed in the same directory.'
25
+ },
26
+ compare_messages: {
27
+ aliases: ['-m'],
28
+ type: :boolean,
29
+ default: false,
30
+ desc: 'Compare messages when comparing results.'
31
+ },
32
+ compare_result_message: {
33
+ aliases: ['-r'],
34
+ type: :boolean,
35
+ default: false,
36
+ desc: 'Compare result_message when comparing results.'
37
+ },
38
+ normalized_strings: {
39
+ aliases: ['-n'],
40
+ type: :array,
41
+ desc: 'Literal strings or regexes to normalize away before comparing ' \
42
+ '(URL-encoded form of literal strings will also be normalized).'
43
+ }
44
+ }.freeze
45
+ def run
46
+ display_compared_results
47
+ if output_directory.present? && !results_match?
48
+ save_actual_results_to_file
49
+ save_comparison_csv_to_file
50
+ end
51
+
52
+ if results_match?
53
+ exit(0)
54
+ else
55
+ exit(3)
56
+ end
57
+ end
58
+
59
+ def results_timestamp
60
+ @results_timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
61
+ end
62
+
63
+ # Output directory is the dirname of the expected results file (-f).
64
+ # Returns nil when -f is not provided (e.g. session-to-session comparison),
65
+ # in which case no output files are written on mismatch.
66
+ def output_directory
67
+ options[:expected_results_file].present? && File.dirname(options[:expected_results_file])
68
+ end
69
+
70
+ def output_file_prefix
71
+ return '' unless options[:expected_results_file].present?
72
+
73
+ basename = File.basename(options[:expected_results_file])
74
+ basename.end_with?('expected.json') ? basename.sub(/expected\.json$/, '') : ''
75
+ end
76
+
77
+ def save_actual_results_to_file
78
+ actual_results_file_name = "#{output_file_prefix}actual_results_#{results_timestamp}.json"
79
+ File.write(File.join(output_directory, actual_results_file_name), session_results.to_json)
80
+ end
81
+
82
+ def save_comparison_csv_to_file
83
+ compared_csv_file_name = "#{output_file_prefix}compared_results_#{results_timestamp}.csv"
84
+ File.write(File.join(output_directory, compared_csv_file_name),
85
+ compared_results_as_csv)
86
+ end
87
+
88
+ def display_compared_results
89
+ output = {
90
+ matched: results_match?,
91
+ results: compared_results.map(&:to_h)
92
+ }
93
+ puts JSON.pretty_generate(output)
94
+ end
95
+
96
+ def compared_results_as_csv
97
+ CSV.generate do |csv|
98
+ csv << comparison_csv_header_row
99
+ compared_results.each do |result|
100
+ next unless result.different_result?
101
+
102
+ csv << result.comparison_csv_row
103
+ end
104
+ end
105
+ end
106
+
107
+ def normalizing?
108
+ options[:normalized_strings].present?
109
+ end
110
+
111
+ def comparison_csv_header_row
112
+ normalized_suffix = normalizing? ? ' (normalized)' : ''
113
+ header_row = ['id', 'short_id', 'type', 'different?', 'expected result', 'actual result']
114
+ if options[:compare_result_message]
115
+ header_row << 'result_message different?'
116
+ header_row << "expected result_message#{normalized_suffix}"
117
+ header_row << "actual result_message#{normalized_suffix}"
118
+ end
119
+ if options[:compare_messages]
120
+ header_row << 'messages different?'
121
+ header_row << "expected messages#{normalized_suffix}"
122
+ header_row << "actual messages#{normalized_suffix}"
123
+ end
124
+
125
+ header_row
126
+ end
127
+
128
+ def results_match?
129
+ compared_results.all?(&:same_result?)
130
+ end
131
+
132
+ def expected_results
133
+ @expected_results ||= if options[:expected_results_session].present?
134
+ results_for_session(options[:expected_results_session])
135
+ elsif options[:expected_results_file].present?
136
+ JSON.parse(File.read(options[:expected_results_file]))
137
+ else
138
+ puts({ errors: 'No expected results provided.' }.to_json)
139
+ exit(3)
140
+ end
141
+ end
142
+
143
+ def compared_results
144
+ @compared_results = match_result_ids(expected_results, session_results)
145
+ end
146
+
147
+ def session_details
148
+ @session_details ||= SessionDetails.new(session_id, options).details_for_session
149
+ end
150
+
151
+ def short_id_map
152
+ @short_id_map ||= build_short_id_map(session_details['test_suite'])
153
+ end
154
+
155
+ def build_short_id_map(runnable, map = {})
156
+ return map unless runnable.is_a?(Hash)
157
+
158
+ map[runnable['id']] = runnable['short_id'] if runnable['id'].present?
159
+ runnable['test_groups']&.each { |group| build_short_id_map(group, map) }
160
+ runnable['tests']&.each { |test| build_short_id_map(test, map) }
161
+ map
162
+ end
163
+
164
+ def match_result_ids(expected, actual)
165
+ expected_hash = results_hash_by_id(expected)
166
+ actual_hash = results_hash_by_id(actual)
167
+
168
+ compared_results = expected_hash.map do |id, result|
169
+ ComparedTestResult.new(id, result, actual_hash[id], options, short_id_map)
170
+ end
171
+ actual_hash.keys.reject { |id| expected_hash.key?(id) }.each do |id|
172
+ compared_results << ComparedTestResult.new(id, nil, actual_hash[id], options, short_id_map)
173
+ end
174
+
175
+ compared_results
176
+ end
177
+
178
+ def results_hash_by_id(results)
179
+ results.each_with_object({}) do |result, hash|
180
+ key = result['test_id'] || result['test_group_id'] || result['test_suite_id']
181
+ hash[key] = result
182
+ end
183
+ end
184
+
185
+ class ComparedTestResult
186
+ attr_reader :id, :expected_result, :actual_result, :options, :short_id_map
187
+
188
+ def initialize(id, expected_result, actual_result, options, short_id_map = {})
189
+ @id = id
190
+ @expected_result = expected_result
191
+ @actual_result = actual_result
192
+ @options = options
193
+ @short_id_map = short_id_map
194
+ @same = same_results?
195
+ end
196
+
197
+ def short_id
198
+ short_id_map[id]
199
+ end
200
+
201
+ # Parses a normalize entry into an array of [pattern, replacement] pairs.
202
+ # Entries may be:
203
+ # - A plain string: literal match, replacement defaults to '<NORMALIZED>'
204
+ # - A "/pattern/[flags]" string: compiled to Regexp, replacement defaults to '<NORMALIZED>'
205
+ # - A hash with 'pattern' or 'patterns' and optional 'replacement' keys (from YAML):
206
+ # pattern: '/code_challenge=[A-Za-z0-9+\/=_-]{20,}/'
207
+ # replacement: '<CODE_CHALLENGE>'
208
+ # Or multiple patterns sharing one replacement:
209
+ # patterns:
210
+ # - '/code_challenge=[A-Za-z0-9+\/=_-]{20,}/'
211
+ # - '/code_verifier=[A-Za-z0-9+\/=_-]{20,}/'
212
+ # replacement: '<PKCE_VALUE>'
213
+ def parse_normalize_entry(entry)
214
+ if entry.is_a?(Hash)
215
+ replacement = entry.fetch('replacement', '<NORMALIZED>')
216
+ Array(entry['patterns'] || entry['pattern']).map do |pattern|
217
+ [parse_pattern_string(pattern.to_s), replacement]
218
+ end
219
+ else
220
+ [[parse_pattern_string(entry.to_s), '<NORMALIZED>']]
221
+ end
222
+ end
223
+
224
+ def parse_pattern_string(str)
225
+ return str unless (parsed_regex = str.match(%r{\A/(.+)/([imx]*)\z}m))
226
+
227
+ flags = 0
228
+ flags |= Regexp::IGNORECASE if parsed_regex[2].include?('i')
229
+ flags |= Regexp::MULTILINE if parsed_regex[2].include?('m')
230
+ flags |= Regexp::EXTENDED if parsed_regex[2].include?('x')
231
+ Regexp.new(parsed_regex[1], flags)
232
+ end
233
+
234
+ def normalize_string(str)
235
+ return str unless str.present?
236
+
237
+ Array(options[:normalized_strings]).reduce(str) do |s, entry|
238
+ parse_normalize_entry(entry).reduce(s) do |s2, (pattern, replacement)|
239
+ if pattern.is_a?(Regexp)
240
+ s2.gsub(pattern, replacement)
241
+ else
242
+ s2.gsub(pattern, replacement).gsub(CGI.escape(pattern), replacement)
243
+ end
244
+ end
245
+ end
246
+ end
247
+
248
+ def normalizing?
249
+ options[:normalized_strings].present?
250
+ end
251
+
252
+ def same_results?
253
+ return false unless type == 'Compared'
254
+ return false unless expected_result['result'] == actual_result['result']
255
+
256
+ if options[:compare_result_message] &&
257
+ normalize_string(expected_result['result_message']) != normalize_string(actual_result['result_message'])
258
+ return false
259
+ end
260
+ return false if options[:compare_messages] && !same_messages?
261
+
262
+ true
263
+ end
264
+
265
+ def message_comparisons
266
+ @message_comparisons ||= build_message_comparisons
267
+ end
268
+
269
+ MESSAGE_TYPE_ORDER = { 'error' => 0, 'warning' => 1, 'info' => 2 }.freeze
270
+ UNKNOWN_MESSAGE_TYPE_ORDER = 99
271
+
272
+ def build_message_comparisons
273
+ expected_msgs = sorted_messages(expected_result)
274
+ actual_msgs = sorted_messages(actual_result)
275
+ max_length = [expected_msgs.size, actual_msgs.size].max
276
+ (0...max_length).map { |i| messages_match?(expected_msgs[i], actual_msgs[i]) }
277
+ end
278
+
279
+ def sorted_messages(result)
280
+ Array(result&.dig('messages')).sort_by do |m|
281
+ [MESSAGE_TYPE_ORDER.fetch(m['type'].to_s, UNKNOWN_MESSAGE_TYPE_ORDER), m['message'].to_s]
282
+ end
283
+ end
284
+
285
+ def messages_match?(expected_message, actual_message)
286
+ expected_message.present? && actual_message.present? && same_message?(expected_message, actual_message)
287
+ end
288
+
289
+ def same_messages?
290
+ return false unless expected_result['messages']&.size == actual_result['messages']&.size
291
+ return true unless expected_result['messages'].present?
292
+
293
+ message_comparisons.all?
294
+ end
295
+
296
+ def same_message?(expected_message, actual_message)
297
+ expected_message['type'] == actual_message['type'] &&
298
+ normalize_string(expected_message['message']) == normalize_string(actual_message['message'])
299
+ end
300
+
301
+ def same_result?
302
+ @same
303
+ end
304
+
305
+ def different_result?
306
+ !same_result?
307
+ end
308
+
309
+ def different_result_message?
310
+ return false unless type == 'Compared'
311
+
312
+ normalize_string(expected_result['result_message']) != normalize_string(actual_result['result_message'])
313
+ end
314
+
315
+ def different_messages?
316
+ return false unless type == 'Compared'
317
+
318
+ !same_messages?
319
+ end
320
+
321
+ def to_h
322
+ {
323
+ id: id,
324
+ type: type,
325
+ matched: same_result?,
326
+ expected_result: expected_result&.dig('result'),
327
+ actual_result: actual_result&.dig('result')
328
+ }.merge(optional_to_h_fields)
329
+ end
330
+
331
+ def optional_to_h_fields
332
+ fields = {}
333
+ if options[:compare_result_message]
334
+ fields[:expected_result_message] = expected_result&.dig('result_message')
335
+ fields[:actual_result_message] = actual_result&.dig('result_message')
336
+ end
337
+ if options[:compare_messages]
338
+ fields[:expected_messages] = expected_result&.dig('messages')
339
+ fields[:actual_messages] = actual_result&.dig('messages')
340
+ end
341
+ fields
342
+ end
343
+
344
+ def comparison_csv_row
345
+ row = [id, short_id, type, different_result?, expected_result&.dig('result'), actual_result&.dig('result')]
346
+ if options[:compare_result_message]
347
+ row << different_result_message?
348
+ row << normalize_string(expected_result&.dig('result_message'))
349
+ row << normalize_string(actual_result&.dig('result_message'))
350
+ end
351
+ if options[:compare_messages]
352
+ row << different_messages?
353
+ row << format_messages_for_csv(expected_result)
354
+ row << format_messages_for_csv(actual_result)
355
+ end
356
+ row
357
+ end
358
+
359
+ def type
360
+ if expected_result.nil?
361
+ 'Additional'
362
+ elsif actual_result.nil?
363
+ 'Missing'
364
+ else
365
+ 'Compared'
366
+ end
367
+ end
368
+
369
+ def format_messages_for_csv(results)
370
+ return '' unless results&.dig('messages').present?
371
+
372
+ sorted_messages(results).each_with_index.map do |message, index|
373
+ message_text_for_csv(message, index)
374
+ end.join("\n")
375
+ end
376
+
377
+ def message_text_for_csv(message, index)
378
+ prefix = message_comparisons[index] ? '- ' : '! '
379
+ text = normalize_string(message['message'].to_s)
380
+ .gsub("\r\n", '\n')
381
+ .gsub("\n", '\n')
382
+ .gsub("\r", '\r')
383
+ .gsub("\t", '\t')
384
+ "#{prefix}(#{message['type']}) \"#{text}\""
385
+ end
386
+ end
387
+ end
388
+ end
389
+ end
390
+ end
@@ -0,0 +1,39 @@
1
+ require_relative 'connection'
2
+ require_relative 'errors'
3
+
4
+ module Inferno
5
+ module CLI
6
+ module Session
7
+ class SessionData
8
+ include Connection
9
+ include Errors
10
+
11
+ attr_accessor :session_id, :options
12
+
13
+ def initialize(session_id, options)
14
+ self.session_id = session_id
15
+ self.options = options
16
+ end
17
+
18
+ def run
19
+ check_session_exists
20
+ inputs = session_data
21
+
22
+ puts JSON.pretty_generate(inputs)
23
+ exit(0)
24
+ end
25
+
26
+ def session_data
27
+ @session_data ||= data_for_session(session_id)
28
+ end
29
+
30
+ def data_for_session(id)
31
+ response = get("api/test_sessions/#{id}/session_data", nil, content_type: 'application/json')
32
+ handle_web_api_error(response, :session_data) if response.status != 200
33
+
34
+ JSON.parse(response.body)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,27 @@
1
+ require 'faraday'
2
+ require_relative 'connection'
3
+ require_relative 'errors'
4
+
5
+ module Inferno
6
+ module CLI
7
+ module Session
8
+ class SessionDetails
9
+ include Connection
10
+ include Errors
11
+
12
+ attr_accessor :session_id, :options
13
+
14
+ def initialize(session_id, options)
15
+ self.session_id = session_id
16
+ self.options = options
17
+ end
18
+
19
+ def details_for_session
20
+ response = get("api/test_sessions/#{session_id}", nil, content_type: 'application/json')
21
+ handle_web_api_error(response, :session_details) if response.status != 200
22
+ JSON.parse(response.body)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,39 @@
1
+ require_relative 'connection'
2
+ require_relative 'errors'
3
+
4
+ module Inferno
5
+ module CLI
6
+ module Session
7
+ class SessionResults
8
+ include Connection
9
+ include Errors
10
+
11
+ attr_accessor :session_id, :options
12
+
13
+ def initialize(session_id, options)
14
+ self.session_id = session_id
15
+ self.options = options
16
+ end
17
+
18
+ def run
19
+ check_session_exists
20
+ results = session_results
21
+
22
+ puts JSON.pretty_generate(results)
23
+ exit(0)
24
+ end
25
+
26
+ def session_results
27
+ @session_results ||= results_for_session(session_id)
28
+ end
29
+
30
+ def results_for_session(id)
31
+ response = get("api/test_sessions/#{id}/results", nil, content_type: 'application/json')
32
+ handle_web_api_error(response, :session_results) if response.status != 200
33
+
34
+ JSON.parse(response.body)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,69 @@
1
+ require 'faraday'
2
+ require_relative 'connection'
3
+ require_relative 'errors'
4
+
5
+ module Inferno
6
+ module CLI
7
+ module Session
8
+ class SessionStatus
9
+ include Connection
10
+ include Errors
11
+
12
+ attr_accessor :session_id, :options
13
+
14
+ def initialize(session_id, options)
15
+ self.session_id = session_id
16
+ self.options = options
17
+ end
18
+
19
+ def run
20
+ session_status = status_for_session
21
+ puts JSON.pretty_generate(session_status)
22
+ exit(0)
23
+ end
24
+
25
+ def status_for_session
26
+ session_status = last_test_run
27
+
28
+ if session_status['id'].present?
29
+ run_id = session_status['id']
30
+ last_test_executed = last_test_executed(run_id)
31
+ if last_test_executed.present?
32
+ session_status['last_test_executed'] = last_test_executed['test_id']
33
+ if session_status['status'] == 'waiting'
34
+ session_status['wait_outputs'] = last_test_executed['outputs']
35
+ session_status['wait_result_message'] = last_test_executed['result_message']
36
+ end
37
+ end
38
+ end
39
+
40
+ session_status
41
+ end
42
+
43
+ def last_test_run
44
+ response = get("api/test_sessions/#{session_id}/last_test_run", nil,
45
+ content_type: 'application/json')
46
+ handle_web_api_error(response, :last_session_run) if response.status != 200
47
+ return JSON.parse(response.body) if response.body.present?
48
+
49
+ # no execution has started yet for this session
50
+ {
51
+ 'test_session_id' => session_id,
52
+ 'status' => 'created'
53
+ }
54
+ end
55
+
56
+ def last_test_executed(run_id)
57
+ results = run_results(run_id)
58
+ results.sort_by { |r| r['updated_at'] }.reverse.find { |result| result['test_id'].present? }
59
+ end
60
+
61
+ def run_results(run_id)
62
+ response = get("api/test_runs/#{run_id}/results", nil, content_type: 'application/json')
63
+ handle_web_api_error(response, :test_run_results) if response.status != 200
64
+ JSON.parse(response.body)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end