diff --git a/.travis.yml b/.travis.yml index 08dae787..e404d04b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,15 +1,16 @@ sudo: false language: ruby -rvm: +# rvm: # - "2.0.0" # - "2.1.0" # - "2.2.0" # - rbx - - "2.5.0" +# - "2.5.0" before_install: gem install bundler -v 1.15.4 script: - bundle exec rubocop --version - bundle exec rubocop -D . - bundle exec rspec + - bundle exec ci_system_check.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 72f7c402..ccc89a72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added +- `ArduinoInstallation` class for managing lib / executable paths +- `DisplayManager` class for managing Xvfb instance if needed +- `ArduinoCmd` can report on whether a board is installed ### Changed +- `DisplayManger.with_display` doesn't `disable` if the display was enabled prior to starting the block ### Deprecated diff --git a/arduino_ci.gemspec b/arduino_ci.gemspec index 1ba46750..b25f8ba6 100644 --- a/arduino_ci.gemspec +++ b/arduino_ci.gemspec @@ -14,9 +14,12 @@ Gem::Specification.new do |spec| spec.description = spec.description spec.homepage = "http://github.com/ifreecarve/arduino_ci" - spec.files = ['README.md', '.yardopts'] + Dir['lib/**/*.*'].reject { |f| f.match(%r{^(test|spec|features)/}) } - spec.bindir = "exe" + rejection_regex = %r{^(test|spec|features)/} + libfiles = Dir['lib/**/*.*'].reject { |f| f.match(rejection_regex) } + binfiles = Dir[File.join(spec.bindir, '/**/*.*')].reject { |f| f.match(rejection_regex) } + spec.files = ['README.md', '.yardopts'] + libfiles + binfiles + spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] diff --git a/exe/ci_system_check.rb b/exe/ci_system_check.rb new file mode 100755 index 00000000..bbd104ad --- /dev/null +++ b/exe/ci_system_check.rb @@ -0,0 +1,23 @@ +require 'arduino_ci' + +puts "Enabling display with display manager" +ArduinoCI::DisplayManager::instance.enable + +puts "Autlocating Arduino command" +arduino_cmd = ArduinoCI::ArduinoCmd.autolocate! + +board_tests = { + "arduino:avr:uno" => true, + "eggs:milk:wheat" => false, +} + +got_problem = false +board_tests.each do |k, v| + puts "I expect arduino_cmd.board_installed?(#{k}) to be #{v}" + result = arduino_cmd.board_installed?(k) + puts " board_installed?(#{k}) returns #{result}. expected #{v}" + got_problem = true if v != result +end + +abort if got_problem +exit(0) diff --git a/lib/arduino_ci.rb b/lib/arduino_ci.rb index 7edf9596..2f871c67 100644 --- a/lib/arduino_ci.rb +++ b/lib/arduino_ci.rb @@ -1,124 +1,8 @@ require "arduino_ci/version" - -require 'singleton' - -# Cross-platform way of finding an executable in the $PATH. -# via https://stackoverflow.com/a/5471032/2063546 -# which('ruby') #=> /usr/bin/ruby -def which(cmd) - exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : [''] - ENV['PATH'].split(File::PATH_SEPARATOR).each do |path| - exts.each do |ext| - exe = File.join(path, "#{cmd}#{ext}") - return exe if File.executable?(exe) && !File.directory?(exe) - end - end - nil -end +require "arduino_ci/arduino_cmd" # ArduinoCI contains classes for automated testing of Arduino code on the command line # @author Ian Katz module ArduinoCI - # Wrap the Arduino executable. This requires, in some cases, a faked display. - class ArduinoCmd - - # create as many ArduinoCmds as you like, but we need one and only one display manager - class DisplayMgr - include Singleton - attr_reader :enabled - - def initialize - @existing = existing_display? - @enabled = false - @pid = nil - end - - # attempt to determine if the machine is running a graphical display (i.e. not Travis) - def existing_display? - return true if RUBY_PLATFORM.include? "darwin" - return true if ENV["DISPLAY"].nil? - return true if ENV["DISPLAY"].include? ":" - false - end - - # enable a virtual display - def enable - return @enabled = true if @existing # silent no-op if built in display - return unless @pid.nil? - - @enabled = true - @pid = fork do - puts "Forking Xvfb" - system("Xvfb", ":1", "-ac", "-screen", "0", "1280x1024x16") - puts "Xvfb unexpectedly quit!" - end - sleep(3) # TODO: test a connection to the X server? - end - - # disable the virtual display - def disable - return @enabled = false if @existing # silent no-op if built in display - return if @pid.nil? - - begin - Process.kill 9, @pid - ensure - Process.wait @pid - @pid = nil - end - puts "Xvfb killed" - end - - # Enable a virtual display for the duration of the given block - def with_display - enable - begin - yield environment - ensure - disable - end - end - - def environment - return nil unless @existing || @enabled - return {} if @existing - { DISPLAY => ":1.0" } - end - - # On finalize, ensure child process is ended - def self.finalize - disable - end - end - - class << self - protected :new - - # attempt to find a workable Arduino executable across platforms - def guess_executable_location - osx_place = "/Applications/Arduino.app/Contents/MacOS/Arduino" - places = { - "arduino" => !which("arduino").nil?, - osx_place => (File.exist? osx_place), - } - places.each { |k, v| return k if v } - nil - end - - def autolocate - ret = new - ret.path = guess_executable_location - ret - end - end - - attr_accessor :path - - def initialize - @display_mgr = DisplayMgr::instance - end - - end - end diff --git a/lib/arduino_ci/arduino_cmd.rb b/lib/arduino_ci/arduino_cmd.rb new file mode 100644 index 00000000..fdb0bdfb --- /dev/null +++ b/lib/arduino_ci/arduino_cmd.rb @@ -0,0 +1,43 @@ +require 'arduino_ci/display_manager' +require 'arduino_ci/arduino_installation' + +module ArduinoCI + + # Wrap the Arduino executable. This requires, in some cases, a faked display. + class ArduinoCmd + + class << self + protected :new + + # @return [ArduinoCmd] A command object with a best guess (or nil) for the installation + def autolocate + new(ArduinoInstallation.autolocate) + end + + # @return [ArduinoCmd] A command object, installing Arduino if necessary + def autolocate! + new(ArduinoInstallation.autolocate!) + end + + end + + attr_accessor :installation + + # @param installation [ArduinoInstallation] the location of the Arduino program installation + def initialize(installation) + @display_mgr = DisplayManager::instance + @installation = installation + end + + # run the arduino command + def run(*args) + full_args = [@installation.cmd_path] + args + @display_mgr.run(*full_args) + end + + def board_installed?(board) + run("--board", board) + end + + end +end diff --git a/lib/arduino_ci/arduino_installation.rb b/lib/arduino_ci/arduino_installation.rb new file mode 100644 index 00000000..10ef76fd --- /dev/null +++ b/lib/arduino_ci/arduino_installation.rb @@ -0,0 +1,65 @@ +require "arduino_ci/host" + +module ArduinoCI + + # Manage the OS-specific install location of Arduino + class ArduinoInstallation + attr_accessor :cmd_path + attr_accessor :lib_dir + + class << self + def force_install_location + File.join(ENV['HOME'], 'arduino_ci_ide') + end + + # attempt to find a workable Arduino executable across platforms + def autolocate + ret = new + + osx_place = "/Applications/Arduino.app/Contents/MacOS" + if File.exist? osx_place + ret.cmd_path = File.join(osx_place, "Arduino") + ret.lib_dir = File.join(osx_place, "Libraries") + return ret + end + + posix_place = Host.which("arduino") + unless posix_place.nil? + ret.cmd_path = posix_place + ret.lib_dir = File.join(ENV['HOME'], "Sketchbook") # assume linux + # https://learn.adafruit.com/adafruit-all-about-arduino-libraries-install-use/how-to-install-a-library + return ret + end + + if File.exist? force_install_location + ret.cmd_path = File.join(force_install_location, "arduino") + ret.lib_dir = File.join(force_install_location, "libraries") + # TODO: "libraries" is what's in the adafruit install.sh script + return ret + end + + ret + end + + # Attempt to find a workable Arduino executable across platforms, and install it if we don't + def autolocate! + candidate = autolocate + return candidate unless candidate.cmd_path.nil? + # force the install + + if force_install + candidate.cmd_path = File.join(force_install_location, "arduino") + candidate.lib_dir = File.join(force_install_location, "libraries") + end + candidate + end + + def force_install + system("wget", "https://downloads.arduino.cc/arduino-1.6.5-linux64.tar.xz") + system("tar", "xf", "arduino-1.6.5-linux64.tar.xz") + system("mv", "arduino-1.6.5", force_install_location) + end + + end + end +end diff --git a/lib/arduino_ci/display_manager.rb b/lib/arduino_ci/display_manager.rb new file mode 100644 index 00000000..98f2d7c8 --- /dev/null +++ b/lib/arduino_ci/display_manager.rb @@ -0,0 +1,154 @@ +require 'arduino_ci/host' +require 'singleton' +require 'timeout' + +DESIRED_DISPLAY = ":1.0".freeze + +module ArduinoCI + + # When arduino commands run, they need a graphical display. + # This class handles the setup of that display, if needed. + class DisplayManager + include Singleton + attr_reader :enabled + + def initialize + @existing = existing_display? + @enabled = false + @pid = nil + end + + # attempt to determine if the machine is running a graphical display (i.e. not Travis) + def existing_display? + return true if RUBY_PLATFORM.include? "darwin" + return false if ENV["DISPLAY"].nil? + return true if ENV["DISPLAY"].include? ":" + false + end + + # check whether a process is alive + # https://stackoverflow.com/a/32513298/2063546 + def alive?(pid) + Process.kill(0, pid) + true + rescue + false + end + + # check whether an X server is taking connections + def xserver_exist?(display) + run_silent({ "DISPLAY" => display }, "xdpyinfo") + end + + # wait for the xvfb command to launch + # @param display [String] the value of the DISPLAY env var + # @param pid [Int] the process of Xvfb + # @param timeout [Int] the timeout in seconds + # @return [Bool] whether we detected a launch + def xvfb_launched?(display, pid, timeout) + Timeout.timeout(timeout) do + loop do + return false unless alive? pid + return true if xserver_exist? display + end + end + rescue Timeout::Error + false + end + + # enable a virtual display + def enable + if @existing + puts "DisplayManager enable: no-op for what appears to be an existing display" + @enabled = true + return + end + + return unless @pid.nil? # TODO: disable first? + + # open Xvfb + xvfb_cmd = [ + "Xvfb", + "+extension", "RANDR", + ":1", + "-ac", + "-screen", "0", + "1280x1024x16", + ] + puts "pipeline_start for Xvfb" + pipe = IO.popen(xvfb_cmd) + @pid = pipe.pid + @enabled = xvfb_launched?(DESIRED_DISPLAY, @pid, 30) + end + + # disable the virtual display + def disable + if @existing + puts "DisplayManager disable: no-op for what appears to be an existing display" + return @enabled = false + end + + return @enabled = false if @pid.nil? + + # https://www.whatastruggle.com/timeout-a-subprocess-in-ruby + begin + Timeout.timeout(30) do + Process.kill("TERM", @pid) + puts "Xvfb TERMed" + end + rescue Timeout::Error + Process.kill(9, @pid) + puts "Xvfb KILLed" + ensure + Process.wait @pid + @enabled = false + end + end + + # Enable a virtual display for the duration of the given block + def with_display + was_enabled = @enabled + enable unless was_enabled + begin + yield environment + ensure + disable unless was_enabled + end + end + + # run a command in a display + def run(*args, **kwargs) + ret = false + # do some work to extract & merge environment variables if they exist + has_env = !args.empty? && args[0].class == Hash + with_display do |env_vars| + env_vars = {} if env_vars.nil? + env_vars.merge!(args[0]) if has_env + actual_args = has_env ? args[1..-1] : args # need to shift over if we extracted args + full_cmd = env_vars.empty? ? actual_args : [env_vars] + actual_args + + puts "Running #{env_vars} $ #{args.join(' ')}" + puts "Full_cmd is #{full_cmd}" + puts "kwargs is #{kwargs}" + ret = system(*full_cmd, **kwargs) + end + ret + end + + # run a command in a display with no output + def run_silent(*args) + run(*args, out: File::NULL, err: File::NULL) + end + + def environment + return nil unless @existing || @enabled + return {} if @existing + { "DISPLAY" => DESIRED_DISPLAY } + end + + # On finalize, ensure child process is ended + def self.finalize + disable + end + end +end diff --git a/lib/arduino_ci/host.rb b/lib/arduino_ci/host.rb new file mode 100644 index 00000000..aeed2c2f --- /dev/null +++ b/lib/arduino_ci/host.rb @@ -0,0 +1,19 @@ +module ArduinoCI + + # Tools for interacting with the host machine + class Host + # Cross-platform way of finding an executable in the $PATH. + # via https://stackoverflow.com/a/5471032/2063546 + # which('ruby') #=> /usr/bin/ruby + def self.which(cmd) + exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : [''] + ENV['PATH'].split(File::PATH_SEPARATOR).each do |path| + exts.each do |ext| + exe = File.join(path, "#{cmd}#{ext}") + return exe if File.executable?(exe) && !File.directory?(exe) + end + end + nil + end + end +end diff --git a/spec/arduino_ci_spec.rb b/spec/arduino_ci_spec.rb index 0f3d9c57..e63fca26 100644 --- a/spec/arduino_ci_spec.rb +++ b/spec/arduino_ci_spec.rb @@ -1,7 +1,20 @@ require "spec_helper" RSpec.describe ArduinoCI do - it "has a version number" do - expect(ArduinoCI::VERSION).not_to be nil + context "gem" do + it "has a version number" do + expect(ArduinoCI::VERSION).not_to be nil + end end end + +RSpec.describe ArduinoCI::Host do + context "which" do + it "can find things with which" do + ruby_path = ArduinoCI::Host.which("ruby") + expect(ruby_path).not_to be nil + expect(ruby_path.include? "ruby").to be true + end + end + +end diff --git a/spec/arduino_cmd_spec.rb b/spec/arduino_cmd_spec.rb new file mode 100644 index 00000000..ac44d081 --- /dev/null +++ b/spec/arduino_cmd_spec.rb @@ -0,0 +1,28 @@ +require "spec_helper" + +RSpec.describe ArduinoCI::ArduinoCmd do + context "autolocate" do + it "Finds the Arduino executable" do + arduino_cmd = ArduinoCI::ArduinoCmd.autolocate + end + end + + context "autolocate!" do + it "Finds the Arduino executable" do + arduino_cmd = ArduinoCI::ArduinoCmd.autolocate! + expect(arduino_cmd.installation.cmd_path).not_to be nil + end + end + + context "board_installed?" do + arduino_cmd = ArduinoCI::ArduinoCmd.autolocate! + ArduinoCI::DisplayManager::instance.enable + it "Finds installed boards" do + expect(arduino_cmd.board_installed? "arduino:avr:uno").to be true + end + + it "Doesn't find bogus boards" do + expect(arduino_cmd.board_installed? "eggs:milk:wheat").to be false + end + end +end diff --git a/spec/arduino_exec_spec.rb b/spec/arduino_exec_spec.rb deleted file mode 100644 index 19d2147a..00000000 --- a/spec/arduino_exec_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -require "spec_helper" - -RSpec.describe ArduinoCI::ArduinoCmd do - it "Finds the Arduino executable" do - arduino_cmd = ArduinoCI::ArduinoCmd.autolocate - # expect(arduino_cmd.path).not_to be nil - end -end - -RSpec.describe ArduinoCI::ArduinoCmd::DisplayMgr do - context "singleton ::instance" do - it "produces an instance" do - expect(ArduinoCI::ArduinoCmd::DisplayMgr::instance).not_to be_nil - end - end - - context "with_display" do - it "Properly enables and disables" do - manager = ArduinoCI::ArduinoCmd::DisplayMgr::instance - expect(manager.enabled).to be false - manager.with_display do |environment| - expect(manager.enabled).to be true - expect(environment.class).to eq(Hash) - also_manager = ArduinoCI::ArduinoCmd::DisplayMgr::instance - expect(also_manager.enabled).to be true - end - expect(manager.enabled).to be false - end - end -end diff --git a/spec/arduino_installation_spec.rb b/spec/arduino_installation_spec.rb new file mode 100644 index 00000000..c0a81278 --- /dev/null +++ b/spec/arduino_installation_spec.rb @@ -0,0 +1,25 @@ +require "spec_helper" + +RSpec.describe ArduinoCI::ArduinoInstallation do + context "force_install_location" do + it "is resolvable" do + expect(ArduinoCI::ArduinoInstallation.force_install_location).not_to be nil + end + end + + context "autolocate" do + it "doesn't fail" do + ArduinoCI::ArduinoInstallation.autolocate + end + end + + context "autolocate!" do + it "doesn't fail" do + installation = ArduinoCI::ArduinoInstallation.autolocate! + expect(installation.cmd_path).not_to be nil + expect(installation.lib_dir).not_to be nil + end + end + +end + diff --git a/spec/display_manager_spec.rb b/spec/display_manager_spec.rb new file mode 100644 index 00000000..60b25456 --- /dev/null +++ b/spec/display_manager_spec.rb @@ -0,0 +1,37 @@ +require "spec_helper" + +RSpec.describe ArduinoCI::DisplayManager do + context "singleton ::instance" do + it "produces an instance" do + expect(ArduinoCI::DisplayManager::instance).not_to be_nil + end + end + + context "with_display" do + manager = ArduinoCI::DisplayManager::instance + manager.disable + + it "Properly enables and disables when not previously enabled" do + expect(manager.enabled).to be false + manager.with_display do |environment| + expect(manager.enabled).to be true + expect(environment.class).to eq(Hash) + also_manager = ArduinoCI::DisplayManager::instance + expect(also_manager.enabled).to be true + end + expect(manager.enabled).to be false + end + + it "Properly enables and disables when previously enabled" do + manager.enable + expect(manager.enabled).to be true + manager.with_display do |environment| + expect(manager.enabled).to be true + expect(environment.class).to eq(Hash) + also_manager = ArduinoCI::DisplayManager::instance + expect(also_manager.enabled).to be true + end + expect(manager.enabled).to be true + end + end +end