summaryrefslogtreecommitdiffstats
path: root/features/support
diff options
context:
space:
mode:
authorTails developers <amnesia@boum.org>2014-12-19 00:40:08 +0100
committerHolger Levsen <holger@layer-acht.org>2014-12-21 09:45:40 +0100
commit51680b6ebb645d37ebdfcd122ca163b3a638aefa (patch)
tree337e128d2eac3cbc89ecbacf38851bfa33469cd5 /features/support
parent44bab3c86ca3d95837f4c50cc535206352385a46 (diff)
downloadjenkins.debian.net-51680b6ebb645d37ebdfcd122ca163b3a638aefa.tar.xz
files copied from https://git-tails.immerda.ch/tails - many thanks to the tails developers for their nice work and documentation of it - these files have been released under the GNU General Public License version 3 or (at your option) any later version
features/images has been omitted
Diffstat (limited to 'features/support')
-rw-r--r--features/support/config.rb34
-rw-r--r--features/support/env.rb53
-rw-r--r--features/support/extra_hooks.rb45
-rw-r--r--features/support/helpers/display_helper.rb51
-rw-r--r--features/support/helpers/exec_helper.rb61
-rw-r--r--features/support/helpers/firewall_helper.rb100
-rw-r--r--features/support/helpers/misc_helpers.rb121
-rw-r--r--features/support/helpers/net_helper.rb42
-rw-r--r--features/support/helpers/sikuli_helper.rb145
-rw-r--r--features/support/helpers/storage_helper.rb143
-rw-r--r--features/support/helpers/vm_helper.rb426
-rw-r--r--features/support/hooks.rb156
12 files changed, 1377 insertions, 0 deletions
diff --git a/features/support/config.rb b/features/support/config.rb
new file mode 100644
index 00000000..b5f6fcd9
--- /dev/null
+++ b/features/support/config.rb
@@ -0,0 +1,34 @@
+require 'fileutils'
+require "#{Dir.pwd}/features/support/helpers/misc_helpers.rb"
+
+# Dynamic
+$tails_iso = ENV['ISO'] || get_newest_iso
+$old_tails_iso = ENV['OLD_ISO'] || get_oldest_iso
+$tmp_dir = ENV['TEMP_DIR'] || "/tmp/TailsToaster"
+$vm_xml_path = ENV['VM_XML_PATH'] || "#{Dir.pwd}/features/domains"
+$misc_files_dir = "#{Dir.pwd}/features/misc_files"
+$keep_snapshots = !ENV['KEEP_SNAPSHOTS'].nil?
+$x_display = ENV['DISPLAY']
+$debug = !ENV['DEBUG'].nil?
+$pause_on_fail = !ENV['PAUSE_ON_FAIL'].nil?
+$time_at_start = Time.now
+$live_user = cmd_helper(". config/chroot_local-includes/etc/live/config.d/username.conf; echo ${LIVE_USERNAME}").chomp
+$sikuli_retry_findfailed = !ENV['SIKULI_RETRY_FINDFAILED'].nil?
+
+# Static
+$configured_keyserver_hostname = 'hkps.pool.sks-keyservers.net'
+$services_expected_on_all_ifaces =
+ [
+ ["cupsd", "0.0.0.0", "631"],
+ ["dhclient", "0.0.0.0", "*"]
+ ]
+$tor_authorities =
+ # List grabbed from Tor's sources, src/or/config.c:~750.
+ [
+ "128.31.0.39", "86.59.21.38", "194.109.206.212",
+ "82.94.251.203", "76.73.17.194", "212.112.245.170",
+ "193.23.244.244", "208.83.223.34", "171.25.193.9",
+ "154.35.32.5"
+ ]
+# OpenDNS
+$some_dns_server = "208.67.222.222"
diff --git a/features/support/env.rb b/features/support/env.rb
new file mode 100644
index 00000000..523a1d1c
--- /dev/null
+++ b/features/support/env.rb
@@ -0,0 +1,53 @@
+require 'rubygems'
+require "#{Dir.pwd}/features/support/extra_hooks.rb"
+require 'time'
+require 'rspec'
+
+def fatal_system(str)
+ unless system(str)
+ raise StandardError.new("Command exited with #{$?}")
+ end
+end
+
+def git_exists?
+ File.exists? '.git'
+end
+
+def create_git
+ Dir.mkdir 'debian'
+ File.open('debian/changelog', 'w') do |changelog|
+ changelog.write(<<END_OF_CHANGELOG)
+tails (0) stable; urgency=low
+
+ * First release.
+
+ -- Tails developers <tails@boum.org> Mon, 30 Jan 2012 01:00:00 +0000
+END_OF_CHANGELOG
+ end
+
+ fatal_system "git init --quiet"
+ fatal_system "git config user.email 'tails@boum.org'"
+ fatal_system "git config user.name 'Tails developers'"
+ fatal_system "git add debian/changelog"
+ fatal_system "git commit --quiet debian/changelog -m 'First release'"
+ fatal_system "git branch -M stable"
+ fatal_system "git branch testing stable"
+ fatal_system "git branch devel stable"
+ fatal_system "git branch experimental devel"
+end
+
+RSpec::Matchers.define :have_suite do |suite|
+ match do |string|
+ # e.g.: `deb http://deb.tails.boum.org/ 0.10 main contrib non-free`
+ %r{^deb +http://deb\.tails\.boum\.org/ +#{Regexp.escape(suite)} main}.match(string)
+ end
+ failure_message_for_should do |string|
+ "expected the sources to include #{suite}\nCurrent sources : #{string}"
+ end
+ failure_message_for_should_not do |string|
+ "expected the sources to exclude #{suite}\nCurrent sources : #{string}"
+ end
+ description do
+ "expected an output with #{suite}"
+ end
+end
diff --git a/features/support/extra_hooks.rb b/features/support/extra_hooks.rb
new file mode 100644
index 00000000..a8addb35
--- /dev/null
+++ b/features/support/extra_hooks.rb
@@ -0,0 +1,45 @@
+require 'cucumber/formatter/pretty'
+
+# Sort of inspired by Cucumber::RbSupport::RbHook, but really we just
+# want an object with a 'tag_expressions' attribute to make
+# accept_hook?() (used below) happy.
+class SimpleHook
+ attr_reader :tag_expressions
+
+ def initialize(tag_expressions, proc)
+ @tag_expressions = tag_expressions
+ @proc = proc
+ end
+
+ def invoke(arg)
+ @proc.call(arg)
+ end
+end
+
+def BeforeFeature(*tag_expressions, &block)
+ $before_feature_hooks ||= []
+ $before_feature_hooks << SimpleHook.new(tag_expressions, block)
+end
+
+def AfterFeature(*tag_expressions, &block)
+ $after_feature_hooks ||= []
+ $after_feature_hooks << SimpleHook.new(tag_expressions, block)
+end
+
+module ExtraHooks
+ class Pretty < Cucumber::Formatter::Pretty
+ def before_feature(feature)
+ for hook in $before_feature_hooks do
+ hook.invoke(feature) if feature.accept_hook?(hook)
+ end
+ super if defined?(super)
+ end
+
+ def after_feature(feature)
+ for hook in $after_feature_hooks do
+ hook.invoke(feature) if feature.accept_hook?(hook)
+ end
+ super if defined?(super)
+ end
+ end
+end
diff --git a/features/support/helpers/display_helper.rb b/features/support/helpers/display_helper.rb
new file mode 100644
index 00000000..354935f0
--- /dev/null
+++ b/features/support/helpers/display_helper.rb
@@ -0,0 +1,51 @@
+
+class Display
+
+ def initialize(domain, x_display)
+ @domain = domain
+ @x_display = x_display
+ end
+
+ def start
+ start_virtviewer(@domain)
+ # We wait for the display to be active to not lose actions
+ # (e.g. key presses via sikuli) that come immediately after
+ # starting (or restoring) a vm
+ try_for(20, { :delay => 0.1, :msg => "virt-viewer failed to start"}) {
+ active?
+ }
+ end
+
+ def stop
+ stop_virtviewer
+ end
+
+ def restart
+ stop_virtviewer
+ start_virtviewer(@domain)
+ end
+
+ def start_virtviewer(domain)
+ # virt-viewer forks, so we cannot (easily) get the child pid
+ # and use it in active? and stop_virtviewer below...
+ IO.popen(["virt-viewer", "-d",
+ "-f",
+ "-r",
+ "-c", "qemu:///system",
+ ["--display=", @x_display].join(''),
+ domain,
+ "&"].join(' '))
+ end
+
+ def active?
+ p = IO.popen("xprop -display #{@x_display} " +
+ "-name '#{@domain} (1) - Virt Viewer' 2>/dev/null")
+ Process.wait(p.pid)
+ p.close
+ $? == 0
+ end
+
+ def stop_virtviewer
+ system("killall virt-viewer")
+ end
+end
diff --git a/features/support/helpers/exec_helper.rb b/features/support/helpers/exec_helper.rb
new file mode 100644
index 00000000..b0d3a9cd
--- /dev/null
+++ b/features/support/helpers/exec_helper.rb
@@ -0,0 +1,61 @@
+require 'json'
+require 'socket'
+
+class VMCommand
+
+ attr_reader :cmd, :returncode, :stdout, :stderr
+
+ def initialize(vm, cmd, options = {})
+ @cmd = cmd
+ @returncode, @stdout, @stderr = VMCommand.execute(vm, cmd, options)
+ end
+
+ def VMCommand.wait_until_remote_shell_is_up(vm, timeout = 30)
+ begin
+ Timeout::timeout(timeout) do
+ VMCommand.execute(vm, "true", { :user => "root", :spawn => false })
+ end
+ rescue Timeout::Error
+ raise "Remote shell seems to be down"
+ end
+ end
+
+ # The parameter `cmd` cannot contain newlines. Separate multiple
+ # commands using ";" instead.
+ # If `:spawn` is false the server will block until it has finished
+ # executing `cmd`. If it's true the server won't block, and the
+ # response will always be [0, "", ""] (only used as an
+ # ACK). execute() will always block until a response is received,
+ # though. Spawning is useful when starting processes in the
+ # background (or running scripts that does the same) like the
+ # vidalia-wrapper, or any application we want to interact with.
+ def VMCommand.execute(vm, cmd, options = {})
+ options[:user] ||= "root"
+ options[:spawn] ||= false
+ type = options[:spawn] ? "spawn" : "call"
+ socket = TCPSocket.new("127.0.0.1", vm.get_remote_shell_port)
+ STDERR.puts "#{type}ing as #{options[:user]}: #{cmd}" if $debug
+ begin
+ socket.puts(JSON.dump([type, options[:user], cmd]))
+ s = socket.readline(sep = "\0").chomp("\0")
+ ensure
+ socket.close
+ end
+ STDERR.puts "#{type} returned: #{s}" if $debug
+ begin
+ return JSON.load(s)
+ rescue JSON::ParserError
+ # The server often returns something unparsable for the very
+ # first execute() command issued after a VM start/restore
+ # (generally from wait_until_remote_shell_is_up()) presumably
+ # because the TCP -> serial link isn't properly setup yet. All
+ # will be well after that initial hickup, so we just retry.
+ return VMCommand.execute(vm, cmd, options)
+ end
+ end
+
+ def success?
+ return @returncode == 0
+ end
+
+end
diff --git a/features/support/helpers/firewall_helper.rb b/features/support/helpers/firewall_helper.rb
new file mode 100644
index 00000000..400965a5
--- /dev/null
+++ b/features/support/helpers/firewall_helper.rb
@@ -0,0 +1,100 @@
+require 'packetfu'
+require 'ipaddr'
+
+# Extent IPAddr with a private/public address space checks
+class IPAddr
+ PrivateIPv4Ranges = [
+ IPAddr.new("10.0.0.0/8"),
+ IPAddr.new("172.16.0.0/12"),
+ IPAddr.new("192.168.0.0/16"),
+ IPAddr.new("255.255.255.255/32")
+ ]
+
+ PrivateIPv6Ranges = [
+ IPAddr.new("fc00::/7"), # private
+ ]
+
+ def private?
+ if self.ipv4?
+ PrivateIPv4Ranges.each do |ipr|
+ return true if ipr.include?(self)
+ end
+ return false
+ else
+ PrivateIPv6Ranges.each do |ipr|
+ return true if ipr.include?(self)
+ end
+ return false
+ end
+ end
+
+ def public?
+ !private?
+ end
+end
+
+class FirewallLeakCheck
+ attr_reader :ipv4_tcp_leaks, :ipv4_nontcp_leaks, :ipv6_leaks, :nonip_leaks
+
+ def initialize(pcap_file, tor_relays)
+ packets = PacketFu::PcapFile.new.file_to_array(:filename => pcap_file)
+ @tor_relays = tor_relays
+ ipv4_tcp_packets = []
+ ipv4_nontcp_packets = []
+ ipv6_packets = []
+ nonip_packets = []
+ packets.each do |p|
+ if PacketFu::TCPPacket.can_parse?(p)
+ ipv4_tcp_packets << PacketFu::TCPPacket.parse(p)
+ elsif PacketFu::IPPacket.can_parse?(p)
+ ipv4_nontcp_packets << PacketFu::IPPacket.parse(p)
+ elsif PacketFu::IPv6Packet.can_parse?(p)
+ ipv6_packets << PacketFu::IPv6Packet.parse(p)
+ elsif PacketFu::Packet.can_parse?(p)
+ nonip_packets << PacketFu::Packet.parse(p)
+ else
+ save_pcap_file
+ raise "Found something in the pcap file that cannot be parsed"
+ end
+ end
+ ipv4_tcp_hosts = get_public_hosts_from_ippackets ipv4_tcp_packets
+ tor_nodes = Set.new(get_all_tor_contacts)
+ @ipv4_tcp_leaks = ipv4_tcp_hosts.select{|host| !tor_nodes.member?(host)}
+ @ipv4_nontcp_leaks = get_public_hosts_from_ippackets ipv4_nontcp_packets
+ @ipv6_leaks = get_public_hosts_from_ippackets ipv6_packets
+ @nonip_leaks = nonip_packets
+ end
+
+ # Returns a list of all unique non-LAN destination IP addresses
+ # found in `packets`.
+ def get_public_hosts_from_ippackets(packets)
+ hosts = []
+ packets.each do |p|
+ candidate = nil
+ if p.kind_of?(PacketFu::IPPacket)
+ candidate = p.ip_daddr
+ elsif p.kind_of?(PacketFu::IPv6Packet)
+ candidate = p.ipv6_header.ipv6_daddr
+ else
+ save_pcap_file
+ raise "Expected an IP{v4,v6} packet, but got something else:\n" +
+ p.peek_format
+ end
+ if candidate != nil and IPAddr.new(candidate).public?
+ hosts << candidate
+ end
+ end
+ hosts.uniq
+ end
+
+ # Returns an array of all Tor relays and authorities, i.e. all
+ # Internet hosts Tails ever should contact.
+ def get_all_tor_contacts
+ @tor_relays + $tor_authorities
+ end
+
+ def empty?
+ @ipv4_tcp_leaks.empty? and @ipv4_nontcp_leaks.empty? and @ipv6_leaks.empty? and @nonip_leaks.empty?
+ end
+
+end
diff --git a/features/support/helpers/misc_helpers.rb b/features/support/helpers/misc_helpers.rb
new file mode 100644
index 00000000..caf64b80
--- /dev/null
+++ b/features/support/helpers/misc_helpers.rb
@@ -0,0 +1,121 @@
+require 'date'
+require 'timeout'
+require 'test/unit'
+
+# Make all the assert_* methods easily accessible in any context.
+include Test::Unit::Assertions
+
+def assert_vmcommand_success(p, msg = nil)
+ assert(p.success?, msg.nil? ? "Command failed: #{p.cmd}\n" + \
+ "error code: #{p.returncode}\n" \
+ "stderr: #{p.stderr}" : \
+ msg)
+end
+
+# Call block (ignoring any exceptions it may throw) repeatedly with one
+# second breaks until it returns true, or until `t` seconds have
+# passed when we throw Timeout::Error. As a precondition, the code
+# block cannot throw Timeout::Error.
+def try_for(t, options = {})
+ options[:delay] ||= 1
+ begin
+ Timeout::timeout(t) do
+ loop do
+ begin
+ return true if yield
+ rescue Timeout::Error => e
+ if options[:msg]
+ raise RuntimeError, options[:msg], caller
+ else
+ raise e
+ end
+ rescue Exception
+ # noop
+ end
+ sleep options[:delay]
+ end
+ end
+ rescue Timeout::Error => e
+ if options[:msg]
+ raise RuntimeError, options[:msg], caller
+ else
+ raise e
+ end
+ end
+end
+
+def wait_until_tor_is_working
+ try_for(240) { @vm.execute(
+ '. /usr/local/lib/tails-shell-library/tor.sh; tor_is_working').success? }
+end
+
+def convert_bytes_mod(unit)
+ case unit
+ when "bytes", "b" then mod = 1
+ when "KB" then mod = 10**3
+ when "k", "KiB" then mod = 2**10
+ when "MB" then mod = 10**6
+ when "M", "MiB" then mod = 2**20
+ when "GB" then mod = 10**9
+ when "G", "GiB" then mod = 2**30
+ when "TB" then mod = 10**12
+ when "T", "TiB" then mod = 2**40
+ else
+ raise "invalid memory unit '#{unit}'"
+ end
+ return mod
+end
+
+def convert_to_bytes(size, unit)
+ return (size*convert_bytes_mod(unit)).to_i
+end
+
+def convert_to_MiB(size, unit)
+ return (size*convert_bytes_mod(unit) / (2**20)).to_i
+end
+
+def convert_from_bytes(size, unit)
+ return size.to_f/convert_bytes_mod(unit).to_f
+end
+
+def cmd_helper(cmd)
+ IO.popen(cmd + " 2>&1") do |p|
+ out = p.readlines.join("\n")
+ p.close
+ ret = $?
+ assert_equal(0, ret, "Command failed (returned #{ret}): #{cmd}:\n#{out}")
+ return out
+ end
+end
+
+def tails_iso_creation_date(path)
+ label = cmd_helper("/sbin/blkid -p -s LABEL -o value #{path}")
+ assert(label[/^TAILS \d+(\.\d+)+(~rc\d+)? - \d+$/],
+ "Got invalid label '#{label}' from Tails image '#{path}'")
+ return label[/\d+$/]
+end
+
+def sort_isos_by_creation_date
+ Dir.glob("#{Dir.pwd}/*.iso").sort_by {|f| tails_iso_creation_date(f)}
+end
+
+def get_newest_iso
+ return sort_isos_by_creation_date.last
+end
+
+def get_oldest_iso
+ return sort_isos_by_creation_date.first
+end
+
+# This command will grab all router IP addresses from the Tor
+# consensus in the VM.
+def get_tor_relays
+ cmd = 'awk "/^r/ { print \$6 }" /var/lib/tor/cached-microdesc-consensus'
+ @vm.execute(cmd).stdout.chomp.split("\n")
+end
+
+def save_pcap_file
+ pcap_copy = "#{$tmp_dir}/pcap_with_leaks-#{DateTime.now}"
+ FileUtils.cp(@sniffer.pcap_file, pcap_copy)
+ puts "Full network capture available at: #{pcap_copy}"
+end
diff --git a/features/support/helpers/net_helper.rb b/features/support/helpers/net_helper.rb
new file mode 100644
index 00000000..29119195
--- /dev/null
+++ b/features/support/helpers/net_helper.rb
@@ -0,0 +1,42 @@
+#
+# Sniffer is a very dumb wrapper to start and stop tcpdumps instances, possibly
+# with customized filters. Captured traffic is stored in files whose name
+# depends on the sniffer name. The resulting captured packets for each sniffers
+# can be accessed as an array through its `packets` method.
+#
+# Use of more rubyish internal ways to sniff a network like with pcap-able gems
+# is waaay to much resource consumming, notmuch reliable and soooo slow. Let's
+# not bother too much with that. :)
+#
+# Should put all that in a Module.
+
+class Sniffer
+
+ attr_reader :name, :pcap_file, :pid
+
+ def initialize(name, bridge_name)
+ @name = name
+ @bridge_name = bridge_name
+ @bridge_mac = File.open("/sys/class/net/#{@bridge_name}/address", "rb").read.chomp
+ @pcap_file = "#{$tmp_dir}/#{name}.pcap"
+ end
+
+ def capture(filter="not ether src host #{@bridge_mac} and not ether proto \\arp and not ether proto \\rarp")
+ job = IO.popen("/usr/sbin/tcpdump -n -i #{@bridge_name} -w #{@pcap_file} -U '#{filter}' >/dev/null 2>&1")
+ @pid = job.pid
+ end
+
+ def stop
+ begin
+ Process.kill("TERM", @pid)
+ rescue
+ # noop
+ end
+ end
+
+ def clear
+ if File.exist?(@pcap_file)
+ File.delete(@pcap_file)
+ end
+ end
+end
diff --git a/features/support/helpers/sikuli_helper.rb b/features/support/helpers/sikuli_helper.rb
new file mode 100644
index 00000000..f6211be7
--- /dev/null
+++ b/features/support/helpers/sikuli_helper.rb
@@ -0,0 +1,145 @@
+require 'rjb'
+require 'rjbextension'
+$LOAD_PATH << ENV['SIKULI_HOME']
+require 'sikuli-script.jar'
+Rjb::load
+
+package_members = [
+ "org.sikuli.script.Finder",
+ "org.sikuli.script.Key",
+ "org.sikuli.script.KeyModifier",
+ "org.sikuli.script.Location",
+ "org.sikuli.script.Match",
+ "org.sikuli.script.Pattern",
+ "org.sikuli.script.Region",
+ "org.sikuli.script.Screen",
+ "org.sikuli.script.Settings",
+ ]
+
+translations = Hash[
+ "org.sikuli.script", "Sikuli",
+ ]
+
+for p in package_members
+ imported_class = Rjb::import(p)
+ package, ignore, class_name = p.rpartition(".")
+ next if ! translations.include? package
+ mod_name = translations[package]
+ mod = mod_name.split("::").inject(Object) do |parent_obj, child_name|
+ if parent_obj.const_defined?(child_name, false)
+ parent_obj.const_get(child_name, false)
+ else
+ child_obj = Module.new
+ parent_obj.const_set(child_name, child_obj)
+ end
+ end
+ mod.const_set(class_name, imported_class)
+end
+
+def findfailed_hook(pic)
+ STDERR.puts ""
+ STDERR.puts "FindFailed for: #{pic}"
+ STDERR.puts ""
+ STDERR.puts "Update the image and press RETURN to retry"
+ STDIN.gets
+end
+
+# Since rjb imports Java classes without creating a corresponding
+# Ruby class (it's just an instance of Rjb_JavaProxy) we can't
+# monkey patch any class, so additional methods must be added
+# to each Screen object.
+#
+# All Java classes' methods are immediately available in the proxied
+# Ruby classes, but care has to be given to match their type. For a
+# list of methods, see: <http://doc.sikuli.org/javadoc/index.html>.
+# The type "PRSML" is a union of Pattern, Region, Screen, Match and
+# Location.
+#
+# Also, due to limitations in Ruby's syntax we can't do:
+# def Sikuli::Screen.new
+# so we work around it with the following vairable.
+sikuli_script_proxy = Sikuli::Screen
+$_original_sikuli_screen_new ||= Sikuli::Screen.method :new
+
+def sikuli_script_proxy.new(*args)
+ s = $_original_sikuli_screen_new.call(*args)
+
+ if $sikuli_retry_findfailed
+ # The usage of `_invoke()` below exemplifies how one can wrap
+ # around Java objects' methods when they're imported using RJB. It
+ # isn't pretty. The seconds argument is the parameter signature,
+ # which can be obtained by creating the intended Java object using
+ # RJB, and then calling its `java_methods` method.
+
+ def s.wait(pic, time)
+ self._invoke('wait', 'Ljava.lang.Object;D', pic, time)
+ rescue FindFailed => e
+ findfailed_hook(pic)
+ self._invoke('wait', 'Ljava.lang.Object;D', pic, time)
+ end
+
+ def s.find(pic)
+ self._invoke('find', 'Ljava.lang.Object;', pic)
+ rescue FindFailed => e
+ findfailed_hook(pic)
+ self._invoke('find', 'Ljava.lang.Object;', pic)
+ end
+
+ def s.waitVanish(pic, time)
+ self._invoke('waitVanish', 'Ljava.lang.Object;D', pic, time)
+ rescue FindFailed => e
+ findfailed_hook(pic)
+ self._invoke('waitVanish', 'Ljava.lang.Object;D', pic, time)
+ end
+
+ def s.click(pic)
+ self._invoke('click', 'Ljava.lang.Object;', pic)
+ rescue FindFailed => e
+ findfailed_hook(pic)
+ self._invoke('click', 'Ljava.lang.Object;', pic)
+ end
+ end
+
+ def s.click_point(x, y)
+ self.click(Sikuli::Location.new(x, y))
+ end
+
+ def s.wait_and_click(pic, time)
+ self.click(self.wait(pic, time))
+ end
+
+ def s.wait_and_double_click(pic, time)
+ self.doubleClick(self.wait(pic, time))
+ end
+
+ def s.hover_point(x, y)
+ self.hover(Sikuli::Location.new(x, y))
+ end
+
+ def s.hide_cursor
+ self.hover_point(self.w, self.h/2)
+ end
+
+ s
+end
+
+# Configure sikuli
+java.lang.System.setProperty("SIKULI_IMAGE_PATH", "#{Dir.pwd}/features/images/")
+
+# ruby and rjb doesn't play well together when it comes to static
+# fields (and possibly methods) so we instantiate and access the field
+# via objects instead. It actually works inside this file, but when
+# it's required from "outside", and the file has been completely
+# required, ruby's require method complains that the method for the
+# field accessor is missing.
+sikuli_settings = Sikuli::Settings.new
+sikuli_settings.OcrDataPath = $tmp_dir
+# sikuli_ruby, which we used before, defaulted to 0.9 minimum
+# similarity, so all our current images are adapted to that value.
+# Also, Sikuli's default of 0.7 is simply too low (many false
+# positives).
+sikuli_settings.MinSimilarity = 0.9
+sikuli_settings.ActionLogs = $debug
+sikuli_settings.DebugLogs = $debug
+sikuli_settings.InfoLogs = $debug
+sikuli_settings.ProfileLogs = $debug
diff --git a/features/support/helpers/storage_helper.rb b/features/support/helpers/storage_helper.rb
new file mode 100644
index 00000000..80a1e1e0
--- /dev/null
+++ b/features/support/helpers/storage_helper.rb
@@ -0,0 +1,143 @@
+# Helper class for manipulating VM storage *volumes*, i.e. it deals
+# only with creation of images and keeps a name => volume path lookup
+# table (plugging drives or getting info of plugged devices is done in
+# the VM class). We'd like better coupling, but given the ridiculous
+# disconnect between Libvirt::StoragePool and Libvirt::Domain (hint:
+# they have nothing with each other to do whatsoever) it's what makes
+# sense.
+
+require 'libvirt'
+require 'rexml/document'
+require 'etc'
+
+class VMStorage
+
+ @@virt = nil
+
+ def initialize(virt, xml_path)
+ @@virt ||= virt
+ @xml_path = xml_path
+ pool_xml = REXML::Document.new(File.read("#{@xml_path}/storage_pool.xml"))
+ pool_name = pool_xml.elements['pool/name'].text
+ begin
+ @pool = @@virt.lookup_storage_pool_by_name(pool_name)
+ rescue Libvirt::RetrieveError
+ # There's no pool with that name, so we don't have to clear it
+ else
+ VMStorage.clear_storage_pool(@pool)
+ end
+ @pool_path = "#{$tmp_dir}/#{pool_name}"
+ pool_xml.elements['pool/target/path'].text = @pool_path
+ @pool = @@virt.define_storage_pool_xml(pool_xml.to_s)
+ @pool.build
+ @pool.create
+ @pool.refresh
+ end
+
+ def VMStorage.clear_storage_pool_volumes(pool)
+ was_not_active = !pool.active?
+ if was_not_active
+ pool.create
+ end
+ pool.list_volumes.each do |vol_name|
+ vol = pool.lookup_volume_by_name(vol_name)
+ vol.delete
+ end
+ if was_not_active
+ pool.destroy
+ end
+ rescue
+ # Some of the above operations can fail if the pool's path was
+ # deleted by external means; let's ignore that.
+ end
+
+ def VMStorage.clear_storage_pool(pool)
+ VMStorage.clear_storage_pool_volumes(pool)
+ pool.destroy if pool.active?
+ pool.undefine
+ end
+
+ def clear_pool
+ VMStorage.clear_storage_pool(@pool)
+ end
+
+ def clear_volumes
+ VMStorage.clear_storage_pool_volumes(@pool)
+ end
+
+ def create_new_disk(name, options = {})
+ options[:size] ||= 2
+ options[:unit] ||= "GiB"
+ options[:type] ||= "qcow2"
+ begin
+ old_vol = @pool.lookup_volume_by_name(name)
+ rescue Libvirt::RetrieveError
+ # noop
+ else
+ old_vol.delete
+ end
+ uid = Etc::getpwnam("libvirt-qemu").uid
+ gid = Etc::getgrnam("libvirt-qemu").gid
+ vol_xml = REXML::Document.new(File.read("#{@xml_path}/volume.xml"))
+ vol_xml.elements['volume/name'].text = name
+ size_b = convert_to_bytes(options[:size].to_f, options[:unit])
+ vol_xml.elements['volume/capacity'].text = size_b.to_s
+ vol_xml.elements['volume/target/format'].attributes["type"] = options[:type]
+ vol_xml.elements['volume/target/path'].text = "#{@pool_path}/#{name}"
+ vol_xml.elements['volume/target/permissions/owner'].text = uid.to_s
+ vol_xml.elements['volume/target/permissions/group'].text = gid.to_s
+ vol = @pool.create_volume_xml(vol_xml.to_s)
+ @pool.refresh
+ end
+
+ def clone_to_new_disk(from, to)
+ begin
+ old_to_vol = @pool.lookup_volume_by_name(to)
+ rescue Libvirt::RetrieveError
+ # noop
+ else
+ old_to_vol.delete
+ end
+ from_vol = @pool.lookup_volume_by_name(from)
+ xml = REXML::Document.new(from_vol.xml_desc)
+ pool_path = REXML::Document.new(@pool.xml_desc).elements['pool/target/path'].text
+ xml.elements['volume/name'].text = to
+ xml.elements['volume/target/path'].text = "#{pool_path}/#{to}"
+ @pool.create_volume_xml_from(xml.to_s, from_vol)
+ end
+
+ def disk_format(name)
+ vol = @pool.lookup_volume_by_name(name)
+ vol_xml = REXML::Document.new(vol.xml_desc)
+ return vol_xml.elements['volume/target/format'].attributes["type"]
+ end
+
+ def disk_path(name)
+ @pool.lookup_volume_by_name(name).path
+ end
+
+ # We use parted for the disk_mk* functions since it can format
+ # partitions "inside" the super block device; mkfs.* need a
+ # partition device (think /dev/sdaX), so we'd have to use something
+ # like losetup or kpartx, which would require administrative
+ # privileges. These functions only work for raw disk images.
+
+ # TODO: We should switch to guestfish/libguestfs (which has
+ # ruby-bindings) so we could use qcow2 instead of raw, and more
+ # easily use LVM volumes.
+
+ # For type, see label-type for mklabel in parted(8)
+ def disk_mklabel(name, type)
+ assert_equal("raw", disk_format(name))
+ path = disk_path(name)
+ cmd_helper("/sbin/parted -s '#{path}' mklabel #{type}")
+ end
+
+ # For fstype, see fs-type for mkfs in parted(8)
+ def disk_mkpartfs(name, fstype)
+ assert(disk_format(name), "raw")
+ path = disk_path(name)
+ cmd_helper("/sbin/parted -s '#{path}' mkpartfs primary '#{fstype}' 0% 100%")
+ end
+
+end
diff --git a/features/support/helpers/vm_helper.rb b/features/support/helpers/vm_helper.rb
new file mode 100644
index 00000000..2b5ad291
--- /dev/null
+++ b/features/support/helpers/vm_helper.rb
@@ -0,0 +1,426 @@
+require 'libvirt'
+require 'rexml/document'
+
+class VM
+
+ # These class attributes will be lazily initialized during the first
+ # instantiation:
+ # This is the libvirt connection, of which we only want one and
+ # which can persist for different VM instances (even in parallel)
+ @@virt = nil
+ # This is a storage helper that deals with volume manipulation. The
+ # storage it deals with persists across VMs, by necessity.
+ @@storage = nil
+
+ def VM.storage
+ return @@storage
+ end
+
+ def storage
+ return @@storage
+ end
+
+ attr_reader :domain, :display, :ip, :net
+
+ def initialize(xml_path, x_display)
+ @@virt ||= Libvirt::open("qemu:///system")
+ @xml_path = xml_path
+ default_domain_xml = File.read("#{@xml_path}/default.xml")
+ update_domain(default_domain_xml)
+ default_net_xml = File.read("#{@xml_path}/default_net.xml")
+ update_net(default_net_xml)
+ @display = Display.new(@domain_name, x_display)
+ set_cdrom_boot($tails_iso)
+ plug_network
+ # unlike the domain and net the storage pool should survive VM
+ # teardown (so a new instance can use e.g. a previously created
+ # USB drive), so we only create a new one if there is none.
+ @@storage ||= VMStorage.new(@@virt, xml_path)
+ rescue Exception => e
+ clean_up_net
+ clean_up_domain
+ raise e
+ end
+
+ def update_domain(xml)
+ domain_xml = REXML::Document.new(xml)
+ @domain_name = domain_xml.elements['domain/name'].text
+ clean_up_domain
+ @domain = @@virt.define_domain_xml(xml)
+ end
+
+ def update_net(xml)
+ net_xml = REXML::Document.new(xml)
+ @net_name = net_xml.elements['network/name'].text
+ @ip = net_xml.elements['network/ip/dhcp/host/'].attributes['ip']
+ clean_up_net
+ @net = @@virt.define_network_xml(xml)
+ @net.create
+ end
+
+ def clean_up_domain
+ begin
+ domain = @@virt.lookup_domain_by_name(@domain_name)
+ domain.destroy if domain.active?
+ domain.undefine
+ rescue
+ end
+ end
+
+ def clean_up_net
+ begin
+ net = @@virt.lookup_network_by_name(@net_name)
+ net.destroy if net.active?
+ net.undefine
+ rescue
+ end
+ end
+
+ def set_network_link_state(state)
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements['domain/devices/interface/link'].attributes['state'] = state
+ if is_running?
+ @domain.update_device(domain_xml.elements['domain/devices/interface'].to_s)
+ else
+ update_domain(domain_xml.to_s)
+ end
+ end
+
+ def plug_network
+ set_network_link_state('up')
+ end
+
+ def unplug_network
+ set_network_link_state('down')
+ end
+
+ def set_cdrom_tray_state(state)
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements.each('domain/devices/disk') do |e|
+ if e.attribute('device').to_s == "cdrom"
+ e.elements['target'].attributes['tray'] = state
+ if is_running?
+ @domain.update_device(e.to_s)
+ else
+ update_domain(domain_xml.to_s)
+ end
+ end
+ end
+ end
+
+ def eject_cdrom
+ set_cdrom_tray_state('open')
+ end
+
+ def close_cdrom
+ set_cdrom_tray_state('closed')
+ end
+
+ def set_boot_device(dev)
+ if is_running?
+ raise "boot settings can only be set for inactive vms"
+ end
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements['domain/os/boot'].attributes['dev'] = dev
+ update_domain(domain_xml.to_s)
+ end
+
+ def set_cdrom_image(image)
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements.each('domain/devices/disk') do |e|
+ if e.attribute('device').to_s == "cdrom"
+ if ! e.elements['source']
+ e.add_element('source')
+ end
+ e.elements['source'].attributes['file'] = image
+ if is_running?
+ @domain.update_device(e.to_s, Libvirt::Domain::DEVICE_MODIFY_FORCE)
+ else
+ update_domain(domain_xml.to_s)
+ end
+ end
+ end
+ end
+
+ def remove_cdrom
+ set_cdrom_image('')
+ end
+
+ def set_cdrom_boot(image)
+ if is_running?
+ raise "boot settings can only be set for inactice vms"
+ end
+ set_boot_device('cdrom')
+ set_cdrom_image(image)
+ close_cdrom
+ end
+
+ def plug_drive(name, type)
+ # Get the next free /dev/sdX on guest
+ used_devs = []
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements.each('domain/devices/disk/target') do |e|
+ used_devs <<= e.attribute('dev').to_s
+ end
+ letter = 'a'
+ dev = "sd" + letter
+ while used_devs.include? dev
+ letter = (letter[0].ord + 1).chr
+ dev = "sd" + letter
+ end
+ assert letter <= 'z'
+
+ xml = REXML::Document.new(File.read("#{@xml_path}/disk.xml"))
+ xml.elements['disk/source'].attributes['file'] = @@storage.disk_path(name)
+ xml.elements['disk/driver'].attributes['type'] = @@storage.disk_format(name)
+ xml.elements['disk/target'].attributes['dev'] = dev
+ xml.elements['disk/target'].attributes['bus'] = type
+ if type == "usb"
+ xml.elements['disk/target'].attributes['removable'] = 'on'
+ end
+
+ if is_running?
+ @domain.attach_device(xml.to_s)
+ else
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements['domain/devices'].add_element(xml)
+ update_domain(domain_xml.to_s)
+ end
+ end
+
+ def disk_xml_desc(name)
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements.each('domain/devices/disk') do |e|
+ begin
+ if e.elements['source'].attribute('file').to_s == @@storage.disk_path(name)
+ return e.to_s
+ end
+ rescue
+ next
+ end
+ end
+ return nil
+ end
+
+ def unplug_drive(name)
+ xml = disk_xml_desc(name)
+ @domain.detach_device(xml)
+ end
+
+ def disk_dev(name)
+ xml = REXML::Document.new(disk_xml_desc(name))
+ return "/dev/" + xml.elements['disk/target'].attribute('dev').to_s
+ end
+
+ def disk_detected?(name)
+ return execute("test -b #{disk_dev(name)}").success?
+ end
+
+ def set_disk_boot(name, type)
+ if is_running?
+ raise "boot settings can only be set for inactive vms"
+ end
+ plug_drive(name, type)
+ set_boot_device('hd')
+ # For some reason setting the boot device doesn't prevent cdrom
+ # boot unless it's empty
+ remove_cdrom
+ end
+
+ # XXX-9p: Shares don't work together with snapshot save+restore. See
+ # XXX-9p in common_steps.rb for more information.
+ def add_share(source, tag)
+ if is_running?
+ raise "shares can only be added to inactice vms"
+ end
+ xml = REXML::Document.new(File.read("#{@xml_path}/fs_share.xml"))
+ xml.elements['filesystem/source'].attributes['dir'] = source
+ xml.elements['filesystem/target'].attributes['dir'] = tag
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements['domain/devices'].add_element(xml)
+ update_domain(domain_xml.to_s)
+ end
+
+ def list_shares
+ list = []
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements.each('domain/devices/filesystem') do |e|
+ list << e.elements['target'].attribute('dir').to_s
+ end
+ return list
+ end
+
+ def set_ram_size(size, unit = "KiB")
+ raise "System memory can only be added to inactice vms" if is_running?
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements['domain/memory'].text = size
+ domain_xml.elements['domain/memory'].attributes['unit'] = unit
+ domain_xml.elements['domain/currentMemory'].text = size
+ domain_xml.elements['domain/currentMemory'].attributes['unit'] = unit
+ update_domain(domain_xml.to_s)
+ end
+
+ def get_ram_size_in_bytes
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ unit = domain_xml.elements['domain/memory'].attribute('unit').to_s
+ size = domain_xml.elements['domain/memory'].text.to_i
+ return convert_to_bytes(size, unit)
+ end
+
+ def set_arch(arch)
+ raise "System architecture can only be set to inactice vms" if is_running?
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements['domain/os/type'].attributes['arch'] = arch
+ update_domain(domain_xml.to_s)
+ end
+
+ def add_hypervisor_feature(feature)
+ raise "Hypervisor features can only be added to inactice vms" if is_running?
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements['domain/features'].add_element(feature)
+ update_domain(domain_xml.to_s)
+ end
+
+ def drop_hypervisor_feature(feature)
+ raise "Hypervisor features can only be fropped from inactice vms" if is_running?
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements['domain/features'].delete_element(feature)
+ update_domain(domain_xml.to_s)
+ end
+
+ def disable_pae_workaround
+ # add_hypervisor_feature("nonpae") results in a libvirt error, and
+ # drop_hypervisor_feature("pae") alone won't disable pae. Hence we
+ # use this workaround.
+ xml = <<EOF
+ <qemu:commandline xmlns:qemu='http://libvirt.org/schemas/domain/qemu/1.0'>
+ <qemu:arg value='-cpu'/>
+ <qemu:arg value='pentium,-pae'/>
+ </qemu:commandline>
+EOF
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements['domain'].add_element(REXML::Document.new(xml))
+ update_domain(domain_xml.to_s)
+ end
+
+ def set_os_loader(type)
+ if is_running?
+ raise "boot settings can only be set for inactice vms"
+ end
+ if type == 'UEFI'
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements['domain/os'].add_element(REXML::Document.new(
+ '<loader>/usr/share/ovmf/OVMF.fd</loader>'
+ ))
+ update_domain(domain_xml.to_s)
+ else
+ raise "unsupported OS loader type"
+ end
+ end
+
+ def is_running?
+ begin
+ return @domain.active?
+ rescue
+ return false
+ end
+ end
+
+ def execute(cmd, user = "root")
+ return VMCommand.new(self, cmd, { :user => user, :spawn => false })
+ end
+
+ def execute_successfully(cmd, user = "root")
+ p = execute(cmd, user)
+ assert_vmcommand_success(p)
+ return p
+ end
+
+ def spawn(cmd, user = "root")
+ return VMCommand.new(self, cmd, { :user => user, :spawn => true })
+ end
+
+ def wait_until_remote_shell_is_up(timeout = 30)
+ VMCommand.wait_until_remote_shell_is_up(self, timeout)
+ end
+
+ def host_to_guest_time_sync
+ host_time= DateTime.now.strftime("%s").to_s
+ execute("date -s '@#{host_time}'").success?
+ end
+
+ def has_network?
+ return execute("/sbin/ifconfig eth0 | grep -q 'inet addr'").success?
+ end
+
+ def has_process?(process)
+ return execute("pidof -x -o '%PPID' " + process).success?
+ end
+
+ def pidof(process)
+ return execute("pidof -x -o '%PPID' " + process).stdout.chomp.split
+ end
+
+ def file_exist?(file)
+ execute("test -e #{file}").success?
+ end
+
+ def file_content(file, user = 'root')
+ # We don't quote #{file} on purpose: we sometimes pass environment variables
+ # or globs that we want to be interpreted by the shell.
+ cmd = execute("cat #{file}", user)
+ assert(cmd.success?,
+ "Could not cat '#{file}':\n#{cmd.stdout}\n#{cmd.stderr}")
+ return cmd.stdout
+ end
+
+ def save_snapshot(path)
+ @domain.save(path)
+ @display.stop
+ end
+
+ def restore_snapshot(path)
+ # Clean up current domain so its snapshot can be restored
+ clean_up_domain
+ Libvirt::Domain::restore(@@virt, path)
+ @domain = @@virt.lookup_domain_by_name(@domain_name)
+ @display.start
+ end
+
+ def start
+ return if is_running?
+ @domain.create
+ @display.start
+ end
+
+ def reset
+ # ruby-libvirt 0.4 does not support the reset method.
+ # XXX: Once we use Jessie, use @domain.reset instead.
+ system("virsh -c qemu:///system reset " + @domain_name) if is_running?
+ end
+
+ def power_off
+ @domain.destroy if is_running?
+ @display.stop
+ end
+
+ def destroy
+ clean_up_domain
+ clean_up_net
+ power_off
+ end
+
+ def take_screenshot(description)
+ @display.take_screenshot(description)
+ end
+
+ def get_remote_shell_port
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements.each('domain/devices/serial') do |e|
+ if e.attribute('type').to_s == "tcp"
+ return e.elements['source'].attribute('service').to_s.to_i
+ end
+ end
+ end
+
+end
diff --git a/features/support/hooks.rb b/features/support/hooks.rb
new file mode 100644
index 00000000..2f2f98c1
--- /dev/null
+++ b/features/support/hooks.rb
@@ -0,0 +1,156 @@
+require 'fileutils'
+require 'time'
+require 'tmpdir'
+
+# For @product tests
+####################
+
+def delete_snapshot(snapshot)
+ if snapshot and File.exist?(snapshot)
+ File.delete(snapshot)
+ end
+rescue Errno::EACCES => e
+ STDERR.puts "Couldn't delete background snapshot: #{e.to_s}"
+end
+
+def delete_all_snapshots
+ Dir.glob("#{$tmp_dir}/*.state").each do |snapshot|
+ delete_snapshot(snapshot)
+ end
+end
+
+BeforeFeature('@product') do |feature|
+ if File.exist?($tmp_dir)
+ if !File.directory?($tmp_dir)
+ raise "Temporary directory '#{$tmp_dir}' exists but is not a " +
+ "directory"
+ end
+ if !File.owned?($tmp_dir)
+ raise "Temporary directory '#{$tmp_dir}' must be owned by the " +
+ "current user"
+ end
+ FileUtils.chmod(0755, $tmp_dir)
+ else
+ begin
+ Dir.mkdir($tmp_dir)
+ rescue Errno::EACCES => e
+ raise "Cannot create temporary directory: #{e.to_s}"
+ end
+ end
+ delete_all_snapshots if !$keep_snapshots
+ if $tails_iso.nil?
+ raise "No Tails ISO image specified, and none could be found in the " +
+ "current directory"
+ end
+ if File.exist?($tails_iso)
+ # Workaround: when libvirt takes ownership of the ISO image it may
+ # become unreadable for the live user inside the guest in the
+ # host-to-guest share used for some tests.
+
+ if !File.world_readable?($tails_iso)
+ if File.owned?($tails_iso)
+ File.chmod(0644, $tails_iso)
+ else
+ raise "warning: the Tails ISO image must be world readable or be " +
+ "owned by the current user to be available inside the guest " +
+ "VM via host-to-guest shares, which is required by some tests"
+ end
+ end
+ else
+ raise "The specified Tails ISO image '#{$tails_iso}' does not exist"
+ end
+ puts "Testing ISO image: #{File.basename($tails_iso)}"
+ base = File.basename(feature.file, ".feature").to_s
+ $background_snapshot = "#{$tmp_dir}/#{base}_background.state"
+end
+
+AfterFeature('@product') do
+ delete_snapshot($background_snapshot) if !$keep_snapshots
+ VM.storage.clear_volumes if VM.storage
+end
+
+BeforeFeature('@product', '@old_iso') do
+ if $old_tails_iso.nil?
+ raise "No old Tails ISO image specified, and none could be found in the " +
+ "current directory"
+ end
+ if !File.exist?($old_tails_iso)
+ raise "The specified old Tails ISO image '#{$old_tails_iso}' does not exist"
+ end
+ if $tails_iso == $old_tails_iso
+ raise "The old Tails ISO is the same as the Tails ISO we're testing"
+ end
+ puts "Using old ISO image: #{File.basename($old_tails_iso)}"
+end
+
+# BeforeScenario
+Before('@product') do
+ @screen = Sikuli::Screen.new
+ if File.size?($background_snapshot)
+ @skip_steps_while_restoring_background = true
+ else
+ @skip_steps_while_restoring_background = false
+ end
+ @theme = "gnome"
+ @os_loader = "MBR"
+end
+
+# AfterScenario
+After('@product') do |scenario|
+ if (scenario.status != :passed)
+ time_of_fail = Time.now - $time_at_start
+ secs = "%02d" % (time_of_fail % 60)
+ mins = "%02d" % ((time_of_fail / 60) % 60)
+ hrs = "%02d" % (time_of_fail / (60*60))
+ STDERR.puts "Scenario failed at time #{hrs}:#{mins}:#{secs}"
+ base = File.basename(scenario.feature.file, ".feature").to_s
+ tmp = @screen.capture.getFilename
+ out = "#{$tmp_dir}/#{base}-#{DateTime.now}.png"
+ FileUtils.mv(tmp, out)
+ STDERR.puts("Took screenshot \"#{out}\"")
+ if $pause_on_fail
+ STDERR.puts ""
+ STDERR.puts "Press ENTER to continue running the test suite"
+ STDIN.gets
+ end
+ end
+ if @sniffer
+ @sniffer.stop
+ @sniffer.clear
+ end
+ @vm.destroy if @vm
+end
+
+After('@product', '~@keep_volumes') do
+ VM.storage.clear_volumes
+end
+
+# For @source tests
+###################
+
+# BeforeScenario
+Before('@source') do
+ @orig_pwd = Dir.pwd
+ @git_clone = Dir.mktmpdir 'tails-apt-tests'
+ Dir.chdir @git_clone
+end
+
+# AfterScenario
+After('@source') do
+ Dir.chdir @orig_pwd
+ FileUtils.remove_entry_secure @git_clone
+end
+
+
+# Common
+########
+
+BeforeFeature('@product', '@source') do |feature|
+ raise "Feature #{feature.file} is tagged both @product and @source, " +
+ "which is an impossible combination"
+end
+
+at_exit do
+ delete_all_snapshots if !$keep_snapshots
+ VM.storage.clear_pool if VM.storage
+end