diff --git a/README.md b/README.md index 0b89679..f749245 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,13 @@ React Native applications commonly depend on certain Node modules. This workflow Required Input Variables - `$AC_SELECTED_NODE_VERSION`: Specifies the Node version to be installed. Defaults to: `lts` + +## Running tests + +Requires [RSpec](https://rspec.info) gem and Ruby standard library (Coverage, REXML). No Gemfile or Bundler needed. + +```bash +ruby test/test_main.rb +``` + +A coverage report is printed at the end of each run. diff --git a/main.rb b/main.rb index 7875c9f..1fc8374 100644 --- a/main.rb +++ b/main.rb @@ -1,4 +1,7 @@ -require 'os' +begin + require 'os' +rescue LoadError +end require 'open3' def env_has_key(key) @@ -9,11 +12,6 @@ def get_version(component_version,config_version) return config_version == nil ? component_version : config_version end -component_node_version = env_has_key("AC_SELECTED_NODE_VERSION") || "lts" -config_node_version = env_has_key("AC_NODE_JS_VERSION") -selected_node_version = get_version(component_node_version,config_node_version) -puts "Selected node version is #{selected_node_version}" - def run_command(command) puts "@@[command] #{command}" status = nil @@ -33,10 +31,17 @@ def run_command(command) end end -if OS.linux? - run_command("n #{selected_node_version}") -elsif OS.mac? - run_command("sudo n #{selected_node_version}") -else - abort("Unexpected OS") +if __FILE__ == $PROGRAM_NAME + component_node_version = env_has_key("AC_SELECTED_NODE_VERSION") || "lts" + config_node_version = env_has_key("AC_NODE_JS_VERSION") + selected_node_version = get_version(component_node_version,config_node_version) + puts "Selected node version is #{selected_node_version}" + + if OS.linux? + run_command("n #{selected_node_version}") + elsif OS.mac? + run_command("sudo n #{selected_node_version}") + else + abort("Unexpected OS") + end end diff --git a/test/test_main.rb b/test/test_main.rb new file mode 100644 index 0000000..646dae6 --- /dev/null +++ b/test/test_main.rb @@ -0,0 +1,435 @@ +# frozen_string_literal: true + +unless defined?(Coverage) && Coverage.running? + require 'coverage' + Coverage.start +end + +require 'rspec' +require 'rspec/core/formatters/base_formatter' +require 'open3' +require 'stringio' + +MAIN_RB = File.expand_path('../main.rb', __dir__) +PROJECT_ROOT = File.dirname(MAIN_RB) + +require MAIN_RB + +unless defined?(OS) + module OS + def self.linux? + false + end + + def self.mac? + false + end + end +end + +class ReadableFormatter < RSpec::Core::Formatters::BaseFormatter + RSpec::Core::Formatters.register( + self, + :example_group_started, + :example_group_finished, + :example_passed, + :example_failed, + :example_pending, + :dump_summary + ) + + PASS = "\e[32;1m[ PASS ]\e[0m" + FAIL = "\e[31;1m[ FAIL ]\e[0m" + ERROR = "\e[31;1m[ERROR ]\e[0m" + SKIP = "\e[33;1m[ SKIP ]\e[0m" + + DIVIDER = "\e[90m#{'─' * 72}\e[0m" + DIVIDER_FAT = "\e[90m#{'═' * 72}\e[0m" + + def initialize(output) + super + @depth = 0 + @failures = [] + @counts = { passed: 0, failed: 0, pending: 0 } + end + + GROUP_COLORS = [ + "\e[34;1m", + "\e[35;1m", + "\e[36;1m", + "\e[33;1m", + ].freeze + + def example_group_started(notification) + group = notification.group + if group.parent_groups.size <= 1 + output.puts if @depth.zero? + color = GROUP_COLORS[@depth % GROUP_COLORS.size] + output.puts " #{color}#{group.description}\e[0m" + else + output.puts " #{' ' * (@depth - 1)}\e[90m▸ \e[0m\e[37m#{group.description}\e[0m" + end + @depth += 1 + end + + def example_group_finished(_notification) + @depth -= 1 if @depth > 0 + end + + def example_passed(notification) + @counts[:passed] += 1 + print_example(PASS, notification.example) + end + + def example_failed(notification) + @counts[:failed] += 1 + ex = notification.example + exc = ex.execution_result.exception + badge = exc.is_a?(RSpec::Expectations::ExpectationNotMetError) ? FAIL : ERROR + print_example(badge, ex) + @failures << notification + end + + def example_pending(notification) + @counts[:pending] += 1 + ex = notification.example + output.puts " #{' ' * [0, @depth - 1].max}#{SKIP} #{ex.description}" + end + + def dump_summary(notification) + output.puts + output.puts DIVIDER_FAT + + unless @failures.empty? + output.puts "\n \e[1;31mFailures:\e[0m\n" + @failures.each_with_index do |n, i| + ex = n.example + exc = ex.execution_result.exception + output.puts " \e[1m#{i + 1}) #{ex.full_description}\e[0m" + exc.message.lines.first(6).each do |line| + output.puts " \e[31m#{line.rstrip}\e[0m" + end + output.puts " \e[90m# #{ex.location}\e[0m" + output.puts + end + output.puts DIVIDER + end + + t = notification.examples.size + p = @counts[:passed] + f = @counts[:failed] + s = @counts[:pending] + sec = format('%.3fs', notification.duration) + + parts = ["\e[32m#{p} passed\e[0m"] + parts << "\e[31m#{f} failed\e[0m" if f > 0 + parts << "\e[33m#{s} pending\e[0m" if s > 0 + + overall = f.zero? ? "\e[32;1m✔ All #{t} tests passed\e[0m" : "\e[31;1m✖ #{f} of #{t} tests failed\e[0m" + output.puts "\n #{overall}" + output.puts " #{parts.join(' | ')} \e[90m(#{sec})\e[0m" + output.puts DIVIDER_FAT + end + + private + + def print_example(badge, example) + indent = ' ' * [0, @depth - 1].max + time = format('%.3fs', example.execution_result.run_time) + output.puts " #{indent}#{badge} #{example.description} \e[90m(#{time})\e[0m" + end +end + +def fake_status(success) + status = double('Process::Status', success?: success) + double('wait_thr', value: status) +end + +def stub_popen3(stdout: '', stderr: '', success: true, &recorder) + allow(Open3).to receive(:popen3) do |cmd, &block| + recorder&.call(cmd) + block.call(StringIO.new, StringIO.new(stdout), StringIO.new(stderr), fake_status(success)) + end +end + +def capture_stdout + old = $stdout + $stdout = StringIO.new + yield + $stdout.string +ensure + $stdout = old +end + +COVERAGE_SNAPSHOTS = [] + +def snapshot_coverage + return unless defined?(Coverage) && Coverage.running? + + data = Coverage.peek_result[MAIN_RB] + COVERAGE_SNAPSHOTS << data if data +end + +def run_step(env) + keys = %w[AC_SELECTED_NODE_VERSION AC_NODE_JS_VERSION] + old = keys.to_h { |k| [k, ENV[k]] } + keys.each { |k| ENV.delete(k) } + env.each { |k, v| ENV[k] = v } + old_program_name = $PROGRAM_NAME + $PROGRAM_NAME = MAIN_RB + snapshot_coverage + capture_stdout { load MAIN_RB } +ensure + $PROGRAM_NAME = old_program_name + old.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v } +end + +RSpec.describe '#env_has_key' do + around do |example| + old = ENV['_TEST_VAR'] + example.run + old.nil? ? ENV.delete('_TEST_VAR') : ENV['_TEST_VAR'] = old + end + + it 'returns the value when the key is set' do + ENV['_TEST_VAR'] = 'hello' + expect(env_has_key('_TEST_VAR')).to eq('hello') + end + + it 'returns nil when the key is missing' do + ENV.delete('_TEST_VAR') + expect(env_has_key('_TEST_VAR')).to be_nil + end + + it 'returns nil when the value is an empty string' do + ENV['_TEST_VAR'] = '' + expect(env_has_key('_TEST_VAR')).to be_nil + end + + it 'returns nil for an empty key name' do + expect(env_has_key('')).to be_nil + end + + it 'raises TypeError for a nil key' do + expect { env_has_key(nil) }.to raise_error(TypeError) + end +end + +RSpec.describe '#get_version' do + it 'returns the config version when it is set' do + expect(get_version('lts', '18.17.0')).to eq('18.17.0') + end + + it 'falls back to the component version when the config version is nil' do + expect(get_version('lts', nil)).to eq('lts') + end + + it 'returns an empty config version as-is' do + expect(get_version('lts', '')).to eq('') + end + + it 'returns nil when both versions are nil' do + expect(get_version(nil, nil)).to be_nil + end +end + +RSpec.describe '#run_command' do + before { allow($stderr).to receive(:write) } + + it 'prints the command and its stdout lines' do + stub_popen3(stdout: "line one\nline two\n") + out = capture_stdout { run_command('n 18') } + expect(out).to include('@@[command] n 18') + expect(out).to include('line one') + expect(out).to include('line two') + end + + it 'passes the command string to Open3.popen3 unchanged' do + captured = nil + stub_popen3 { |cmd| captured = cmd } + capture_stdout { run_command('sudo n lts') } + expect(captured).to eq('sudo n lts') + end + + it 'returns without raising when the command succeeds' do + stub_popen3(success: true) + expect { capture_stdout { run_command('n lts') } }.not_to raise_error + end + + it 'aborts with the stderr output when the command fails' do + stub_popen3(stderr: 'failure details', success: false) + expect { + capture_stdout { run_command('n 99') } + }.to raise_error(SystemExit) { |e| + expect(e.status).to eq(1) + expect(e.message).to include('failure details') + } + end + + it 'aborts with an empty message when the command fails silently' do + stub_popen3(stderr: '', success: false) + expect { capture_stdout { run_command('n 99') } }.to raise_error(SystemExit) + end + + it 'runs a real successful shell command without raising' do + expect { capture_stdout { run_command('echo hi') } }.not_to raise_error + end + + it 'aborts when a real shell command exits non-zero' do + expect { + capture_stdout { run_command('sh -c "echo failure details >&2; exit 1"') } + }.to raise_error(SystemExit) { |e| + expect(e.message).to include('failure details') + } + end + + it 'raises for a nil command' do + expect { capture_stdout { run_command(nil) } }.to raise_error(TypeError) + end + + it 'raises for an empty command' do + expect { capture_stdout { run_command('') } }.to raise_error(StandardError) + end +end + +RSpec.describe 'step execution' do + let(:commands) { [] } + + before do + allow($stderr).to receive(:write) + stub_popen3 { |cmd| commands << cmd } + allow(OS).to receive(:linux?).and_return(false) + allow(OS).to receive(:mac?).and_return(false) + end + + context 'version selection' do + before { allow(OS).to receive(:linux?).and_return(true) } + + it 'defaults to lts when no variable is set' do + out = run_step({}) + expect(out).to include('Selected node version is lts') + expect(commands).to eq(['n lts']) + end + + it 'uses AC_SELECTED_NODE_VERSION when set' do + out = run_step('AC_SELECTED_NODE_VERSION' => '18') + expect(out).to include('Selected node version is 18') + expect(commands).to eq(['n 18']) + end + + it 'prefers AC_NODE_JS_VERSION over AC_SELECTED_NODE_VERSION' do + out = run_step('AC_SELECTED_NODE_VERSION' => '18', 'AC_NODE_JS_VERSION' => '20.5.1') + expect(out).to include('Selected node version is 20.5.1') + expect(commands).to eq(['n 20.5.1']) + end + + it 'ignores an empty AC_NODE_JS_VERSION' do + out = run_step('AC_SELECTED_NODE_VERSION' => '18', 'AC_NODE_JS_VERSION' => '') + expect(out).to include('Selected node version is 18') + expect(commands).to eq(['n 18']) + end + + it 'ignores an empty AC_SELECTED_NODE_VERSION' do + out = run_step('AC_SELECTED_NODE_VERSION' => '') + expect(out).to include('Selected node version is lts') + expect(commands).to eq(['n lts']) + end + end + + context 'on Linux' do + before { allow(OS).to receive(:linux?).and_return(true) } + + it 'runs n without sudo' do + run_step('AC_SELECTED_NODE_VERSION' => '16') + expect(commands).to eq(['n 16']) + end + end + + context 'on macOS' do + before { allow(OS).to receive(:mac?).and_return(true) } + + it 'runs n with sudo' do + run_step('AC_SELECTED_NODE_VERSION' => '16') + expect(commands).to eq(['sudo n 16']) + end + end + + context 'on an unsupported OS' do + it 'aborts with an Unexpected OS message and runs nothing' do + expect { run_step({}) }.to raise_error(SystemExit) { |e| + expect(e.message).to include('Unexpected OS') + } + expect(commands).to be_empty + end + end + + context 'when the install command fails' do + before { allow(OS).to receive(:linux?).and_return(true) } + + it 'aborts with the stderr output' do + stub_popen3(stderr: 'n: version not found', success: false) + expect { run_step('AC_SELECTED_NODE_VERSION' => '99') }.to raise_error(SystemExit) { |e| + expect(e.message).to include('n: version not found') + } + end + end +end + +RSpec.describe 'loading main.rb' do + it 'does not execute the step when required from another script' do + out, err, status = Open3.capture3({}, "ruby -e 'require %q(#{MAIN_RB}); puts :loaded'") + expect(status.exitstatus).to eq(0) + expect(err).to eq('') + expect(out.strip).to eq('loaded') + expect(out).not_to include('Selected node version') + end +end + +def print_coverage_report + return unless defined?(Coverage) && Coverage.running? + + result = begin + Coverage.result(stop: false, clear: false) + rescue ArgumentError + Coverage.result + end + + main_path = result.keys.find { |p| p&.end_with?('main.rb') } + return puts("\nCoverage: main.rb not found in results") unless main_path + + data = COVERAGE_SNAPSHOTS.reduce(result[main_path]) do |acc, snap| + acc.zip(snap).map { |a, b| a.nil? ? nil : a + b.to_i } + end + lines = data.each_with_index.reject { |c, _| c.nil? } + total = lines.size + covered = lines.count { |c, _| c.to_i > 0 } + pct = total.positive? ? (covered * 100.0 / total).round(1) : 100.0 + uncovered = lines.select { |c, _| c.to_i == 0 }.map { |_, i| i + 1 } + + color = pct == 100 ? "\e[32;1m" : pct >= 80 ? "\e[33m" : "\e[31m" + bar_filled = (pct / 5).round + bar = "\e[32m" + '█' * bar_filled + "\e[90m" + '░' * (20 - bar_filled) + "\e[0m" + + puts "\n\e[90m#{'═' * 72}\e[0m" + puts ' Coverage Report' + puts "\e[90m#{'─' * 72}\e[0m" + puts " main.rb #{bar} #{color}#{pct}%\e[0m (#{covered}/#{total} lines)" + if uncovered.any? && uncovered.size <= 20 + puts " Uncovered lines: \e[90m#{uncovered.join(', ')}\e[0m" + elsif uncovered.any? + puts " Uncovered lines: \e[90m#{uncovered.first(15).join(', ')} … (+#{uncovered.size - 15} more)\e[0m" + end + puts "\e[90m#{'═' * 72}\e[0m" +end + +if __FILE__ == $PROGRAM_NAME + RSpec.configure do |config| + config.add_formatter ReadableFormatter + config.color = true + config.order = :defined + end + + exit_code = RSpec::Core::Runner.run(['--order', 'defined']) + print_coverage_report + exit exit_code +end