testscript_engine 0.0.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.
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
4
+ # #
5
+ # TestReportHandler Module #
6
+ # #
7
+ # The TestReportHandler (handler) module is intended to be imported into the #
8
+ # TestScriptRunnable (runnable) class. The handler instantiates a #
9
+ # TestReportBuilder (builder) object tailored to the parent runnable instance. #
10
+ # In executing a runnable, calls (i.e. 'Pass', 'Fail') are made to the handler #
11
+ # module -- which then directs the builder instance to update its report #
12
+ # accordingly. Think of the handler as the 'API' to interact with the #
13
+ # TestReport output by the execution of a runnable. Each time the runnable is #
14
+ # executed, it instantiates a new builder instance, using the initial builder #
15
+ # as a template. #
16
+ # #
17
+ # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
18
+
19
+ module TestReportHandler
20
+ def testreport
21
+ report_builder.report
22
+ end
23
+
24
+ def fresh_testreport
25
+ @report_builder = nil
26
+ end
27
+
28
+ def report_builder
29
+ @report_builder ||= fresh_builder
30
+ end
31
+
32
+ def fresh_builder
33
+ builder_template.clone
34
+ end
35
+
36
+ def builder_template
37
+ @builder_template ||= TestReportBuilder.new(script)
38
+ end
39
+
40
+ # TODO: Remove!
41
+ def pass(message = nil)
42
+ report_builder.pass
43
+ end
44
+
45
+ def fail(message = nil)
46
+ report_builder.fail(message)
47
+ end
48
+
49
+ def skip(message = nil)
50
+ report_builder.skip(message)
51
+ end
52
+
53
+ def warning(message = nil)
54
+ report_builder.warning(message)
55
+ end
56
+
57
+ def error(message = nil)
58
+ report_builder.error(message)
59
+ end
60
+
61
+ def finalize_report
62
+ report_builder.finalize_report
63
+ testreport
64
+ end
65
+
66
+ def cascade_skips(number_to_skip)
67
+ while number_to_skip > 0
68
+ report_builder.skip
69
+ number_to_skip -= 1
70
+ end
71
+ end
72
+
73
+ # A 'script' method ought to be defined in the klass
74
+ # that includes the handler - if 'script' is undefined,
75
+ # this feeds an empty testscript to the builder
76
+ def script
77
+ begin
78
+ super
79
+ rescue NoMethodError
80
+ FHIR::TestScript.new
81
+ end
82
+ end
83
+
84
+ class TestReportBuilder
85
+ attr_accessor :pass_count, :total_test_count, :actions
86
+
87
+ def actions
88
+ @actions ||= []
89
+ end
90
+
91
+ def report
92
+ @report ||= FHIR::TestReport.new
93
+ end
94
+
95
+ def initialize(testscript_blueprint = nil)
96
+ add_boilerplate(testscript_blueprint)
97
+ build_setup(testscript_blueprint.setup)
98
+ build_test(testscript_blueprint.test)
99
+ build_teardown(testscript_blueprint.teardown)
100
+
101
+ self.pass_count = 0
102
+ self.total_test_count = actions.length
103
+ end
104
+
105
+ def build_setup(setup_blueprint)
106
+ return unless setup_blueprint
107
+
108
+ actions = setup_blueprint.action.map { |action| build_action(action) }
109
+ report.setup = FHIR::TestReport::Setup.new(action: actions)
110
+ end
111
+
112
+ def build_test(test_blueprint)
113
+ return if test_blueprint.empty?
114
+
115
+ report.test = test_blueprint.map do |test|
116
+ actions = test.action.map { |action| build_action(action) }
117
+ FHIR::TestReport::Test.new(action: actions)
118
+ end
119
+ end
120
+
121
+ def build_teardown(teardown_blueprint)
122
+ return unless teardown_blueprint
123
+
124
+ actions = teardown_blueprint.action.map { |action| build_action(action) }
125
+ report.teardown = FHIR::TestReport::Teardown.new(action: actions)
126
+ end
127
+
128
+ def build_action(action_blueprint)
129
+ phase = action_blueprint.class.to_s.split("::")[2]
130
+
131
+ action_definition = {
132
+ id: action_blueprint.id,
133
+ operation: build_operation(action_blueprint.operation),
134
+ assert: (build_assert(action_blueprint.assert) unless phase == 'Teardown')
135
+ }
136
+
137
+ "FHIR::TestReport::#{phase}::Action".constantize.new(action_definition)
138
+ end
139
+
140
+ def build_operation(operation_blueprint)
141
+ return unless operation_blueprint
142
+
143
+ operation_def = {
144
+ id: operation_blueprint.label || operation_blueprint.id || 'unlabeled operation',
145
+ message: operation_blueprint.description
146
+ }
147
+
148
+ operation = FHIR::TestReport::Setup::Action::Operation.new(operation_def)
149
+ store_action(operation)
150
+ operation
151
+ end
152
+
153
+ def build_assert(assert_blueprint)
154
+ return unless assert_blueprint
155
+
156
+ assert_def = {
157
+ id: assert_blueprint.label || assert_blueprint.id || 'unlabeled assert',
158
+ message: assert_blueprint.description
159
+ }
160
+
161
+ assert = FHIR::TestReport::Setup::Action::Assert.new(assert_def)
162
+ store_action(assert)
163
+ assert
164
+ end
165
+
166
+ def add_boilerplate(testscript_blueprint)
167
+ report.result = 'pending'
168
+ report.status = 'in-progress'
169
+ report.tester = 'The MITRE Corporation'
170
+ report.id = testscript_blueprint.id&.gsub(/(?i)testscript/, 'testreport')
171
+ report.name = testscript_blueprint.name&.gsub(/(?i)testscript/, 'TestReport')
172
+ report.testScript = FHIR::Reference.new({
173
+ reference: testscript_blueprint.url,
174
+ type: "http://hl7.org/fhir/R4B/testscript.html"
175
+ })
176
+ end
177
+
178
+ def finalize_report
179
+ report.issued = DateTime.now.to_s
180
+ report.status = 'completed'
181
+ report.score = (self.pass_count.to_f / self.total_test_count).round(2) * 100
182
+ report.result = (report.score == 100.0 ? 'pass' : 'fail')
183
+ end
184
+
185
+ def action
186
+ actions.first
187
+ end
188
+
189
+ def store_action(action)
190
+ actions.concat(Array(action))
191
+ end
192
+
193
+ def next_action
194
+ actions.shift
195
+ finalize_report if actions.empty?
196
+ end
197
+
198
+ def pass
199
+ action.result = 'pass'
200
+ self.pass_count += 1
201
+ next_action
202
+ end
203
+
204
+ def skip(message = nil)
205
+ action.result = 'skip'
206
+ action.message = message if message
207
+ self.total_test_count -= 1
208
+ next_action
209
+ end
210
+
211
+ def warning(message = nil)
212
+ action.result = 'warning'
213
+ action.message = message if message
214
+ next_action
215
+ end
216
+
217
+ def fail(message = nil)
218
+ action.result = 'fail'
219
+ action.message = message if message
220
+ next_action
221
+ end
222
+
223
+ def error(message = nil)
224
+ action.result = 'error'
225
+ action.message = message if message
226
+ next_action
227
+ end
228
+
229
+ def clone
230
+ builder_dup = self.deep_dup
231
+ builder_dup.actions.clear
232
+ builder_dup.instance_eval('@report = FHIR::TestReport.new(self.report.to_hash)')
233
+
234
+ clone_actions(builder_dup.report.setup, builder_dup)
235
+ builder_dup.report.test.each { |test| clone_actions(test, builder_dup) }
236
+ clone_actions(builder_dup.report.teardown, builder_dup)
237
+
238
+ builder_dup
239
+ end
240
+
241
+ def clone_actions(report_phase, clone)
242
+ report_phase.try(:action)&.each do |action|
243
+ clone.store_action(action.operation || action.assert)
244
+ end
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'operation'
3
+ require_relative 'assertion'
4
+ require_relative 'message_handler'
5
+ require_relative 'testreport_handler'
6
+
7
+ class TestScriptRunnable
8
+ include Operation
9
+ include Assertion
10
+ prepend MessageHandler
11
+ include TestReportHandler
12
+
13
+ attr_accessor :script, :client, :reply
14
+
15
+ def id_map
16
+ @id_map ||= {}
17
+ end
18
+
19
+ def fixtures
20
+ @fixtures ||= {}
21
+ end
22
+
23
+ def request_map
24
+ @request_map ||= {}
25
+ end
26
+
27
+ def response_map
28
+ @response_map ||= {}
29
+ end
30
+
31
+ def autocreate
32
+ @autocreate ||= []
33
+ end
34
+
35
+ def autodelete_ids
36
+ @autodelete_ids ||= []
37
+ end
38
+
39
+ def initialize(script)
40
+ raise ArgumentError.new(messages(:bad_script)) unless script.is_a?(FHIR::TestScript)
41
+ raise ArgumentError.new(messages(:invalid_script_input)) unless script.valid?
42
+
43
+ @script = script
44
+ load_fixtures
45
+ end
46
+
47
+ def run(client)
48
+ @client = client
49
+
50
+ fresh_testreport
51
+
52
+ preprocess
53
+ setup
54
+ test
55
+ teardown
56
+ postprocessing
57
+
58
+ finalize_report
59
+ end
60
+
61
+ def preprocess
62
+ return info(:no_preprocess) if autocreate.empty?
63
+ autocreate.each do |fixture|
64
+ begin
65
+ client.send(*build_request((create_operation(fixture))))
66
+ rescue => e
67
+ error(:uncaught_error, e.message)
68
+ end
69
+ end
70
+ end
71
+
72
+ def setup
73
+ return info(:no_setup) unless script.setup
74
+ handle_actions(script.setup.action, true)
75
+ end
76
+
77
+ def test
78
+ script.test.each { |test| handle_actions(test.action, false) }
79
+ end
80
+
81
+ def teardown
82
+ return info(:no_teardown) unless script.teardown
83
+ handle_actions(script.teardown.action, false)
84
+ end
85
+
86
+ def postprocessing
87
+ return info(:no_postprocess) if autocreate.empty?
88
+
89
+ autodelete_ids.each do |fixture_id|
90
+ begin
91
+ client.send(*build_request((delete_operation(fixture_id))))
92
+ rescue => e
93
+ error(:uncaught_error, e.message)
94
+ end
95
+ end
96
+
97
+ @ended = nil
98
+ @id_map = {}
99
+ @request_map = {}
100
+ @response_map = {}
101
+ end
102
+
103
+ def handle_actions(actions, end_on_fail)
104
+ return abort_test(actions) if @ended
105
+ @modify_report = true
106
+ current_action = 0
107
+
108
+ begin
109
+ actions.each do |action|
110
+ current_action += 1
111
+ if action.operation
112
+ execute(action.operation)
113
+ elsif action.respond_to?(:assert)
114
+ begin
115
+ evaluate(action.assert)
116
+ rescue AssertionException => ae
117
+ if ae.outcome == :skip
118
+ skip(:eval_assert_result, ae.details)
119
+ elsif ae.outcome == :fail
120
+ next warning(:eval_assert_result, ae.details) if action.assert.warningOnly
121
+ if end_on_fail
122
+ @ended = true
123
+ fail(:eval_assert_result, ae.details)
124
+ cascade_skips_with_message(actions, current_action) unless current_action == actions.length
125
+ return
126
+ else
127
+ fail(:eval_assert_result, ae.details)
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+ rescue OperationException => oe
134
+ error(oe.details)
135
+ if end_on_fail
136
+ @ended = true
137
+ cascade_skips_with_message(actions, current_action) unless current_action == actions.length
138
+ end
139
+ rescue => e
140
+ error(:uncaught_error, e.message)
141
+ cascade_skips_with_message(actions, current_action) unless current_action == actions.length
142
+ end
143
+
144
+ @modify_report = false
145
+ end
146
+
147
+ def cascade_skips_with_message(actions, current_action)
148
+ actions_to_skip = actions.slice(current_action, actions.length)
149
+ cascade_skips(:skip_on_fail, actions_to_skip, actions_to_skip.length)
150
+ end
151
+
152
+ def abort_test(actions_to_skip)
153
+ cascade_skips(:abort_test, actions_to_skip, 'setup', actions_to_skip.length)
154
+ end
155
+
156
+ def load_fixtures
157
+ script.fixture.each do |fixture|
158
+ next warning(:no_static_fixture_id) unless fixture.id
159
+ next warning(:no_static_fixture_resource) unless fixture.resource
160
+
161
+ resource = get_resource_from_ref(fixture.resource)
162
+ next warning(:bad_static_fixture_reference) unless resource
163
+
164
+ fixtures[fixture.id] = resource
165
+ autocreate << fixture.id if fixture.autocreate
166
+ autodelete_ids << fixture.id if fixture.autodelete
167
+ end
168
+ end
169
+
170
+ def get_resource_from_ref(reference)
171
+ return warning(:bad_reference) unless reference.is_a?(FHIR::Reference)
172
+
173
+ ref = reference.reference
174
+ return warning(:no_reference) unless ref
175
+ return warning(:unsupported_ref, ref) if ref.start_with? 'http'
176
+
177
+ if ref.start_with? '#'
178
+ contained = script.contained.find { |r| r.id == ref[1..] }
179
+ return contained || warning(:no_contained_resource, ref)
180
+ end
181
+
182
+ begin
183
+ fixture_path = script.url.split('/')[0...-1].join('/') + '/fixtures'
184
+ filepath = File.expand_path(ref, File.absolute_path(fixture_path))
185
+ file = File.open(filepath, 'r:UTF-8', &:read)
186
+ file.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '')
187
+ resource = FHIR.from_contents(file)
188
+ info(:loaded_static_fixture, ref, script.id)
189
+ return resource
190
+ rescue => e
191
+ warning(:resource_extraction, ref, e.message)
192
+ end
193
+ end
194
+
195
+ def storage(op)
196
+ @reply = client.reply
197
+ reply.nil? ? return : client.reply = nil
198
+
199
+ request_map[op.requestId] = reply.request if op.requestId
200
+ response_map[op.responseId] = reply.response if op.responseId
201
+
202
+ (reply.resource = FHIR.from_contents(reply.response&.[](:body).to_s)) rescue {}
203
+ (reply.response[:body] = reply.resource)
204
+ response_map[op.responseId][:body] = reply.resource if reply.resource and response_map[op.responseId]
205
+
206
+ if op.targetId and (reply.request[:method] == :delete) and [200, 201, 204].include?(reply.response[:code])
207
+ id_map.delete(op.targetId) and return
208
+ end
209
+
210
+ dynamic_id = reply.resource&.id || begin
211
+ reply.response&.[](:headers)&.[]('location')&.remove(reply.request[:url].to_s)&.split('/')&.[](2)
212
+ end
213
+
214
+ id_map[op.responseId] = dynamic_id if op.responseId and dynamic_id
215
+ id_map[op.sourceId] = dynamic_id if op.sourceId and dynamic_id
216
+ return
217
+ end
218
+
219
+ def find_resource id
220
+ fixtures[id] || response_map[id]&.[](:body)
221
+ end
222
+
223
+ def replace_variables placeholder
224
+ return placeholder unless placeholder&.include? '${'
225
+ replaced = placeholder.clone
226
+
227
+ script.variable.each do |var|
228
+ next unless replaced.include? "${#{var.name}}"
229
+ replacement = evaluate_variable(var)
230
+ replaced.gsub!("${#{var.name}}", replacement) if replacement
231
+ end
232
+
233
+ return replaced
234
+ end
235
+
236
+ def evaluate_variable var
237
+ if var.expression
238
+ evaluate_expression(var.expression, find_resource(var.sourceId))
239
+ elsif var.path
240
+ evaluate_path(var.path, find_resource(var.sourceId))
241
+ elsif var.headerField
242
+ headers = response_map[var.sourceId]&.[](:headers)
243
+ headers&.find { |h, v| h == var.headerField || h == var.headerField.downcase }&.last
244
+ end || var.defaultValue
245
+ end
246
+
247
+ def evaluate_expression(expression, resource)
248
+ return unless expression and resource
249
+
250
+ return FHIRPath.evaluate(expression, resource.to_hash)
251
+ end
252
+ end
@@ -0,0 +1,134 @@
1
+ require 'pry-nav'
2
+ require 'fhir_client'
3
+ require 'fhir_models'
4
+ require_relative 'testscript_engine/testscript_runnable'
5
+ require_relative 'testscript_engine/message_handler'
6
+
7
+ class TestScriptEngine
8
+ prepend MessageHandler
9
+
10
+ attr_accessor :endpoint, :testscript_path, :testreport_path
11
+
12
+ def scripts
13
+ @scripts ||= {}
14
+ end
15
+
16
+ def runnables
17
+ @runnables ||= {}
18
+ end
19
+
20
+ def reports
21
+ @reports ||= {}
22
+ end
23
+
24
+ def client
25
+ @client ||= begin
26
+ info(:begin_initialize_client)
27
+ client = FHIR::Client.new(endpoint || 'localhost:3000')
28
+ info(:finish_initialize_client)
29
+ client
30
+ end
31
+ end
32
+
33
+ def initialize(endpoint, testscript_path, testreport_path)
34
+ self.endpoint = endpoint
35
+ self.testscript_path = testscript_path
36
+ self.testreport_path = testreport_path
37
+ self.debug_mode = true
38
+ end
39
+
40
+ # TODO: Tie-in stronger validation. Possibly, Inferno validator.
41
+ def valid_testscript? script
42
+ return (script.is_a? FHIR::TestScript) && script.valid?
43
+ end
44
+
45
+ # @path [String] Optional, specifies the path to the folder containing the
46
+ # TestScript Resources to-be loaded into the engine.
47
+ def load_scripts
48
+ if File.file?(testscript_path)
49
+ on_deck = [testscript_path]
50
+ elsif File.directory?(testscript_path)
51
+ on_deck = Dir.glob (["#{testscript_path}/**/*.{json}", "#{testscript_path}/**/*.{xml}"])
52
+ end
53
+ on_deck.each do |resource|
54
+ next if resource.include? "/fixtures/"
55
+
56
+ begin
57
+ script = FHIR.from_contents File.read(resource)
58
+ if valid_testscript? script
59
+ script.url = resource
60
+ if scripts[script.id]
61
+ info(:overwrite_existing_script, script.id)
62
+ else
63
+ info(:loaded_script, script.id)
64
+ end
65
+ scripts[script.id] = script
66
+ else
67
+ info(:invalid_script, resource)
68
+ end
69
+ rescue
70
+ info(:bad_serialized_script, resource)
71
+ end
72
+ end
73
+ end
74
+
75
+ # @script [FHIR::TestScript] Optional, a singular TestScript resource to be
76
+ # transformed into a runnable. If no resource is
77
+ # given, all stored TestScript are by default
78
+ # transformed into and stored as runnables.
79
+ def make_runnables script = nil
80
+ begin
81
+ if valid_testscript? script
82
+ runnables[script.id] = TestScriptRunnable.new script
83
+ info(:created_runnable, script.id)
84
+ else
85
+ scripts.each do |_, script|
86
+ runnables[script.id] = TestScriptRunnable.new script
87
+ info(:created_runnable, script.id)
88
+ end
89
+ end
90
+ rescue => e
91
+ error(:unable_to_create_runnable, script.id)
92
+ end
93
+ end
94
+
95
+ # TODO: Clean-up, possibly modularize into a pretty_print type method
96
+ # @runnable_id [String] Optional, specifies the id of the runnable to be
97
+ # tested against the endpoint.
98
+ def execute_runnables runnable_id = nil
99
+ if runnable_id
100
+ if runnables[runnable_id]
101
+ reports[runnable_id] = runnables[runnable_id].run(client)
102
+ else
103
+ error(:unable_to_locate_runnable, runnable_id)
104
+ end
105
+ else
106
+ runnables.each do |id, runnable|
107
+ reports[id] = runnable.run(client)
108
+ end
109
+ end
110
+ end
111
+
112
+ def verify_runnable(runnable_id)
113
+ return true unless runnables[runnable_id].nil?
114
+ false
115
+ end
116
+
117
+ def new_client(url)
118
+ @client = nil
119
+ @endpoint = url
120
+ end
121
+
122
+ # @path [String] Optional, specifies the path to the folder which the
123
+ # TestReport resources should be written to.
124
+ def write_reports path = nil
125
+ report_directory = path || testreport_path
126
+ FileUtils.mkdir_p report_directory
127
+
128
+ reports.each do |_, report|
129
+ File.open("#{report_directory}/#{report.name.downcase.split(' ')[1...].join('_')}.json", 'w') do |f|
130
+ f.write(report.to_json)
131
+ end
132
+ end
133
+ end
134
+ end