diff --git a/CHANGELOG.md b/CHANGELOG.md index cdd60721..989e170f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added - Add util/atomic.h +- `Logger` class to centralize CI runner script logging (in particular, indentation) +- Explicit reporting of free bytes after compilation +- `interrupt.h` mock +- `#define` statements for analog pins `A0` - `A11` ### Changed +- `arduino_ci.rb` uses new `Logger` ### Deprecated @@ -17,6 +22,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed - Fix phrasing of free-space check +- Handle unrecognized command line errors in a nicer way ### Security @@ -52,7 +58,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added a CI workflow to lint the code base - Added a CI workflow to check for spelling errors - Extraction of bytes usage in a compiled sketch is now calculated in a method: `ArduinoBackend.last_bytes_usage` -- Added ```nano_every``` platform to represent ```arduino:megaavr``` architecture +- Added `nano_every` platform to represent `arduino:megaavr` architecture - Working directory is now printed in test runner output - Explicitly include `irb` via rubygems diff --git a/cpp/arduino/ArduinoDefines.h b/cpp/arduino/ArduinoDefines.h index 3f4de7ad..85dbb9f1 100644 --- a/cpp/arduino/ArduinoDefines.h +++ b/cpp/arduino/ArduinoDefines.h @@ -92,6 +92,20 @@ #if defined(__AVR_ATmega328P__) || defined(__AVR_ATmega32U4__) || defined(__AVR_ATmega328__) || defined(__AVR_ATmega168__) || defined(__AVR_ATmega1280__) || defined(__AVR_ATmega2560__) || defined(__SAM3X8E__) || defined(__SAMD21G18A__) // Verified on these platforms, see https://github.com/Arduino-CI/arduino_ci/pull/341#issuecomment-1368118880 #define LED_BUILTIN 13 + + #define A0 14 + #define A1 15 + #define A2 16 + #define A3 17 + #define A4 18 + #define A5 19 + #define A6 20 + #define A7 21 + #define A8 22 + #define A9 23 + #define A10 24 + #define A11 25 + #endif // Arduino defines this diff --git a/cpp/arduino/avr/interrupt.h b/cpp/arduino/avr/interrupt.h new file mode 100644 index 00000000..96a2d478 --- /dev/null +++ b/cpp/arduino/avr/interrupt.h @@ -0,0 +1,7 @@ +#pragma once + +#define _VECTOR(N) __vector_ ## N +#define SIGNAL ( vector ) + +void cli() {}; +void sei() {}; diff --git a/exe/arduino_ci.rb b/exe/arduino_ci.rb index 386d3093..ad4f9776 100755 --- a/exe/arduino_ci.rb +++ b/exe/arduino_ci.rb @@ -3,25 +3,34 @@ require 'set' require 'pathname' require 'optparse' -require 'io/console' -# be flexible between 80 and 132 cols of output -WIDTH = begin - [132, [80, IO::console.winsize[1] - 2].max].min -rescue NoMethodError - 80 -end VAR_CUSTOM_INIT_SCRIPT = "CUSTOM_INIT_SCRIPT".freeze VAR_USE_SUBDIR = "USE_SUBDIR".freeze VAR_EXPECT_EXAMPLES = "EXPECT_EXAMPLES".freeze VAR_EXPECT_UNITTESTS = "EXPECT_UNITTESTS".freeze -@failure_count = 0 -@passfail = proc { |result| result ? "✓" : "✗" } -@backend = nil +CLI_SKIP_EXAMPLES_COMPILATION = "--skip-examples-compilation".freeze +CLI_SKIP_UNITTESTS = "--skip-unittests".freeze + +# script-level variables we'll use +@log = nil +@backend = nil +@cli_options = nil # Use some basic parsing to allow command-line overrides of config class Parser + + def self.show_help(opts) + puts opts + puts + puts "Additionally, the following environment variables control the script:" + puts " - #{VAR_CUSTOM_INIT_SCRIPT} - if set, this script will be run from the Arduino/libraries directory" + puts " prior to any automated library installation or testing (e.g. to install unofficial libraries)" + puts " - #{VAR_USE_SUBDIR} - if set, the script will install the library from this subdirectory of the cwd" + puts " - #{VAR_EXPECT_EXAMPLES} - if set, testing will fail if no example sketches are present" + puts " - #{VAR_EXPECT_UNITTESTS} - if set, testing will fail if no unit tests are present" + end + def self.parse(options) unit_config = {} output_options = { @@ -36,11 +45,11 @@ def self.parse(options) opt_parser = OptionParser.new do |opts| opts.banner = "Usage: #{File.basename(__FILE__)} [options]" - opts.on("--skip-unittests", "Don't run unit tests") do |p| + opts.on(CLI_SKIP_UNITTESTS, "Don't run unit tests") do |p| output_options[:skip_unittests] = p end - opts.on("--skip-examples-compilation", "Don't compile example sketches") do |p| + opts.on(CLI_SKIP_EXAMPLES_COMPILATION, "Don't compile example sketches") do |p| output_options[:skip_compilation] = p end @@ -61,140 +70,60 @@ def self.parse(options) end opts.on("-h", "--help", "Prints this help") do - puts opts - puts - puts "Additionally, the following environment variables control the script:" - puts " - #{VAR_CUSTOM_INIT_SCRIPT} - if set, this script will be run from the Arduino/libraries directory" - puts " prior to any automated library installation or testing (e.g. to install unofficial libraries)" - puts " - #{VAR_USE_SUBDIR} - if set, the script will install the library from this subdirectory of the cwd" - puts " - #{VAR_EXPECT_EXAMPLES} - if set, testing will fail if no example sketches are present" - puts " - #{VAR_EXPECT_UNITTESTS} - if set, testing will fail if no unit tests are present" + show_help(opts) exit end end - opt_parser.parse!(options) + begin + opt_parser.parse!(options) + rescue OptionParser::InvalidOption => e + puts e + puts + show_help(opt_parser) + exit 1 + end output_options end end -# Read in command line options and make them read-only -@cli_options = (Parser.parse ARGV).freeze - +# print debugging information from the backend, to be used when things don't go as expected def print_backend_logs - puts "========== Last backend command (if relevant):" - puts @backend.last_msg.to_s - puts "========== Backend Stdout:" - puts @backend.last_out - puts "========== Backend Stderr:" - puts @backend.last_err + @log.iputs "========== Last backend command (if relevant):" + @log.iputs @backend.last_msg.to_s + @log.iputs "========== Backend Stdout:" + @log.iputs @backend.last_out + @log.iputs "========== Backend Stderr:" + @log.iputs @backend.last_err +end + +# describe the last command, to help troubleshoot a failure +# +# @param cpp_library [CppLibrary] +def describe_last_command(cpp_library) + @log.iputs "Last command: #{cpp_library.last_cmd}" + @log.iputs cpp_library.last_out + @log.iputs cpp_library.last_err end # terminate after printing any debug info. TODO: capture debug info def terminate(final = nil) - puts "Failures: #{@failure_count}" - print_backend_logs unless @failure_count.zero? || final || @backend.nil? - retcode = @failure_count.zero? ? 0 : 1 + puts "Failures: #{@log.failure_count}" + print_backend_logs unless @log.failure_count.zero? || final || @backend.nil? + retcode = @log.failure_count.zero? ? 0 : 1 exit(retcode) end -# make a nice status line for an action and react to the action -# TODO / note to self: inform_multiline is tougher to write -# without altering the signature because it only leaves space -# for the checkmark _after_ the multiline, it doesn't know how -# to make that conditionally the body -# @param message String the text of the progress indicator -# @param multiline boolean whether multiline output is expected -# @param mark_fn block (string) -> string that says how to describe the result -# @param on_fail_msg String custom message for failure -# @param tally_on_fail boolean whether to increment @failure_count -# @param abort_on_fail boolean whether to abort immediately on failure (i.e. if this is a fatal error) -def perform_action(message, multiline, mark_fn, on_fail_msg, tally_on_fail, abort_on_fail) - line = "#{message}... " - endline = "...#{message} " - if multiline - puts line - else - print line - end - $stdout.flush - result = yield - mark = mark_fn.nil? ? "" : mark_fn.call(result) - # if multiline, put checkmark at full width - print endline if multiline - puts mark.to_s.rjust(WIDTH - line.length, " ") - unless result - puts on_fail_msg unless on_fail_msg.nil? - @failure_count += 1 if tally_on_fail - # print out error messaging here if we've captured it - terminate if abort_on_fail - end - result -end - -# Make a nice status for something that defers any failure code until script exit -def attempt(message, &block) - perform_action(message, false, @passfail, nil, true, false, &block) -end - -# Make a nice status for something that defers any failure code until script exit -def attempt_multiline(message, &block) - perform_action(message, true, @passfail, nil, true, false, &block) -end - -# Make a nice status for something that kills the script immediately on failure -FAILED_ASSURANCE_MESSAGE = "This may indicate a problem with your configuration; halting here".freeze -def assure(message, &block) - perform_action(message, false, @passfail, FAILED_ASSURANCE_MESSAGE, true, true, &block) -end - -def assure_multiline(message, &block) - perform_action(message, true, @passfail, FAILED_ASSURANCE_MESSAGE, true, true, &block) -end - -def inform(message, &block) - perform_action(message, false, proc { |x| x }, nil, false, false, &block) -end - -def inform_multiline(message, &block) - perform_action(message, true, nil, nil, false, false, &block) -end - -def rule(char) - puts char[0] * WIDTH -end - -def warn(message) - inform("WARNING") { message } -end - -def phase(name) - puts - rule("=") - inform("Beginning the next phase of testing") { name } -end - -def banner - art = [ - " . __ ___", - " _, ,_ _| , . * ._ _ / ` | ", - "(_| [ `(_] (_| | [ ) (_) \\__. _|_ v#{ArduinoCI::VERSION}", - ] - - pad = " " * ((WIDTH - art[2].length) / 2) - art.each { |l| puts "#{pad}#{l}" } - puts -end - # Assure that a platform exists and return its definition def assured_platform(purpose, name, config) platform_definition = config.platform_definition(name) - assure("Requested #{purpose} platform '#{name}' is defined in 'platforms' YML") { !platform_definition.nil? } + @log.assure("Requested #{purpose} platform '#{name}' is defined in 'platforms' YML") { !platform_definition.nil? } platform_definition end +# Perform a config override while explaining it to the user def inform_override(from_where, &block) - inform("Using configuration override from #{from_where}") do + @log.inform("Using configuration override from #{from_where}") do file = block.call file.nil? ? "" : file end @@ -219,29 +148,50 @@ def display_files(pathname) non_hidden = all_files.reject { |path| file_is_hidden_somewhere?(path) } # print files with an indent - puts " Files (excluding hidden files): #{non_hidden.size}" - non_hidden.each { |p| puts " #{p}" } + @log.iputs "Files (excluding hidden files): #{non_hidden.size}" + @log.indent { non_hidden.each(&@log.method(:iputs)) } end +# helper recursive function for library installation +# +# This recursively descends the dependency tree starting from an initial list, +# and either uses existing installations (based on directory naming only) or +# forcibly installs the dependency. Each child dependency logs which parent requested it +# +# @param library_names [Array] the list of libraries to install +# @param on_behalf_of [String] the requestor of a given dependency +# @param already_installed [Array] the set of dependencies installed by previous steps # @return [Array] The list of installed libraries -def install_arduino_library_dependencies(library_names, on_behalf_of, already_installed = []) +def install_arduino_library_dependencies_h(library_names, on_behalf_of, already_installed) installed = already_installed.clone (library_names.map { |n| @backend.library_of_name(n) } - installed).each do |l| if l.installed? - inform("Using pre-existing dependency of #{on_behalf_of}") { l.name } + @log.inform("Using pre-existing dependency of #{on_behalf_of}") { l.name } else - assure("Installing dependency of #{on_behalf_of}: '#{l.name}'") do + @log.assure("Installing dependency of #{on_behalf_of}: '#{l.name}'") do next nil unless l.install l.name end end installed << l.name - installed += install_arduino_library_dependencies(l.arduino_library_dependencies, l.name, installed) + installed += install_arduino_library_dependencies_h(l.arduino_library_dependencies, l.name, installed) end installed end +# @return [Array] The list of installed libraries +def install_arduino_library_dependencies(library_names, on_behalf_of) + if library_names.empty? + @log.inform("Arduino library dependencies (configured in #{on_behalf_of}) to resolve") { library_names.length } + return [] + end + + @log.inform_multiline("Resolving #{library_names.length} Arduino library dependencies configured in #{on_behalf_of})") do + install_arduino_library_dependencies_h(library_names, on_behalf_of, []) + end +end + # @param platforms [Array] list of platforms to consider # @param specific_config [CIConfig] configuration to use def install_all_packages(platforms, specific_config) @@ -252,28 +202,29 @@ def install_all_packages(platforms, specific_config) all_packages.each do |pkg| next if @backend.boards_installed?(pkg) - url = assure("Board package #{pkg} has a defined URL") { specific_config.package_url(pkg) } + url = @log.assure("Board package #{pkg} has a defined URL") { specific_config.package_url(pkg) } @backend.board_manager_urls = [url] - assure("Installing board package #{pkg}") { @backend.install_boards(pkg) } + @log.assure("Installing board package #{pkg}") { @backend.install_boards(pkg) } end end # @param expectation_envvar [String] the name of the env var to check # @param operation [String] a description of what operation we might be skipping +# @param howto_skip [String] a description of how the runner can skip this # @param filegroup_name [String] a description of the set of files without which we effectively skip the operation # @param dir_description [String] a description of the directory where we looked for the files # @param dir [Pathname] the directory where we looked for the files -def handle_expectation_of_files(expectation_envvar, operation, filegroup_name, dir_description, dir_path) +def handle_expectation_of_files(expectation_envvar, operation, howto_skip, filegroup_name, dir_description, dir_path) # alert future me about running the script from the wrong directory, instead of doing the huge file dump # otherwise, assume that the user might be running the script on a library with no actual unit tests if Pathname.new(__dir__).parent == Pathname.new(Dir.pwd) - inform_multiline("arduino_ci seems to be trying to test itself") do + @log.inform_multiline("arduino_ci seems to be trying to test itself") do [ "arduino_ci (the ruby gem) isn't an arduino project itself, so running the CI test script against", "the core library isn't really a valid thing to do... but it's easy for a developer (including the", "owner) to mistakenly do just that. Hello future me, you probably meant to run this against one of", "the sample projects in SampleProjects/ ... if not, please submit a bug report; what a wild case!" - ].each { |l| puts " #{l}" } + ].each(&@log.method(:iputs)) false end exit(1) @@ -286,25 +237,27 @@ def handle_expectation_of_files(expectation_envvar, operation, filegroup_name, d ["No #{dir_description} at", "base directory", dir_path.parent] end - inform(problem) { dir_path } - explain_and_exercise_envvar(expectation_envvar, operation, "contents of #{dir_desc}") { display_files(dir) } + @log.inform(problem) { dir_path } + explain_and_exercise_envvar(expectation_envvar, operation, howto_skip, "contents of #{dir_desc}") { display_files(dir) } end # @param expectation_envvar [String] the name of the env var to check # @param operation [String] a description of what operation we might be skipping +# @param howto_skip [String] a description of how the runner can skip this # @param block_desc [String] a description of what information will be dumped to assist the user # @param block [Proc] a function that dumps information -def explain_and_exercise_envvar(expectation_envvar, operation, block_desc, &block) - inform("Environment variable #{expectation_envvar} is") { "(#{ENV[expectation_envvar].class}) #{ENV[expectation_envvar]}" } +def explain_and_exercise_envvar(expectation_envvar, operation, howto_skip, block_desc, &block) + @log.inform("Environment variable #{expectation_envvar} is") { "(#{ENV[expectation_envvar].class}) #{ENV[expectation_envvar]}" } if ENV[expectation_envvar].nil? - inform_multiline("Skipping #{operation}") do - puts " In case that's an error, displaying #{block_desc}:" + @log.inform_multiline("Skipping #{operation}") do + @log.iputs "In case that's an error, displaying #{block_desc}:" block.call - puts " To force an error in this case, set the environment variable #{expectation_envvar}" + @log.iputs "To force an error in this case, set the environment variable #{expectation_envvar}" + @log.iputs "To explicitly skip this check, use #{howto_skip}" true end else - assure_multiline("Displaying #{block_desc} before exit") do + @log.assure_multiline("Displaying #{block_desc} before exit") do block.call false end @@ -315,16 +268,16 @@ def explain_and_exercise_envvar(expectation_envvar, operation, block_desc, &bloc def get_annotated_compilers(config, cpp_library) # check GCC compilers = config.compilers_to_use - assure("The set of compilers (#{compilers.length}) isn't empty") { !compilers.empty? } + @log.assure("The set of compilers (#{compilers.length}) isn't empty") { !compilers.empty? } compilers.each do |gcc_binary| - attempt_multiline("Checking #{gcc_binary} version") do + @log.attempt_multiline("Checking #{gcc_binary} version") do version = cpp_library.gcc_version(gcc_binary) next nil unless version - puts version.split("\n").map { |l| " #{l}" }.join("\n") + @log.iputs(version) version end - inform("libasan availability for #{gcc_binary}") { cpp_library.libasan?(gcc_binary) } + @log.inform("libasan availability for #{gcc_binary}") { cpp_library.libasan?(gcc_binary) } end compilers end @@ -336,22 +289,81 @@ def get_annotated_compilers(config, cpp_library) # In this case, the user provided script would fetch a git repo or some other method def perform_custom_initialization(_config) script_path = ENV[VAR_CUSTOM_INIT_SCRIPT] - inform("Environment variable #{VAR_CUSTOM_INIT_SCRIPT}") { "'#{script_path}'" } + @log.inform("Environment variable #{VAR_CUSTOM_INIT_SCRIPT}") { "'#{script_path}'" } return if script_path.nil? return if script_path.empty? script_pathname = Pathname.getwd + script_path - assure("Script at #{VAR_CUSTOM_INIT_SCRIPT} exists") { script_pathname.exist? } + @log.assure("Script at #{VAR_CUSTOM_INIT_SCRIPT} exists") { script_pathname.exist? } - assure_multiline("Running #{script_pathname} with sh in libraries working dir") do + @log.assure_multiline("Running #{script_pathname} with sh in libraries working dir") do Dir.chdir(@backend.lib_dir) do IO.popen(["/bin/sh", script_pathname.to_s], err: [:child, :out]) do |io| - io.each_line { |line| puts " #{line}" } + @log.indent { io.each_line(&@log.method(:iputs)) } end end end end +# Kick off the arduino_ci test process by explaining and adjusting the environment +# +# @return Hash of things needed for later steps +def perform_bootstrap + @log.inform("Host OS") { ArduinoCI::Host.os } + @log.inform("Working directory") { Dir.pwd } + + # initialize command and config + default_config = ArduinoCI::CIConfig.default + inform_override("project") { default_config.override_file_from_project_library } + config = default_config.from_project_library + + backend = ArduinoCI::ArduinoInstallation.autolocate! + @log.inform("Located arduino-cli binary") { backend.binary_path.to_s } + @log.inform("Using arduino-cli version") { backend.version.to_s } + if backend.lib_dir.exist? + @log.inform("Found libraries directory") { backend.lib_dir } + else + @log.assure("Creating libraries directory") { backend.lib_dir.mkpath || true } + end + + # run any library init scripts from the library itself. + perform_custom_initialization(config) + + # initialize library under test + @log.inform("Environment variable #{VAR_USE_SUBDIR}") { "'#{ENV[VAR_USE_SUBDIR]}'" } + cpp_library_path = Pathname.new(ENV[VAR_USE_SUBDIR].nil? ? "." : ENV[VAR_USE_SUBDIR]) + cpp_library = @log.assure("Installing library under test") do + backend.install_local_library(cpp_library_path) + end + + # Warn if the library name isn't obvious + assumed_name = backend.name_of_library(cpp_library_path) + ondisk_name = cpp_library_path.realpath.basename.to_s + @log.warn("Installed library named '#{assumed_name}' has directory name '#{ondisk_name}'") if assumed_name != ondisk_name + + if !cpp_library.nil? + @log.inform("Library installed at") { cpp_library.path.to_s } + else + # this is a longwinded way of failing, we aren't really "assuring" anything at this point + @log.assure_multiline("Library installed successfully") do + @log.iputs backend.last_msg + false + end + end + + install_arduino_library_dependencies( + cpp_library.arduino_library_dependencies, + "<#{ArduinoCI::CppLibrary::LIBRARY_PROPERTIES_FILE}>" + ) + + # return all objects needed by other steps + { + backend: backend, + cpp_library: cpp_library, + config: config, + } +end + # Auto-select some platforms to test based on the information available # # Top choice is always library.properties -- otherwise use the default. @@ -368,14 +380,14 @@ def choose_platform_set(config, reason, desired_platforms, library_properties) if library_properties.nil? || library_properties.architectures.nil? || library_properties.architectures.empty? # verify that all platforms exist desired_platforms.each { |p| assured_platform(reason, p, config) } - return inform_multiline("No architectures listed in library.properties, using configured platforms") do - desired_platforms.each { |p| puts " #{p}" } # this returns desired_platforms + return @log.inform_multiline("No architectures listed in library.properties, using configured platforms") do + desired_platforms.each(&@log.method(:iputs)) # this returns desired_platforms end end if library_properties.architectures.include?("*") - return inform_multiline("Wildcard architecture in library.properties, using configured platforms") do - desired_platforms.each { |p| puts " #{p}" } # this returns desired_platforms + return @log.inform_multiline("Wildcard architecture in library.properties, using configured platforms") do + desired_platforms.each(&@log.method(:iputs)) # this returns desired_platforms end end @@ -385,76 +397,81 @@ def choose_platform_set(config, reason, desired_platforms, library_properties) if config.is_default # completely ignore default config, opting for brute-force library matches # OTOH, we don't need to assure platforms because we defined them - return inform_multiline("Default config, platforms matching architectures in library.properties") do + return @log.inform_multiline("Default config, platforms matching architectures in library.properties") do supported_platforms.keys.each do |p| # rubocop:disable Style/HashEachMethods - puts " #{p}" + @log.iputs(p) end # this returns supported_platforms end end desired_supported_platforms = supported_platforms.select { |p, _| desired_platforms.include?(p) }.keys desired_supported_platforms.each { |p| assured_platform(reason, p, config) } - inform_multiline("Configured platforms that match architectures in library.properties") do + @log.inform_multiline("Configured platforms that match architectures in library.properties") do desired_supported_platforms.each do |p| - puts " #{p}" + @log.iputs(p) end # this returns supported_platforms end end # Unit test procedure def perform_unit_tests(cpp_library, file_config) - phase("Unit testing") + @log.phase("Unit testing") if @cli_options[:skip_unittests] - inform("Skipping unit tests") { "as requested via command line" } + @log.inform("Skipping unit tests") { "as requested via command line" } return end config = file_config.with_override_config(@cli_options[:ci_config]) compilers = get_annotated_compilers(config, cpp_library) - inform("Library conforms to Arduino library specification") { cpp_library.one_point_five? ? "1.5" : "1.0" } + @log.inform("Library conforms to Arduino library specification") { cpp_library.one_point_five? ? "1.5" : "1.0" } # Handle lack of test files if cpp_library.test_files.empty? - handle_expectation_of_files(VAR_EXPECT_UNITTESTS, "unit tests", "test files", "tests directory", cpp_library.tests_dir) + handle_expectation_of_files( + VAR_EXPECT_UNITTESTS, + "unit tests", + CLI_SKIP_UNITTESTS, + "test files", + "tests directory", + cpp_library.tests_dir + ) return end # Get platforms, handle lack of them platforms = choose_platform_set(config, "unittest", config.platforms_to_unittest, cpp_library.library_properties) if platforms.empty? - explain_and_exercise_envvar(VAR_EXPECT_UNITTESTS, "unit tests", "platforms and architectures") do - puts " Configured platforms: #{config.platforms_to_unittest}" - puts " Configuration is default: #{config.is_default}" + explain_and_exercise_envvar(VAR_EXPECT_UNITTESTS, "unit tests", CLI_SKIP_UNITTESTS, "platforms and architectures") do + @log.iputs "Configured platforms: #{config.platforms_to_unittest}" + @log.iputs "Configuration is default: #{config.is_default}" arches = cpp_library.library_properties.nil? ? nil : cpp_library.library_properties.architectures - puts " Architectures in library.properties: #{arches}" + @log.iputs "Architectures in library.properties: #{arches}" end end # having undefined platforms is a config error platforms.select { |p| config.platform_info[p].nil? }.each do |p| - assure("Platform '#{p}' is defined in configuration files") { false } + @log.assure("Platform '#{p}' is defined in configuration files") { false } end install_arduino_library_dependencies(config.aux_libraries_for_unittest, "") platforms.each do |p| - puts + @log.iputs compilers.each do |gcc_binary| # before compiling the tests, build a shared library of everything except the test code - next @failure_count += 1 unless build_shared_library(gcc_binary, p, config, cpp_library) + next @log.failure_count += 1 unless build_shared_library(gcc_binary, p, config, cpp_library) # now build and run each test using the shared library build above config.allowable_unittest_files(cpp_library.test_files).each do |unittest_path| unittest_name = unittest_path.basename.to_s - puts "--------------------------------------------------------------------------------" - attempt_multiline("Unit testing #{unittest_name} with #{gcc_binary} for #{p}") do + @log.rule "-" + @log.attempt_multiline("Unit testing #{unittest_name} with #{gcc_binary} for #{p}") do exe = cpp_library.build_for_test(unittest_path, gcc_binary) - puts + @log.iputs unless exe - puts "Last command: #{cpp_library.last_cmd}" - puts cpp_library.last_out - puts cpp_library.last_err + describe_last_command(cpp_library) next false end cpp_library.run_test_file(exe) @@ -465,33 +482,36 @@ def perform_unit_tests(cpp_library, file_config) end def build_shared_library(gcc_binary, platform, config, cpp_library) - attempt_multiline("Build shared library with #{gcc_binary} for #{platform}") do + @log.attempt_multiline("Build shared library with #{gcc_binary} for #{platform}") do exe = cpp_library.build_shared_library( config.aux_libraries_for_unittest, gcc_binary, config.gcc_config(platform) ) - puts - unless exe - puts "Last command: #{cpp_library.last_cmd}" - puts cpp_library.last_out - puts cpp_library.last_err - end + @log.iputs + describe_last_command(cpp_library) unless exe exe end end def perform_example_compilation_tests(cpp_library, config) - phase("Compilation of example sketches") + @log.phase("Compilation of example sketches") if @cli_options[:skip_compilation] - inform("Skipping compilation of examples") { "as requested via command line" } + @log.inform("Skipping compilation of examples") { "as requested via command line" } return end library_examples = cpp_library.example_sketches if library_examples.empty? - handle_expectation_of_files(VAR_EXPECT_EXAMPLES, "builds", "examples", "the examples directory", cpp_library.examples_dir) + handle_expectation_of_files( + VAR_EXPECT_EXAMPLES, + "builds", + CLI_SKIP_EXAMPLES_COMPILATION, + "examples", + "the examples directory", + cpp_library.examples_dir + ) return end @@ -500,8 +520,8 @@ def perform_example_compilation_tests(cpp_library, config) library_examples.each do |example_path| example_name = File.basename(example_path) - puts - inform("Discovered example sketch") { example_name } + @log.iputs + @log.inform("Discovered example sketch") { example_name } inform_override("example") { ex_config.override_file_from_example(example_path) } ovr_config = ex_config.from_example(example_path) @@ -510,17 +530,22 @@ def perform_example_compilation_tests(cpp_library, config) # having no platforms defined is probably an error if platforms.empty? - explain_and_exercise_envvar(VAR_EXPECT_EXAMPLES, "examples compilation", "platforms and architectures") do - puts " Configured platforms: #{ovr_config.platforms_to_build}" - puts " Configuration is default: #{ovr_config.is_default}" + explain_and_exercise_envvar( + VAR_EXPECT_EXAMPLES, + "examples compilation", + CLI_SKIP_EXAMPLES_COMPILATION, + "platforms and architectures" + ) do + @log.iputs "Configured platforms: #{ovr_config.platforms_to_build}" + @log.iputs "Configuration is default: #{ovr_config.is_default}" arches = cpp_library.library_properties.nil? ? nil : cpp_library.library_properties.architectures - puts " Architectures in library.properties: #{arches}" + @log.iputs "Architectures in library.properties: #{arches}" end end # having undefined platforms is a config error platforms.select { |p| ovr_config.platform_info[p].nil? }.each do |p| - assure("Platform '#{p}' is defined in configuration files") { false } + @log.assure("Platform '#{p}' is defined in configuration files") { false } end install_all_packages(platforms, ovr_config) @@ -528,75 +553,58 @@ def perform_example_compilation_tests(cpp_library, config) platforms.each do |p| board = ovr_config.platform_info[p][:board] # assured to exist, above - attempt("Compiling #{example_name} for #{board}") do - ret = @backend.compile_sketch(example_path, board) - unless ret - puts "Last command: #{@backend.last_msg}" - puts @backend.last_err + compiled_ok = @log.attempt("Compiling #{example_name} for #{board}") do + @backend.compile_sketch(example_path, board) + end + + # decode the JSON output of the compiler a little bit + unless compiled_ok + @log.inform_multiline("Compilation failure details") do + begin + # parse the JSON, and print out only the nonempty keys. indent them with 4 spaces in their own labelled sections + msg_json = JSON.parse(@backend.last_msg) + msg_json.each do |k, v| + val = if v.is_a?(Hash) || v.is_a?(Array) + JSON.pretty_generate(v) + else + v.to_s + end + @log.inform_multiline(k) { @log.iputs(val) } unless val.strip.empty? + end + rescue JSON::ParserError + # worst case: dump it + @log.iputs "Last command: #{@backend.last_msg}" + end + @log.iputs @backend.last_err end - ret end + # reporting or enforcing of free space + usage = @backend.last_bytes_usage + @log.inform("Free space (bytes) after compilation") { usage[:free] } next if @cli_options[:min_free_space].nil? - usage = @backend.last_bytes_usage min_free_space = @cli_options[:min_free_space] - attempt("Checking that the free space of #{usage[:free]} is at least the desired minimum of #{min_free_space}") do + @log.attempt("Free space exceeds desired minimum #{min_free_space}") do min_free_space <= usage[:free] end end end end -banner -inform("Host OS") { ArduinoCI::Host.os } -inform("Working directory") { Dir.pwd } - -# initialize command and config -default_config = ArduinoCI::CIConfig.default -inform_override("project") { default_config.override_file_from_project_library } -config = default_config.from_project_library - -@backend = ArduinoCI::ArduinoInstallation.autolocate! -inform("Located arduino-cli binary") { @backend.binary_path.to_s } -inform("Using arduino-cli version") { @backend.version.to_s } -if @backend.lib_dir.exist? - inform("Found libraries directory") { @backend.lib_dir } -else - assure("Creating libraries directory") { @backend.lib_dir.mkpath || true } -end - -# run any library init scripts from the library itself. -perform_custom_initialization(config) - -# initialize library under test -inform("Environment variable #{VAR_USE_SUBDIR}") { "'#{ENV[VAR_USE_SUBDIR]}'" } -cpp_library_path = Pathname.new(ENV[VAR_USE_SUBDIR].nil? ? "." : ENV[VAR_USE_SUBDIR]) -cpp_library = assure("Installing library under test") do - @backend.install_local_library(cpp_library_path) -end +############################################################### +# script execution +# -# Warn if the library name isn't obvious -assumed_name = @backend.name_of_library(cpp_library_path) -ondisk_name = cpp_library_path.realpath.basename.to_s -warn("Installed library named '#{assumed_name}' has directory name '#{ondisk_name}'") if assumed_name != ondisk_name - -if !cpp_library.nil? - inform("Library installed at") { cpp_library.path.to_s } -else - # this is a longwinded way of failing, we aren't really "assuring" anything at this point - assure_multiline("Library installed successfully") do - puts @backend.last_msg - false - end -end +# Read in command line options and make them read-only +@cli_options = Parser.parse(ARGV).freeze -install_arduino_library_dependencies( - cpp_library.arduino_library_dependencies, - "<#{ArduinoCI::CppLibrary::LIBRARY_PROPERTIES_FILE}>" -) +@log = ArduinoCI::Logger.auto_width +@log.banner -perform_unit_tests(cpp_library, config) -perform_example_compilation_tests(cpp_library, config) +strap = perform_bootstrap +@backend = strap[:backend] +perform_unit_tests(strap[:cpp_library], strap[:config]) +perform_example_compilation_tests(strap[:cpp_library], strap[:config]) terminate(true) diff --git a/lib/arduino_ci.rb b/lib/arduino_ci.rb index 344a1463..85b025fa 100644 --- a/lib/arduino_ci.rb +++ b/lib/arduino_ci.rb @@ -1,4 +1,5 @@ require "arduino_ci/version" +require "arduino_ci/logger" require "arduino_ci/arduino_installation" require "arduino_ci/cpp_library" require "arduino_ci/ci_config" diff --git a/lib/arduino_ci/logger.rb b/lib/arduino_ci/logger.rb new file mode 100644 index 00000000..3fc71874 --- /dev/null +++ b/lib/arduino_ci/logger.rb @@ -0,0 +1,243 @@ +require 'io/console' + +module ArduinoCI + + # Provide all text processing functions to aid readability of the test log + class Logger + + TAB_WIDTH = 4 + INDENT_CHAR = " ".freeze + + # @return [Integer] the cardinal number of indents + attr_reader :tab + + # @return [Integer] The number of failures reported through the logging mechanism + attr_reader :failure_count + + # @param width [int] The desired console width + def initialize(width = nil) + @tab = 0 + @width = width.nil? ? 80 : width + @failure_count = 0 + @passfail = proc { |result| result ? "✓" : "✗" } + end + + # create a logger that's automatically sized to the console, between 80 and 132 characters + def self.auto_width + width = begin + [132, [80, IO::console.winsize[1] - 2].max].min + rescue NoMethodError + 80 + end + + self.new(width) + end + + # print a nice banner for this project + def banner + art = [ + " . __ ___", + " _, ,_ _| , . * ._ _ / ` | ", + "(_| [ `(_] (_| | [ ) (_) \\__. _|_ v#{ArduinoCI::VERSION}", + ] + + pad = " " * ((@width - art[2].length) / 2) + art.each { |l| puts "#{pad}#{l}" } + puts + end + + # @return [String] the current line indentation + def indentation + (INDENT_CHAR * TAB_WIDTH * @tab) + end + + # put an indented string + # + # @param str [String] the string to puts + # @return [void] + def iputs(str = "") + print(indentation) + + # split the lines and interleave with a newline character, then render + stream_lines = str.to_s.split("\n") + marked_stream_lines = stream_lines.flat_map { |s| [s, :nl] }.tap(&:pop) + marked_stream_lines.each { |l| print(l == :nl ? "\n#{indentation}" : l) } + puts + end + + # print an indented string + # + # @param str [String] the string to print + # @return [void] + def iprint(str) + print(indentation) + print(str) + end + + # increment an indentation level for the duration of a block's execution + # + # @param amount [Integer] the number of tabs to indent + # @yield [] The code to execute while indented + # @return [void] + def indent(amount = 1, &block) + @tab += amount + block.call + ensure + @tab -= amount + end + + # make a nice status line for an action and react to the action + # + # TODO / note to self: inform_multiline is tougher to write + # without altering the signature because it only leaves space + # for the checkmark _after_ the multiline, it doesn't know how + # to make that conditionally the body + # + # @param message String the text of the progress indicator + # @param multiline boolean whether multiline output is expected + # @param mark_fn block (string) -> string that says how to describe the result + # @param on_fail_msg String custom message for failure + # @param tally_on_fail boolean whether to increment @failure_count + # @param abort_on_fail boolean whether to abort immediately on failure (i.e. if this is a fatal error) + # @yield [] The action being performed + # @yieldreturn [Object] whether the action was successful, can be any type but it is evaluated as a boolean + # @return [Object] The return value of the block + def perform_action(message, multiline, mark_fn, on_fail_msg, tally_on_fail, abort_on_fail) + line = "#{indentation}#{message}... " + endline = "#{indentation}...#{message} " + if multiline + puts line + @tab += 1 + else + print line + end + $stdout.flush + + # handle the block and any errors it raises + caught_error = nil + begin + result = yield + rescue StandardError => e + caught_error = e + result = false + ensure + @tab -= 1 if multiline + end + + # put the trailing mark + mark = mark_fn.nil? ? "" : mark_fn.call(result) + # if multiline, put checkmark at full width + print endline if multiline + puts mark.to_s.rjust(@width - line.length, " ") + unless result + iputs on_fail_msg unless on_fail_msg.nil? + raise caught_error unless caught_error.nil? + + @failure_count += 1 if tally_on_fail + terminate if abort_on_fail + end + result + end + + # Make a nice status (with checkmark) for something that defers any failure code until script exit + # + # @param message the message to print + # @yield [] The action being performed + # @yieldreturn [boolean] whether the action was successful + # @return [Object] The return value of the block + def attempt(message, &block) + perform_action(message, false, @passfail, nil, true, false, &block) + end + + # Make a nice multiline status (with checkmark) for something that defers any failure code until script exit + # + # @param message the message to print + # @yield [] The action being performed + # @yieldreturn [boolean] whether the action was successful + # @return [Object] The return value of the block + def attempt_multiline(message, &block) + perform_action(message, true, @passfail, nil, true, false, &block) + end + + FAILED_ASSURANCE_MESSAGE = "This may indicate a problem with your configuration; halting here".freeze + # Make a nice status (with checkmark) for something that kills the script immediately on failure + # + # @param message the message to print + # @yield [] The action being performed + # @yieldreturn [boolean] whether the action was successful + # @return [Object] The return value of the block + def assure(message, &block) + perform_action(message, false, @passfail, FAILED_ASSURANCE_MESSAGE, true, true, &block) + end + + # Make a nice multiline status (with checkmark) for something that kills the script immediately on failure + # + # @param message the message to print + # @yield [] The action being performed + # @yieldreturn [boolean] whether the action was successful + # @return [Object] The return value of the block + def assure_multiline(message, &block) + perform_action(message, true, @passfail, FAILED_ASSURANCE_MESSAGE, true, true, &block) + end + + # print a failure message (with checkmark) but do not tally a failure + # @param message the message to print + # @return [Object] The return value of the block + def warn(message) + inform("WARNING") { message } + end + + # print a failure message (with checkmark) but do not exit + # @param message the message to print + # @return [Object] The return value of the block + def fail(message) + attempt(message) { false } + end + + # print a failure message (with checkmark) and exit immediately afterward + # @param message the message to print + # @return [Object] The return value of the block + def halt(message) + assure(message) { false } + end + + # Print a value as a status line "message... retval" + # + # @param message the message to print + # @yield [] The action being performed + # @yieldreturn [String] The value to print at the end of the line + # @return [Object] The return value of the block + def inform(message, &block) + perform_action(message, false, proc { |x| x }, nil, false, false, &block) + end + + # Print section beginning and end + # + # @param message the message to print + # @yield [] The action being performed + # @return [Object] The return value of the block + def inform_multiline(message, &block) + perform_action(message, true, nil, nil, false, false, &block) + end + + # Print a horizontal rule across the console + # @param char [String] the character to use + # @return [void] + def rule(char) + puts char[0] * @width + end + + # Print a section heading to the console to break up the text output + # + # @param name [String] the section name + # @return [void] + def phase(name) + puts + rule("=") + puts("| #{name}") + puts("====") + end + + end + +end