diff --git a/Gemfile.lock b/Gemfile.lock index c3a5cbb86..7a94d2b1e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -18,6 +18,7 @@ PATH hanami-controller (= 2.0.0) hanami-router (= 2.0.0) oj (= 3.11.0) + pastel (~> 0.8.0) pry pry-byebug puma (~> 5.6.7) diff --git a/dev_suites/dev_validator_suite/validator_suite.rb b/dev_suites/dev_validator_suite/validator_suite.rb new file mode 100644 index 000000000..edc848b00 --- /dev/null +++ b/dev_suites/dev_validator_suite/validator_suite.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module DevValidatorSuite + class ValidatorSuite < Inferno::TestSuite + title 'Validator Suite' + id :dev_validator + description 'Inferno Core Developer Suite that makes calls to the HL7 Validator.' + + input :url, + title: 'FHIR Server Base Url' + + input :access_token, + title: 'Bearer/Access Token', + optional: true + + fhir_client do + url :url + bearer_token :access_token + end + + fhir_resource_validator do + url 'http://localhost/hl7validatorapi' + end + + group do + title 'Patient Test Group' + id :patient_group + + input :patient_id, + title: 'Patient ID' + + test do + title 'Patient Read Test' + id :patient_read_test + + makes_request :patient_read + + run do + fhir_read(:patient, patient_id, name: :patient_read) + + assert_response_status 200 + end + end + + test do + title 'Patient Validate Test' + id :patient_validate_test + + uses_request :patient_read + + run do + assert_resource_type(:patient) + assert_valid_resource + end + end + end + end +end diff --git a/inferno_core.gemspec b/inferno_core.gemspec index a8572f2d9..e3c08b516 100644 --- a/inferno_core.gemspec +++ b/inferno_core.gemspec @@ -27,6 +27,7 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency 'hanami-controller', '2.0.0' spec.add_runtime_dependency 'hanami-router', '2.0.0' spec.add_runtime_dependency 'oj', '3.11.0' + spec.add_runtime_dependency 'pastel', '~> 0.8.0' spec.add_runtime_dependency 'pry' spec.add_runtime_dependency 'pry-byebug' spec.add_runtime_dependency 'puma', '~> 5.6.7' diff --git a/lib/inferno/apps/cli.rb b/lib/inferno/apps/cli.rb index 7018b444e..b5db5f430 100644 --- a/lib/inferno/apps/cli.rb +++ b/lib/inferno/apps/cli.rb @@ -1,5 +1,4 @@ require 'thor' - require_relative 'cli/main' module Inferno diff --git a/lib/inferno/apps/cli/execute.rb b/lib/inferno/apps/cli/execute.rb new file mode 100644 index 000000000..9284ce975 --- /dev/null +++ b/lib/inferno/apps/cli/execute.rb @@ -0,0 +1,227 @@ +require 'pastel' +require 'active_support' +require_relative '../../utils/verify_runnable' +require_relative '../../utils/persist_inputs' +require_relative 'execute/console_outputter' + +module Inferno + module CLI + class Execute + include ::Inferno::Utils::VerifyRunnable + include ::Inferno::Utils::PersistInputs + + attr_accessor :options + + def self.suppress_output + begin + original_stdout = $stdout.clone + $stdout.reopen(File.new(File::NULL, 'w+')) + retval = yield + ensure + $stdout.reopen(original_stdout) + end + retval + end + + def self.boot_full_inferno + ENV['NO_DB'] = 'false' + + # Inferno boot flow triggers migration and logger outputs it + Inferno::CLI::Execute.suppress_output { require_relative '../../../inferno' } + + Inferno::Application.start(:executor) + end + + def run(options) + print_help_and_exit if options[:help] + + self.options = options + + outputter.print_start_message(options) + + results = [] + outputter.print_around_run(options) do + if selected_runnables.empty? + run_one(suite) + results = test_runs_repo.results_for_test_run(test_run(suite).id).reverse + else + selected_runnables.each do |runnable| + run_one(runnable) + results += test_runs_repo.results_for_test_run(test_run(runnable).id).reverse + end + end + end + + outputter.print_results(options, results) + + outputter.print_end_message(options) + + exit(0) if results.all? { |result| result.result == 'pass' } + + # exit(1) is for Thor failures + # exit(2) is for shell builtin failures + exit(3) + rescue Sequel::ValidationFailed => e + print_error_and_exit(e, 4) + rescue Sequel::ForeignKeyConstraintViolation => e + print_error_and_exit(e, 5) + rescue Inferno::Exceptions::RequiredInputsNotFound => e + print_error_and_exit(e, 6) + rescue Inferno::Exceptions::NotUserRunnableException => e + print_error_and_exit(e, 7) + rescue StandardError => e + print_error_and_exit(e, 8) + end + + def print_help_and_exit + puts `NO_DB=true bundle exec inferno help execute` + exit(0) + end + + def outputter + # TODO: swap outputter based on options + @outputter ||= Inferno::CLI::Execute::ConsoleOutputter.new + end + + def selected_runnables + groups + tests + end + + def run_one(runnable) + verify_runnable( + suite, + thor_hash_to_inputs_array(options[:inputs]), + test_session.suite_options + ) + + persist_inputs(session_data_repo, create_params(test_session, suite), test_run(runnable)) + + dispatch_job(test_run(runnable)) + end + + def suite + @suite ||= Inferno::Repositories::TestSuites.new.find(options[:suite]) + + raise StandardError, "Test suite #{options[:suite]} not found" if @suite.nil? + + @suite + end + + def test_runs_repo + @test_runs_repo ||= Inferno::Repositories::TestRuns.new + end + + def test_run(runnable_param) + @test_runs ||= {} + + @test_runs[runnable_param] ||= test_runs_repo.create( + create_params(test_session, runnable_param).merge({ status: 'queued' }) + ) + + @test_runs[runnable_param] + end + + def test_groups_repo + @test_groups_repo ||= Inferno::Repositories::TestGroups.new + end + + def tests_repo + @tests_repo ||= Inferno::Repositories::Tests.new + end + + def test_sessions_repo + @test_sessions_repo ||= Inferno::Repositories::TestSessions.new + end + + def session_data_repo + @session_data_repo ||= Inferno::Repositories::SessionData.new + end + + def test_session + @test_session ||= test_sessions_repo.create({ + test_suite_id: suite.id, + suite_options: thor_hash_to_suite_options_array( + options[:suite_options] + ) + }) + end + + def create_params(test_session, runnable) + { + test_session_id: test_session.id, + runnable_id_key(runnable) => runnable.id, + inputs: thor_hash_to_inputs_array(options[:inputs]) + } + end + + def dispatch_job(test_run) + # TODO: move suppression to outputter? better suppression? + if options[:verbose] + Jobs.perform(Jobs::ExecuteTestRun, test_run.id, force_synchronous: true) + else + Inferno::CLI::Execute.suppress_output do + Jobs.perform(Jobs::ExecuteTestRun, test_run.id, force_synchronous: true) + end + end + end + + def groups + return [] if options[:groups].blank? + + @groups ||= options[:groups]&.map { |short_id| find_by_short_id(test_groups_repo, short_id) } + end + + def tests + return [] if options[:tests].blank? + + @tests ||= options[:tests]&.map { |short_id| find_by_short_id(tests_repo, short_id) } + end + + def find_by_short_id(repo, short_id) + repo.all.each do |entity| + return entity if short_id == entity.short_id && suite.id == entity.suite.id + end + raise StandardError, "Group or test #{short_id} not found" + end + + def thor_hash_to_suite_options_array(hash = {}) + hash.to_a.map { |pair| Inferno::DSL::SuiteOption.new({ id: pair[0], value: pair[1] }) } + end + + def thor_hash_to_inputs_array(hash = {}) + hash.to_a.map { |pair| { name: pair[0], value: pair[1] } } + end + + def print_error_and_exit(err, code) + outputter.print_error(options || {}, err) + rescue StandardError => e + puts "Caught exception #{e} while printing exception #{err}. Exiting." + ensure + exit(code) + end + + def runnable_type(runnable) + if Inferno::TestSuite.subclasses.include? runnable + :suite + elsif Inferno::TestGroup.subclasses.include? runnable + :group + elsif Inferno::Test.subclasses.include? runnable + :test + else + raise StandardError, "Unidentified runnable #{runnable}" + end + end + + def runnable_id_key(runnable) + case runnable_type(runnable) + when :suite + :test_suite_id + when :group + :test_group_id + else + :test_id + end + end + end + end +end diff --git a/lib/inferno/apps/cli/execute/console_outputter.rb b/lib/inferno/apps/cli/execute/console_outputter.rb new file mode 100644 index 000000000..d7cf17cb7 --- /dev/null +++ b/lib/inferno/apps/cli/execute/console_outputter.rb @@ -0,0 +1,149 @@ +require 'pastel' +require_relative '../../web/serializers/test_run' +require_relative '../../web/serializers/result' + +module Inferno + module CLI + class Execute + # @private + class ConsoleOutputter + COLOR = Pastel.new + CHECKMARK = "\u2713".freeze + BAR = ('=' * 80).freeze + + def print_start_message(options) + puts '' + puts BAR + puts "Testing #{options[:suite] || options[:group] || options[:test]}" + puts BAR + end + + def print_around_run(_options) + puts 'Running tests. This may take a while...' + # TODO: spinner/progress bar + yield + end + + def print_results(options, results) + verbose_print_json_results(options, results) + + puts BAR + puts 'Test Results:' + puts BAR + results.each do |result| + print format_tag(result), ': ', format_result(result), "\n" + verbose_puts(options, "\tsummary: ", result.result_message) + verbose_puts(options, "\tmessages: ", format_messages(result)) + verbose_puts(options, "\trequests: ", format_requests(result)) + verbose_puts(options, "\tinputs: ", format_inputs(result)) + verbose_puts(options, "\toutputs: ", format_outputs(result)) + end + puts BAR + end + + def print_end_message(options); end + + def print_error(options, exception) + puts COLOR.red "Error: #{exception.full_message}" + verbose_print(options, exception.backtrace&.join('\n')) + end + + # private + + def verbose_print(options, *args) + print(COLOR.dim(*args)) if options[:verbose] + end + + def verbose_puts(options, *args) + args.push("\n") + verbose_print(options, *args) + end + + def format_tag(result) + if result.runnable.respond_to?(:short_id) + "#{result.runnable.short_id} #{format_tag_suffix(result)}" + else + format_tag_suffix(result) + end + end + + def format_tag_suffix(result) + result.runnable.short_title.presence || result.runnable.title.presence || result.runnable.id + end + + def format_messages(result) + result.messages.map do |message| + "\n\t\t#{message.type}: #{message.message}" + end.join + end + + def format_requests(result) + result.requests.map do |req_res| + "\n\t\t#{req_res.status} #{req_res.verb.upcase} #{req_res.url}" + end.join + end + + def format_session_data(result, attr) + json = result.send(attr) + return '' if json.nil? + + JSON.parse(json).map do |hash| + "\n\t\t#{hash['name']}: #{hash['value']}" + end.join + end + + def format_inputs(result) + format_session_data(result, :input_json) + end + + def format_outputs(result) + format_session_data(result, :output_json) + end + + def format_result(result) # rubocop:disable Metrics/CyclomaticComplexity + case result.result + when 'pass' + COLOR.bold.green(CHECKMARK, ' pass') + when 'fail' + COLOR.bold.red 'X fail' + when 'skip' + COLOR.yellow '* skip' + when 'omit' + COLOR.blue '* omit' + when 'error' + COLOR.magenta 'X error' + when 'wait' + COLOR.bold '. wait' + when 'cancel' + COLOR.red 'X cancel' + when 'running' + COLOR.bold '- running' + else + raise StandardError.new, "Unrecognized result #{result.result}" + end + end + + def verbose_print_json_results(options, results) + verbose_puts(options, BAR) + verbose_puts(options, 'JSON Test Results:') + verbose_puts(options, BAR) + verbose_puts(options, serialize(results)) + verbose_puts(options, BAR) + end + + def serialize(entity) + case entity.class.to_s + when 'Array' + JSON.pretty_generate(entity.map { |item| JSON.parse serialize(item) }) + when lambda { |x| + defined?(x.constantize) && defined?("Inferno::Web::Serializers::#{x.split('::').last}".constantize) + } + "Inferno::Web::Serializers::#{entity.class.to_s.split('::').last}".constantize.render(entity) + else + raise StandardError, "CLI does not know how to serialize #{entity.class}" + end + end + end + end + end +end diff --git a/lib/inferno/apps/cli/main.rb b/lib/inferno/apps/cli/main.rb index 808e772aa..2a0d25a47 100644 --- a/lib/inferno/apps/cli/main.rb +++ b/lib/inferno/apps/cli/main.rb @@ -5,6 +5,7 @@ require_relative 'suites' require_relative 'new' require_relative '../../version' +require_relative 'execute' module Inferno module CLI @@ -65,6 +66,70 @@ def version puts "Inferno Core v#{Inferno::VERSION}" end + EXECUTE_HELP = <<~END_OF_HELP.freeze + Run Inferno tests in the command line. Exits with 0 only if test entity passes. + Must be run with test kit as working directory. + + You must have background services running: `bundle exec inferno services start` + + You can view suite ids with: `bundle exec inferno suites` + + Examples: + + `bundle exec inferno execute --suite dev_validator \ + --inputs "url:https://hapi.fhir.org/baseR4" \ + patient_id:1234321` + => Outputs test results + + `bundle exec inferno execute --suite dev_validator \ + --inputs "url:https://hapi.fhir.org/baseR4" \ + patient_id:1234321 \ + --tests 1.01 1.02` + => Run specific tests from suite + END_OF_HELP + desc 'execute', 'Run Inferno tests in command line' + long_desc EXECUTE_HELP, wrap: false + option :suite, + aliases: ['-s'], + type: :string, + desc: 'Test suite id to run or to select groups and tests from', + banner: 'id' + option :suite_options, + aliases: ['-u'], # NOTE: -o will be for outputter + type: :hash, + desc: 'Suite options' + option :groups, + aliases: ['-g'], + type: :array, + desc: 'Series of test group short ids (AKA sequence number) to run, requires suite' + option :tests, + aliases: ['-t'], + type: :array, + desc: 'Series of test short ids (AKA sequence number) to run, requires suite' + option :inputs, + aliases: ['-i'], + type: :hash, + desc: 'Inputs (i.e: --inputs=foo:bar goo:baz)' + option :verbose, + aliases: ['-v'], + type: :boolean, + default: false, + desc: 'Output additional information for debugging' + option :help, + aliases: ['-h'], + type: :boolean, + default: false, + desc: 'Display this message' + def execute + Execute.boot_full_inferno + Execute.new.run(options) + end + + # https://github.com/rails/thor/issues/244 - Make Thor exit(1) on Errors/Exceptions + def self.exit_on_failure? + true + end + private # https://github.com/rubocop/rubocop/issues/12571 - still affects Ruby 3.1 upto Rubocop 1.63 diff --git a/lib/inferno/apps/web/controllers/test_runs/create.rb b/lib/inferno/apps/web/controllers/test_runs/create.rb index 113fec3d0..20cdc2853 100644 --- a/lib/inferno/apps/web/controllers/test_runs/create.rb +++ b/lib/inferno/apps/web/controllers/test_runs/create.rb @@ -1,8 +1,14 @@ +require_relative '../../../../utils/verify_runnable' +require_relative '../../../../utils/persist_inputs' + module Inferno module Web module Controllers module TestRuns class Create < Controller + include ::Inferno::Utils::VerifyRunnable + include ::Inferno::Utils::PersistInputs + include Import[ test_sessions_repo: 'inferno.repositories.test_sessions', session_data_repo: 'inferno.repositories.session_data', @@ -11,37 +17,6 @@ class Create < Controller PARAMS = [:test_session_id, :test_suite_id, :test_group_id, :test_id].freeze - def verify_runnable(runnable, inputs, selected_suite_options) - missing_inputs = runnable&.missing_inputs(inputs, selected_suite_options) - user_runnable = runnable&.user_runnable? - raise Inferno::Exceptions::RequiredInputsNotFound, missing_inputs if missing_inputs&.any? - raise Inferno::Exceptions::NotUserRunnableException unless user_runnable - end - - def persist_inputs(params, test_run) - available_inputs = test_run.runnable.available_inputs - params[:inputs]&.each do |input_params| - input = - available_inputs - .find { |_, runnable_input| runnable_input.name == input_params[:name] } - &.last - - if input.nil? - Inferno::Application['logger'].warn( - "Unknown input `#{input_params[:name]}` for #{test_run.runnable.id}: #{test_run.runnable.title}" - ) - next - end - - session_data_repo.save( - test_session_id: test_run.test_session_id, - name: input.name, - value: input_params[:value], - type: input.type - ) - end - end - def handle(req, res) test_session = test_sessions_repo.find(req.params[:test_session_id]) @@ -60,7 +35,7 @@ def handle(req, res) res.body = serialize(test_run, suite_options: test_session.suite_options) - persist_inputs(req.params, test_run) + persist_inputs(session_data_repo, req.params, test_run) Jobs.perform(Jobs::ExecuteTestRun, test_run.id) rescue Sequel::ValidationFailed, Sequel::ForeignKeyConstraintViolation, diff --git a/lib/inferno/apps/web/serializers/serializer.rb b/lib/inferno/apps/web/serializers/serializer.rb index eec20a2c6..221109f5e 100644 --- a/lib/inferno/apps/web/serializers/serializer.rb +++ b/lib/inferno/apps/web/serializers/serializer.rb @@ -1,3 +1,6 @@ +require 'oj' +require 'blueprinter' + module Inferno module Web module Serializers diff --git a/lib/inferno/config/boot/executor.rb b/lib/inferno/config/boot/executor.rb new file mode 100644 index 000000000..84b28a2c8 --- /dev/null +++ b/lib/inferno/config/boot/executor.rb @@ -0,0 +1,14 @@ +Inferno::Application.register_provider(:executor) do + prepare do + target_container.start :logging + + require 'oj' + require 'blueprinter' + + Blueprinter.configure do |config| + config.generator = Oj + end + + target_container.start :suites + end +end diff --git a/lib/inferno/jobs.rb b/lib/inferno/jobs.rb index ece844a51..6fceb3038 100644 --- a/lib/inferno/jobs.rb +++ b/lib/inferno/jobs.rb @@ -6,11 +6,11 @@ module Inferno module Jobs - def self.perform(job_klass, *params) - if Application['async_jobs'] - job_klass.perform_async(*params) - else + def self.perform(job_klass, *params, force_synchronous: false) + if force_synchronous || (Application['async_jobs'] == false) job_klass.new.perform(*params) + else + job_klass.perform_async(*params) end end end diff --git a/lib/inferno/utils/persist_inputs.rb b/lib/inferno/utils/persist_inputs.rb new file mode 100644 index 000000000..2703a0b71 --- /dev/null +++ b/lib/inferno/utils/persist_inputs.rb @@ -0,0 +1,30 @@ +module Inferno + module Utils + # @private + module PersistInputs + def persist_inputs(session_data_repo, params, test_run) + available_inputs = test_run.runnable.available_inputs + params[:inputs]&.each do |input_params| + input = + available_inputs + .find { |_, runnable_input| runnable_input.name == input_params[:name] } + &.last + + if input.nil? + Inferno::Application['logger'].warn( + "Unknown input `#{input_params[:name]}` for #{test_run.runnable.id}: #{test_run.runnable.title}" + ) + next + end + + session_data_repo.save( + test_session_id: test_run.test_session_id, + name: input.name, + value: input_params[:value], + type: input.type + ) + end + end + end + end +end diff --git a/lib/inferno/utils/verify_runnable.rb b/lib/inferno/utils/verify_runnable.rb new file mode 100644 index 000000000..c6c19d07e --- /dev/null +++ b/lib/inferno/utils/verify_runnable.rb @@ -0,0 +1,15 @@ +require_relative '../exceptions' + +module Inferno + module Utils + # @private + module VerifyRunnable + def verify_runnable(runnable, inputs, selected_suite_options) + missing_inputs = runnable&.missing_inputs(inputs, selected_suite_options) + user_runnable = runnable&.user_runnable? + raise Inferno::Exceptions::RequiredInputsNotFound, missing_inputs if missing_inputs&.any? + raise Inferno::Exceptions::NotUserRunnableException unless user_runnable + end + end + end +end diff --git a/spec/factories/result.rb b/spec/factories/result.rb index eae89c94b..8cbd6dd21 100644 --- a/spec/factories/result.rb +++ b/spec/factories/result.rb @@ -42,5 +42,9 @@ repo_create_list(:request, evaluator.request_count, result_id: instance.id) ) end + + factory :random_result do + result { Inferno::Entities::Result::RESULT_OPTIONS.sample } + end end end diff --git a/spec/fixtures/basic_test_group.rb b/spec/fixtures/basic_test_group.rb index 5328b512d..85a4dd691 100644 --- a/spec/fixtures/basic_test_group.rb +++ b/spec/fixtures/basic_test_group.rb @@ -4,7 +4,8 @@ class AbcGroup < Inferno::Entities::TestGroup input :input1, :input2 test 'demo_test' do - 1 + 1 + id :demo_test + run { 1 + 1 } end end end diff --git a/spec/fixtures/run_as_group_test_group.rb b/spec/fixtures/run_as_group_test_group.rb new file mode 100644 index 000000000..5a067de1d --- /dev/null +++ b/spec/fixtures/run_as_group_test_group.rb @@ -0,0 +1,11 @@ +module BasicTestSuite + class DefGroup < Inferno::Entities::TestGroup + title 'DEF Group' + + run_as_group + + test 'this_test_cannot_run_alone' do + run { 2 + 2 } + end + end +end diff --git a/spec/inferno/apps/cli/execute/console_outputter_spec.rb b/spec/inferno/apps/cli/execute/console_outputter_spec.rb new file mode 100644 index 000000000..4b9092863 --- /dev/null +++ b/spec/inferno/apps/cli/execute/console_outputter_spec.rb @@ -0,0 +1,198 @@ +require_relative '../../../../../lib/inferno/apps/cli/execute/console_outputter' + +RSpec.describe Inferno::CLI::Execute::ConsoleOutputter do # rubocop:disable RSpec/FilePath + let(:instance) { described_class.new } + let(:options) { { verbose: true } } + + describe '#serialize' do + let(:test_results) { create_list(:result, 2) } + + it 'handles an array of test results without raising exception' do + expect { instance.serialize(test_results) }.to_not raise_error(StandardError) + end + + it 'returns valid JSON' do + expect { JSON.parse(instance.serialize(test_results)) }.to_not raise_error(StandardError) + end + end + + describe '#verbose_print' do + it 'outputs when verbose is true' do + expect { instance.verbose_print(options, 'Lorem') }.to output(/Lorem/).to_stdout + end + + it 'does not output when verbose is false' do + expect { instance.verbose_print({ verbose: false }, 'Lorem') }.to_not output(/.+/).to_stdout + end + end + + describe '#verbose_puts' do + it 'has output ending with \n with when verbose is true' do + expect { instance.verbose_puts(options, 'Lorem') }.to output(/Lorem\n/).to_stdout + end + end + + describe '#format_tag' do + let(:suites_repo) { Inferno::Repositories::TestSuites.new } + + let(:suite_all) do + Class.new(Inferno::TestSuite) do + id 'mock_suite_id_1' + short_title 'short' + title 'title' + end + end + + let(:suite_no_short_title) do + Class.new(Inferno::TestSuite) do + id 'mock_suite_id_2' + title 'title' + end + end + + let(:suite_id_only) do + Class.new(Inferno::TestSuite) do + id 'mock_suite_id_3' + end + end + + let(:groups_repo) { Inferno::Repositories::TestGroups.new } + + let(:group_all) do + Class.new(Inferno::TestGroup) do + id 'mock_group_id_1' + short_title 'short' + title 'title' + end + end + + let(:group_no_short_title) do + Class.new(Inferno::TestGroup) do + id 'mock_group_id_2' + title 'title' + end + end + + let(:group_no_titles) do + Class.new(Inferno::TestGroup) do + id 'mock_group_id_3' + end + end + + it "includes a runnable's short_id and short_title if possible" do + groups_repo.insert(group_all) + test_result = create(:result, runnable: { test_group_id: group_all.id }) + + expect(instance.format_tag(test_result)).to match(group_all.short_id) + expect(instance.format_tag(test_result)).to match(group_all.short_title) + end + + it "includes a runnable's short_id and title if no short_title found" do + groups_repo.insert(group_no_short_title) + test_result = create(:result, runnable: { test_group_id: group_no_short_title.id }) + + expect(instance.format_tag(test_result)).to match(group_no_short_title.short_id) + expect(instance.format_tag(test_result)).to match(group_no_short_title.title) + end + + it "includes a runnable's short_id and id if no title/short_title found" do + groups_repo.insert(group_no_titles) + test_result = create(:result, runnable: { test_group_id: group_no_titles.id }) + + expect(instance.format_tag(test_result)).to match(group_no_titles.short_id) + expect(instance.format_tag(test_result)).to match(group_no_titles.id) + end + + it "include's a runnable's short_title if no short_id found" do + suites_repo.insert(suite_all) + test_result = create(:result, runnable: { test_suite_id: suite_all.id }) + + expect(instance.format_tag(test_result)).to match(suite_all.short_title) + end + + it "include's a runnable's title if no short_id/short_title found" do + suites_repo.insert(suite_no_short_title) + test_result = create(:result, runnable: { test_suite_id: suite_no_short_title.id }) + + expect(instance.format_tag(test_result)).to match(suite_no_short_title.title) + end + + it "include's a runnable's id if no short_id/short_title/title found" do + suites_repo.insert(suite_id_only) + test_result = create(:result, runnable: { test_suite_id: suite_id_only.id }) + + expect(instance.format_tag(test_result)).to match(suite_id_only.id) + end + end + + describe '#format_messages' do + let(:test_result) { repo_create(:result, message_count: 10) } + + it 'includes all characters' do + messages = test_result.messages + formatted_string = instance.format_messages(test_result) + + messages.each do |message| + expect(formatted_string).to include message.message + end + end + end + + describe '#format_requests' do + let(:test_result) { repo_create(:result, request_count: 10) } + + it 'includes all status codes' do + requests = test_result.requests + formatted_string = instance.format_requests(test_result) + + requests.each do |request| + expect(formatted_string).to include request.status.to_s + end + end + end + + describe '#format_session_data' do + let(:data) { [{ name: :url, value: 'https://example.com' }, { name: :token, value: 'SAMPLE_OUTPUT' }] } + let(:test_result) { create(:result, input_json: JSON.generate(data), output_json: JSON.generate(data)) } + + it 'includes all values for input_json' do + formatted_string = instance.format_session_data(test_result, :input_json) + data.each do |data_element| + expect(formatted_string).to include data_element[:value] + end + end + + it 'includes all values for output_json' do + formatted_string = instance.format_session_data(test_result, :output_json) + data.each do |data_element| + expect(formatted_string).to include data_element[:value] + end + end + end + + describe '#format_result' do + Inferno::Entities::Result::RESULT_OPTIONS.each do |result_option| + it "can format #{result_option} result type" do + result = create(:result, result: result_option) + expect { instance.format_result(result) }.to_not raise_error + end + + it 'includes result type in return value' do + result = create(:result, result: result_option) + expect(instance.format_result(result).upcase).to include result_option.upcase + end + end + end + + describe '#print_color_results' do + let(:results) { create_list(:random_result, 10) } + + it 'outputs something with verbose false' do + expect { instance.print_results({ verbose: false }, results) }.to output(/.+/).to_stdout + end + + it 'outputs something with verbose true' do + expect { instance.print_results(options, results) }.to output(/.+/).to_stdout + end + end +end diff --git a/spec/inferno/apps/cli/execute_spec.rb b/spec/inferno/apps/cli/execute_spec.rb new file mode 100644 index 000000000..700b1ad14 --- /dev/null +++ b/spec/inferno/apps/cli/execute_spec.rb @@ -0,0 +1,237 @@ +require_relative '../../../../lib/inferno/apps/cli/execute' + +RSpec.describe Inferno::CLI::Execute do # rubocop:disable RSpec/FilePath + let(:instance) { described_class.new } + + describe '.suppress_output' do + it 'disables stdout' do + expect do + described_class.suppress_output { puts 'Hide me' } + end.to_not output(/.+/).to_stdout_from_any_process + end + end + + describe '.boot_full_inferno' do + it 'does not raise error' do + expect { described_class.boot_full_inferno }.to_not raise_error(StandardError) + end + end + + describe '#print_help_and_exit' do + it 'outputs something and exits' do + expect do + expect { instance.print_help_and_exit }.to output(/.+/).to_stdout + end.to raise_error(SystemExit) + end + end + + describe '#outputter' do + it 'returns an object that responds to print_start_message' do + expect(instance.outputter).to respond_to(:print_start_message) + end + + it 'returns an object that responds to print_around_run' do + expect(instance.outputter).to respond_to(:print_around_run) + end + + it 'returns an object whose print_around_run yields' do + expect do + expect { |b| instance.outputter.print_around_run({}, &b) }.to yield_control + end.to output(/.?/).to_stdout # required to prevent output in rspec + end + + it 'returns an object that responds to print_results' do + expect(instance.outputter).to respond_to(:print_results) + end + + it 'returns an object that responds to print_end_message' do + expect(instance.outputter).to respond_to(:print_end_message) + end + + it 'returns an object that responds to print_error' do + expect(instance.outputter).to respond_to(:print_error) + end + + it 'returns an object whose print_error does not raise exception nor exit' do + allow(instance).to receive(:options).and_return({}) + expect do + expect { instance.outputter.print_error({}, StandardError.new('my error')) }.to_not raise_error + end.to output(/.?/).to_stdout # required to prevent output in rspec + end + end + + describe '#selected_runnables' do + it 'returns empty array when no short ids given' do + allow(instance).to receive(:options).and_return({ suite: 'basic' }) + expect(instance.selected_runnables).to eq([]) + end + + it 'returns both groups and tests when short ids for both are given' do + allow(instance).to receive(:options).and_return({ suite: 'basic', groups: ['1'], tests: ['1.01'] }) + expect(instance.selected_runnables.length).to eq(2) + end + end + + describe '#suite' do + it 'returns the correct Inferno TestSuite entity' do + allow(instance).to receive(:options).and_return({ suite: 'basic' }) + expect(instance.suite).to eq(BasicTestSuite::Suite) + end + + it 'raises standard error if no suite provided' do + expect { instance.suite }.to raise_error(StandardError) + end + end + + describe '#test_run' do + { suite: BasicTestSuite::Suite, group: BasicTestSuite::AbcGroup, + test: BasicTestSuite::AbcGroup.tests.first }.each do |type, runnable| + it "returns a test run for #{type}" do + allow(instance).to receive(:options).and_return({ suite: 'basic' }) + expect(instance.test_run(runnable)).to be_instance_of Inferno::Entities::TestRun + end + end + end + + describe '#test_session' do + it 'returns test session given suite options' do + allow(instance).to receive(:options).and_return({ suite: 'basic', suite_options: { option: 'a' } }) + allow(instance).to receive(:suite).and_return(BasicTestSuite::Suite) + expect(instance.test_session).to be_instance_of Inferno::Entities::TestSession + end + end + + describe '#create_params' do + let(:test_suite) { BasicTestSuite::Suite } + let(:test_session) { create(:test_session) } + let(:inputs_hash) { { url: 'https://example.com' } } + let(:inputs_array) { [{ name: :url, value: 'https://example.com' }] } + + it 'returns test run params' do + allow(instance).to receive(:options).and_return({ suite: test_suite.id, inputs: inputs_hash }) + allow(instance).to receive(:runnable_type).and_return(:suite) + + result = instance.create_params(test_session, test_suite) + expect(result).to eq({ test_session_id: test_session.id, test_suite_id: test_suite.id, inputs: inputs_array }) + end + end + + describe '#dispatch_job' do + let(:test_session) { test_run.test_session } + let(:test_run) { repo_create(:test_run, test_suite_id: 'basic') } + + it 'supresses output if verbose is false' do + allow(instance).to receive(:test_session).and_return(test_session) + allow(instance).to receive(:options).and_return({ suite: 'basic', verbose: false }) + + expect { instance.dispatch_job(test_run) }.to_not output(/.+/).to_stdout_from_any_process + end + end + + describe '#groups' do + it 'parses group by short id' do + allow(instance).to receive(:options).and_return({ suite: 'basic', groups: ['1'] }) + expect(instance.groups).to eq([BasicTestSuite::Suite.groups.first]) + end + end + + describe '#tests' do + it 'parses test by short id' do + allow(instance).to receive(:options).and_return({ suite: 'basic', tests: ['1.01'] }) + expect(instance.tests).to eq([BasicTestSuite::Suite.groups.first.tests.first]) + end + end + + describe '#find_by_short_id' do + it 'raises standard error when entity not found by short id' do + expect do + instance.find_by_short_id(Inferno::Repositories::Tests.new, 'does_not_exist') + end.to raise_error(StandardError) + end + end + + describe '#thor_hash_to_suite_options_array' do + let(:hash) { { us_core: 'us_core_v311' } } + + it 'converts hash to array' do + result = instance.thor_hash_to_suite_options_array(hash) + expect(result.class).to eq(Array) + end + + it 'returns proper inputs array' do + result = instance.thor_hash_to_inputs_array(hash) + expect(result).to eq([{ name: :us_core, value: 'us_core_v311' }]) + end + end + + describe '#thor_hash_to_inputs_array' do + let(:hash) { { url: 'https://example.com' } } + + it 'converts hash to array' do + result = instance.thor_hash_to_inputs_array(hash) + expect(result.class).to eq(Array) + end + + it 'returns proper inputs array' do + result = instance.thor_hash_to_inputs_array(hash) + expect(result).to eq([{ name: :url, value: 'https://example.com' }]) + end + end + + describe '#runnable_type' do + { BasicTestSuite::Suite => :suite, BasicTestSuite::AbcGroup => :group, + BasicTestSuite::AbcGroup.tests.first => :test }.each do |runnable, type| + it "can return #{type} type" do + expect(instance.runnable_type(runnable)).to eq(type) + end + end + end + + describe '#runnable_id_key' do + { suite: :test_suite_id, group: :test_group_id, test: :test_id }.each do |runnable_type, id_key| + it "returns proper id key for runnable type #{runnable_type}" do + allow(instance).to receive(:runnable_type).and_return(runnable_type) + runnable = case runnable_type + when :suite + BasicTestSuite::Suite + when :group + BasicTestSuite::AbcGroup + else + BasicTestSuite::AbcGroup.tests.first + end + expect(instance.runnable_id_key(runnable)).to eq(id_key) + end + end + end + + describe '#run' do + let(:suite) { 'dev_validator' } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + let(:test_session) { repo_create(:test_session, test_suite_id: suite.id) } + + let(:success_outcome) do + { + outcomes: [{ + issues: [] + }], + sessionId: '' + } + end + + let(:inputs) { { 'url' => 'https://example.com', 'patient_id' => '1' } } + + it 'works on dev_validator suite' do + stub_request(:post, "#{ENV.fetch('FHIR_RESOURCE_VALIDATOR_URL')}/validate") + .with(query: hash_including({})) + .to_return(status: 200, body: success_outcome.to_json) + + stub_request(:get, 'https://example.com/Patient/1') + .to_return(status: 200, body: FHIR::Patient.new({ name: { given: 'Smith' } }).to_json) + + expect do + expect { instance.run({ suite:, inputs:, verbose: true }) } + .to raise_error(an_instance_of(SystemExit).and(having_attributes(status: 0))) + end.to output(/.+/).to_stdout + end + end +end diff --git a/spec/inferno/utils/persist_inputs_spec.rb b/spec/inferno/utils/persist_inputs_spec.rb new file mode 100644 index 000000000..7cded414a --- /dev/null +++ b/spec/inferno/utils/persist_inputs_spec.rb @@ -0,0 +1,56 @@ +require_relative '../../../lib/inferno/utils/persist_inputs' + +RSpec.describe Inferno::Utils::PersistInputs do + describe '#persist_inputs' do + let(:dummy_class) do + Class.new do + include Inferno::Utils::PersistInputs + end + end + let(:dummy) { dummy_class.new } + let(:suite) { BasicTestSuite::Suite } + let(:test_sessions_repo) { Inferno::Repositories::TestSessions.new } + let(:session_data_repo) { Inferno::Repositories::SessionData.new } + + it 'saves inputs to db' do + test_session = test_sessions_repo.create(test_suite_id: suite.id) + + test_run = create(:test_run, test_session:) + test_run.test_session_id = test_session.id + + params = { + test_session_id: test_session.id, + test_suite_id: suite.id, + inputs: [ + { name: 'input1', value: 'persist me' } + ] + } + + dummy.persist_inputs(session_data_repo, params, test_run) + persisted_data = session_data_repo.load(test_session_id: test_run.test_session_id, name: 'input1') + + expect(persisted_data).to eq('persist me') + end + + it 'saves known inputs when given unknown extraneous inputs' do + test_session = test_sessions_repo.create(test_suite_id: suite.id) + + test_run = create(:test_run, test_session:) + test_run.test_session_id = test_session.id + + params = { + test_session_id: test_session.id, + test_suite_id: suite.id, + inputs: [ + { name: 'extraneous', value: 'omit me' }, + { name: 'input1', value: 'persist me' } + ] + } + + expect { dummy.persist_inputs(session_data_repo, params, test_run) }.to_not raise_error + + persisted_data = session_data_repo.load(test_session_id: test_run.test_session_id, name: 'input1') + expect(persisted_data).to eq('persist me') + end + end +end diff --git a/spec/inferno/utils/verify_runnable_spec.rb b/spec/inferno/utils/verify_runnable_spec.rb new file mode 100644 index 000000000..c5e3574b2 --- /dev/null +++ b/spec/inferno/utils/verify_runnable_spec.rb @@ -0,0 +1,31 @@ +require_relative '../../../lib/inferno/utils/verify_runnable' + +RSpec.describe Inferno::Utils::VerifyRunnable do + describe '#verify_runnable' do + let(:dummy_class) { Class.new { include Inferno::Utils::VerifyRunnable } } + let(:dummy) { dummy_class.new } + let(:suite) { BasicTestSuite::Suite } + let(:group) { BasicTestSuite::AbcGroup } + let(:good_inputs) do + [{ name: 'input1', value: 'baz' }, { name: 'input2', value: 'foo' }] + end + let(:bad_inputs) { [{ name: :input2, value: :foo }] } + let(:unrunnable) { BasicTestSuite::DefGroup.tests.first } + + it 'allows runnables with good inputs' do + expect { dummy.verify_runnable(suite, good_inputs, {}) }.to_not raise_error + end + + it 'rejects bad inputs' do + expect do + dummy.verify_runnable(group, bad_inputs, {}) + end.to raise_error(Inferno::Exceptions::RequiredInputsNotFound) + end + + it 'rejects tests that are part of run_as_group' do + expect do + dummy.verify_runnable(unrunnable, [], {}) + end.to raise_error(Inferno::Exceptions::NotUserRunnableException) + end + end +end