testscript_engine 0.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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