[Qa-jenkins-scm] [jenkins.debian.net] 05/10: rough attempt to grab the good cucumber bits from recent tails

Holger Levsen holger at layer-acht.org
Thu Apr 28 19:52:42 UTC 2016


This is an automated email from the git hooks/post-receive script.

holger pushed a commit to branch master
in repository jenkins.debian.net.

commit da080c472fc415b0ce918f4dd4a1ab143bb1bca4
Author: Philip Hands <phil at hands.com>
Date:   Mon Mar 14 15:36:16 2016 +0100

    rough attempt to grab the good cucumber bits from recent tails
---
 bin/lvc/run_test_suite                             | 174 ++--
 features/apt.feature                               |  15 +-
 features/build.feature                             | 209 ++++-
 features/checks.feature                            |  92 +-
 features/config/defaults.yml                       |  36 +
 features/dhcp.feature                              |  24 +-
 features/domains/default.xml                       |  31 +-
 features/domains/volume.xml                        |   2 +-
 features/electrum.feature                          |  34 +
 features/encryption.feature                        |  10 +-
 features/evince.feature                            |  67 +-
 features/firewall_leaks.feature                    |  37 -
 features/icedove.feature                           |  39 +
 features/localization.feature                      |  19 +
 features/mac_spoofing.feature                      |  71 ++
 features/mat.feature                               |  13 +
 features/persistence.feature                       |  55 ++
 features/pidgin.feature                            |  98 ++-
 features/po.feature                                |   9 +
 features/root_access_control.feature               |  13 +-
 features/scripts/otr-bot.py                        | 206 +++++
 features/scripts/vm-execute                        |  52 ++
 features/ssh.feature                               |  31 +
 features/step_definitions/apt.rb                   |  68 +-
 features/step_definitions/browser.rb               | 195 +++++
 features/step_definitions/build.rb                 |  62 +-
 features/step_definitions/checks.rb                | 253 ++++--
 features/step_definitions/common_steps.rb          | 953 +++++++++++++--------
 features/step_definitions/dhcp.rb                  |   5 +-
 features/step_definitions/electrum.rb              |  52 ++
 features/step_definitions/encryption.rb            | 120 ++-
 features/step_definitions/evince.rb                |  19 +-
 features/step_definitions/firewall_leaks.rb        |  28 +-
 features/step_definitions/git.rb                   |   6 +
 features/step_definitions/icedove.rb               |  94 ++
 features/step_definitions/mac_spoofing.rb          | 108 +++
 features/step_definitions/pidgin.rb                | 369 +++++++-
 features/step_definitions/po.rb                    |   8 +
 features/step_definitions/root_access_control.rb   |  21 +-
 features/step_definitions/snapshots.rb             | 211 +++++
 features/step_definitions/ssh.rb                   | 122 +++
 features/step_definitions/time_syncing.rb          |  80 +-
 features/step_definitions/tor.rb                   | 402 +++++++++
 features/step_definitions/torified_browsing.rb     |  15 +-
 features/step_definitions/torified_gnupg.rb        | 228 ++++-
 features/step_definitions/torified_misc.rb         |  41 +
 features/step_definitions/totem.rb                 |  49 +-
 features/step_definitions/unsafe_browser.rb        | 251 +++---
 features/step_definitions/untrusted_partitions.rb  |  72 +-
 features/step_definitions/usb.rb                   | 552 +++++++-----
 features/support/config.rb                         |  96 ++-
 features/support/env.rb                            |  39 +-
 features/support/extra_hooks.rb                    | 144 +++-
 features/support/helpers/chatbot_helper.rb         |  59 ++
 features/support/helpers/ctcp_helper.rb            | 126 +++
 features/support/helpers/display_helper.rb         |  51 +-
 features/support/helpers/exec_helper.rb            |  30 +-
 features/support/helpers/firewall_helper.rb        |  87 +-
 features/support/helpers/misc_helpers.rb           | 228 +++--
 features/support/helpers/sikuli_helper.rb          |  91 +-
 .../helpers/{net_helper.rb => sniffing_helper.rb}  |  13 +-
 features/support/helpers/sshd_helper.rb            |  67 ++
 features/support/helpers/storage_helper.rb         | 135 ++-
 features/support/helpers/vm_helper.rb              | 532 +++++++++---
 features/support/hooks.rb                          | 306 +++++--
 features/time_syncing.feature                      | 106 ++-
 features/tor_bridges.feature                       |  36 +
 features/tor_enforcement.feature                   |  76 ++
 features/tor_stream_isolation.feature              |  62 ++
 features/torified_browsing.feature                 | 165 +++-
 features/torified_git.feature                      |  31 +
 features/torified_gnupg.feature                    |  52 +-
 features/torified_misc.feature                     |  24 +
 features/totem.feature                             |  63 +-
 features/unsafe_browser.feature                    |  62 +-
 features/untrusted_partitions.feature              |  61 +-
 features/usb_install.feature                       | 323 ++-----
 features/usb_upgrade.feature                       | 164 ++++
 job-cfg/lvc.yaml                                   |   4 +-
 update_jdn.sh                                      |   4 +-
 80 files changed, 6894 insertions(+), 2064 deletions(-)

diff --git a/bin/lvc/run_test_suite b/bin/lvc/run_test_suite
index 9939abc..154a4a6 100755
--- a/bin/lvc/run_test_suite
+++ b/bin/lvc/run_test_suite
@@ -1,44 +1,97 @@
-#!/bin/sh
+#!/bin/bash
 
 set -e
 set -u
+set -o pipefail
 
 NAME=$(basename ${0})
 
+GENERAL_DEPENDENCIES="
+cucumber
+devscripts
+dnsmasq-base
+gawk
+git
+i18nspector
+libav-tools
+libcap2-bin
+libsikuli-script-java
+libvirt-clients
+libvirt-daemon-system
+libvirt-dev
+libvirt0
+openjdk-7-jre
+openssh-server
+ovmf
+python-jabberbot
+python-potr
+qemu-kvm
+qemu-system-x86
+ruby-guestfs
+ruby-json
+ruby-libvirt
+ruby-net-irc
+ruby-packetfu
+ruby-rb-inotify
+ruby-rjb
+ruby-rspec
+ruby-test-unit
+seabios
+tcpdump
+unclutter
+virt-viewer
+xvfb
+"
+
 usage() {
-    echo "Usage: $NAME [OPTION]... [FEATURE]...
-Sets up an appropriate environment and tests FEATUREs (all by default). Note
-that this script must be run from the Tails source directory root.
+    echo "Usage: $NAME [OPTION]... [--] [CUCUMBER_ARGS]...
+Sets up an appropriate environment and invokes cucumber. Note that this script
+must be run from the Tails source directory root.
 
 Options for '@product' features:
-  --capture FILE     Captures the test session into FILE using VP8 encoding.
-                     Requires ffmpeg and libvpx1.
-  --debug            Display various debugging information while running the
-                     test suite.
+  --artifacts-base-uri URI
+                     Pretend that the artifact is located at URI when printing
+                     its location during a scenario failure. This is useful if
+                     you intend to serve the artifacts via the web, for
+                     instance.
+  --capture          Captures failed scenarios into videos stored in the
+                     temporary directory (see --tmpdir below) using x264
+                     encoding. Requires x264.
+  --capture-all      Keep videos for all scenarios, including those that
+                     succeed (implies --capture).
   --pause-on-fail    On failure, pause test suite until pressing Enter. This is
                      useful for investigating the state of the VM guest to see
                      exactly why a test failed.
-  --keep-snapshots   Don't ever delete the background snapshots. This can a big
-                     time saver when debugging new features.
+  --keep-snapshots   Don't ever delete any snapshots (including ones marked as
+                     temporary). This can be a big time saver when debugging new
+                     features.
   --retry-find       Print a warning whenever Sikuli fails to find an image
                      and allow *one* retry after pressing ENTER. This is useful
                      for updating outdated images.
-  --temp-dir         Directory where various temporary files are written
+  --tmpdir           Directory where various temporary files are written
                      during a test, e.g. VM snapshots and memory dumps,
                      failure screenshots, pcap files and disk images
-                     (default is /tmp/DebianToaster).
+                     (default is TMPDIR in the environment, and if unset,
+                     /tmp/DebianToaster).
   --view             Shows the test session in a windows. Requires x11vnc
                      and xtightvncviewer.
   --vnc-server-only  Starts a VNC server for the test session. Requires x11vnc.
-  --iso IMAGE        Test '@product' features using IMAGE. If none is given,
-                     the ISO with most recent creation date (according to the
-                     ISO's label) in the current directory will be used.
+  --iso IMAGE        Test '@product' features using IMAGE.
   --old-iso IMAGE    For some '@product' features (e.g. usb_install) we need
                      an older version of Tails, which this options sets to
-                     IMAGE. If none is given, the ISO with the least recent
-                     creation date will be used.
+                     IMAGE. If none is given, it defaults to the same IMAGE
+                     given by --iso, which will be good enough for most testing
+                     purposes.
 
 Note that '@source' features has no relevant options.
+
+CUCUMBER_ARGS can be used to specify which features to be run, but also any
+cucumber option, although then you must pass \`--\` first to let this wrapper
+script know that we're done with *its* options. For debugging purposes, a
+'debug' formatter has been added so pretty debugging can be enabled with
+\`--format debug\`. You could even combine the default (pretty) formatter with
+pretty debugging printed to a file with \`--format pretty --format debug
+--out debug.log\`.
 "
 }
 
@@ -48,11 +101,25 @@ error() {
     exit 1
 }
 
-check_dependency() {
-    if ! which "${1}" >/dev/null && \
-       ! dpkg -s "${1}" 2>/dev/null | grep -q "^Status:.*installed"; then
-        error "'${1}' is missing, please install it and run again. Aborting..."
+package_installed() {
+    local ret
+    set +o pipefail
+    if dpkg -s "${1}" 2>/dev/null | grep -q "^Status:.*installed"; then
+        ret=0
+    else
+        ret=1
     fi
+    set -o pipefail
+    return ${ret}
+}
+
+check_dependencies() {
+    while [ -n "${1:-}" ]; do
+        if ! which "${1}" >/dev/null && ! package_installed "${1}" ; then
+            error "'${1}' is missing, please install it and run again."
+        fi
+        shift
+    done
 }
 
 display_in_use() {
@@ -67,11 +134,13 @@ next_free_display() {
     echo ":${display_nr}"
 }
 
+test_suite_cleanup() {
+    (kill -0 ${XVFB_PID} 2>/dev/null && kill ${XVFB_PID}) || /bin/true
+}
+
 start_xvfb() {
     Xvfb $TARGET_DISPLAY -screen 0 1024x768x24+32 >/dev/null 2>&1 &
     XVFB_PID=$!
-    trap "kill -0 ${XVFB_PID} 2>/dev/null && kill -9 ${XVFB_PID}; \
-          rm -f /tmp/.X${TARGET_DISPLAY#:}-lock" EXIT
     # Wait for Xvfb to run on TARGET_DISPLAY
     until display_in_use $TARGET_DISPLAY; do
 	sleep 1
@@ -82,42 +151,51 @@ start_xvfb() {
 }
 
 start_vnc_server() {
-    check_dependency x11vnc
+    check_dependencies x11vnc
     VNC_SERVER_PORT="$(x11vnc -listen localhost -display ${TARGET_DISPLAY} \
-                              -bg -nopw 2>&1 | \
+                              -bg -nopw -forever 2>&1 | \
                                   grep -m 1 "^PORT=[0-9]\+" | sed 's/^PORT=//')"
     echo "VNC server running on: localhost:${VNC_SERVER_PORT}"
 }
 
 start_vnc_viewer() {
-    check_dependency xtightvncviewer
+    check_dependencies xtightvncviewer
     xtightvncviewer -viewonly localhost:${VNC_SERVER_PORT} 1>/dev/null 2>&1 &
 }
 
 capture_session() {
+    check_dependencies libvpx1
     echo "Capturing guest display into ${CAPTURE_FILE}"
-    ffmpeg -f x11grab -s 1024x768 -r 15 -i ${TARGET_DISPLAY}.0 -an \
+    avconv -f x11grab -s 1024x768 -r 15 -i ${TARGET_DISPLAY}.0 -an \
         -vcodec libvpx -y "${CAPTURE_FILE}" >/dev/null 2>&1 &
 }
 
 # main script
 
-CAPTURE_FILE=
+# Unset all environment variables used by this script to pass options
+# to cucumber, except TMPDIR since we explicitly want to support
+# setting it that way.
+ARTIFACTS_BASE_URI=
+CAPTURE=
+CAPTURE_ALL=
+LOG_FILE=
 VNC_VIEWER=
 VNC_SERVER=
-DEBUG=
 PAUSE_ON_FAIL=
 KEEP_SNAPSHOTS=
 SIKULI_RETRY_FINDFAILED=
-TEMP_DIR=
 ISO=
 OLD_ISO=
 
-LONGOPTS="view,vnc-server-only,capture:,help,temp-dir:,keep-snapshots,retry-find,iso:,old-iso:,debug,pause-on-fail"
+LONGOPTS="artifacts-base-uri:,view,vnc-server-only,capture,capture-all,help,tmpdir:,keep-snapshots,retry-find,iso:,old-iso:,pause-on-fail"
 OPTS=$(getopt -o "" --longoptions $LONGOPTS -n "${NAME}" -- "$@")
 eval set -- "$OPTS"
 while [ $# -gt 0 ]; do
     case $1 in
+        --artifacts-base-uri)
+            shift
+            export ARTIFACTS_BASE_URI="${1}"
+            ;;
         --view)
             VNC_VIEWER=yes
             VNC_SERVER=yes
@@ -127,11 +205,13 @@ while [ $# -gt 0 ]; do
             VNC_SERVER=yes
             ;;
         --capture)
-            shift
-            CAPTURE_FILE="$1"
+            check_dependencies x264
+            export CAPTURE="yes"
             ;;
-        --debug)
-            export DEBUG="yes"
+        --capture-all)
+            check_dependencies x264
+            export CAPTURE="yes"
+            export CAPTURE_ALL="yes"
             ;;
         --pause-on-fail)
             export PAUSE_ON_FAIL="yes"
@@ -142,9 +222,9 @@ while [ $# -gt 0 ]; do
         --retry-find)
             export SIKULI_RETRY_FINDFAILED="yes"
             ;;
-        --temp-dir)
+        --tmpdir)
             shift
-            export TEMP_DIR="$(readlink -f $1)"
+            export TMPDIR="$(readlink -f $1)"
             ;;
         --iso)
             shift
@@ -166,26 +246,21 @@ while [ $# -gt 0 ]; do
     shift
 done
 
-for dep in ffmpeg git libvirt-bin libvirt-dev libavcodec-extra-53 libvpx1 \
-           virt-viewer libsikuli-script-java ovmf tcpdump xvfb; do
-    check_dependency "${dep}"
-done
+trap "test_suite_cleanup" EXIT HUP INT QUIT TERM
+
+check_dependencies ${GENERAL_DEPENDENCIES}
 
 TARGET_DISPLAY=$(next_free_display)
 
 start_xvfb
 
-if [ -n "${CAPTURE_FILE}" ]; then
-    capture_session
-fi
-if [ -n "${VNC_SERVER}" ]; then
+if [ -n "${VNC_SERVER:-}" ]; then
     start_vnc_server
 fi
-if [ -n "${VNC_VIEWER}" ]; then
+if [ -n "${VNC_VIEWER:-}" ]; then
     start_vnc_viewer
 fi
 
-export JAVA_HOME="/usr/lib/jvm/java-7-openjdk-amd64"
 export SIKULI_HOME="/usr/share/java"
 export SIKULI_IMAGE_PATH="/srv/jenkins/features/images/"
 export RUBYLIB="/srv/jenkins"
@@ -193,7 +268,10 @@ export FEATURE_PATH="/srv/jenkins/features"
 export VM_XML_PATH="/srv/jenkins/features/domains"
 export DISPLAY=${TARGET_DISPLAY}
 CUCUMBEROPTS="--verbose --backtrace --expand"
-check_dependency cucumber
+check_dependencies cucumber
+
+set -x
+
 if [ -z "${*}" ]; then
     cucumber $CUCUMBEROPTS --format ExtraHooks::Pretty $FEATURE_PATH
 else
diff --git a/features/apt.feature b/features/apt.feature
index 126f6ec..ac778c2 100644
--- a/features/apt.feature
+++ b/features/apt.feature
@@ -1,4 +1,5 @@
- at product
+#10497: wait_until_tor_is_working
+ at product @fragile
 Feature: Installing packages through APT
   As a Tails user
   when I set an administration password in Tails Greeter
@@ -21,13 +22,15 @@ Feature: Installing packages through APT
   Scenario: APT sources are configured correctly
     Then the only hosts in APT sources are "ftp.us.debian.org,http.debian.net,ftp.debian.org,security.debian.org"
 
-  Scenario: Install packages using apt-get
-    When I update APT using apt-get
-    Then I should be able to install a package using apt-get
-    And all Internet traffic has only flowed through Tor
+  #10496: apt-get scenarios are fragile
+  @check_tor_leaks @fragile
+  Scenario: Install packages using apt
+    When I update APT using apt
+    Then I should be able to install a package using apt
 
+  #10441: Synaptic test is fragile
+  @check_tor_leaks @fragile
   Scenario: Install packages using Synaptic
     When I start Synaptic
     And I update APT using Synaptic
     Then I should be able to install a package using Synaptic
-    And all Internet traffic has only flowed through Tor
diff --git a/features/build.feature b/features/build.feature
index 4cc0b65..74d314d 100644
--- a/features/build.feature
+++ b/features/build.feature
@@ -4,72 +4,209 @@ Feature: custom APT sources to build branches
   the proper APT sources were automatically picked depending
   on which Git branch I am working on.
 
-  Scenario: build from an untagged stable branch
-    Given I am working on the stable branch
-    And last released version mentioned in debian/changelog is 1.0
+  Scenario: build from an untagged stable branch where the config/APT_overlays.d directory is empty
+    Given I am working on the stable base branch
+    And the last version mentioned in debian/changelog is 1.0
     And Tails 1.0 has not been released yet
-    When I run tails-custom-apt-sources
+    And the config/APT_overlays.d directory is empty
+    When I successfully run tails-custom-apt-sources
+    Then I should see only the 'stable' suite
+
+  Scenario: build from an untagged stable branch where config/APT_overlays.d is not empty
+    Given I am working on the stable base branch
+    And the last version mentioned in debian/changelog is 1.0
+    And Tails 1.0 has not been released yet
+    And config/APT_overlays.d contains 'feature-foo'
+    And config/APT_overlays.d contains 'bugfix-bar'
+    When I successfully run tails-custom-apt-sources
     Then I should see the 'stable' suite
-    Then I should not see the '1.0' suite
+    And I should see the 'feature-foo' suite
+    And I should see the 'bugfix-bar' suite
+    But I should not see the '1.0' suite
 
-  Scenario: build from a tagged stable branch
+  Scenario: build from a tagged stable branch where the config/APT_overlays.d directory is empty
     Given Tails 0.10 has been released
-    And last released version mentioned in debian/changelog is 0.10
-    And I am working on the stable branch
-    When I run tails-custom-apt-sources
-    Then I should see the '0.10' suite
+    And the last version mentioned in debian/changelog is 0.10
+    And I am working on the stable base branch
+    And the config/APT_overlays.d directory is empty
+    When I successfully run tails-custom-apt-sources
+    Then I should see only the '0.10' suite
 
-  Scenario: build from a bugfix branch for a stable release
+  Scenario: build from a tagged stable branch where config/APT_overlays.d is not empty
     Given Tails 0.10 has been released
-    And last released version mentioned in debian/changelog is 0.10
-    And I am working on the bugfix/disable_gdomap branch based on 0.10
+    And the last version mentioned in debian/changelog is 0.10
+    And I am working on the stable base branch
+    And config/APT_overlays.d contains 'feature-foo'
     When I run tails-custom-apt-sources
-    Then I should see the '0.10' suite
+    Then it should fail
+
+  Scenario: build from a bugfix branch without overlays for a stable release
+    Given Tails 0.10 has been released
+    And the last version mentioned in debian/changelog is 0.10.1
+    And Tails 0.10.1 has not been released yet
+    And I am working on the bugfix/disable_gdomap branch based on stable
+    And the config/APT_overlays.d directory is empty
+    When I successfully run tails-custom-apt-sources
+    Then I should see only the 'stable' suite
+
+  Scenario: build from a bugfix branch with overlays for a stable release
+    Given Tails 0.10 has been released
+    And the last version mentioned in debian/changelog is 0.10.1
+    And Tails 0.10.1 has not been released yet
+    And I am working on the bugfix/disable_gdomap branch based on stable
+    And config/APT_overlays.d contains 'bugfix-disable-gdomap'
+    And config/APT_overlays.d contains 'bugfix-bar'
+    When I successfully run tails-custom-apt-sources
+    Then I should see the 'stable' suite
     And I should see the 'bugfix-disable-gdomap' suite
+    And I should see the 'bugfix-bar' suite
+    But I should not see the '0.10' suite
 
-  Scenario: build from an untagged testing branch
-    Given I am working on the testing branch
-    And last released version mentioned in debian/changelog is 0.11
+  Scenario: build from an untagged testing branch where the config/APT_overlays.d directory is empty
+    Given I am working on the testing base branch
+    And the last version mentioned in debian/changelog is 0.11
     And Tails 0.11 has not been released yet
-    When I run tails-custom-apt-sources
+    And the config/APT_overlays.d directory is empty
+    When I successfully run tails-custom-apt-sources
     Then I should see the 'testing' suite
     And I should not see the '0.11' suite
+    And I should not see the 'feature-foo' suite
+    And I should not see the 'bugfix-bar' suite
 
-  Scenario: build from a tagged testing branch
-    Given I am working on the testing branch
-    And last released version mentioned in debian/changelog is 0.11
+  Scenario: build from an untagged testing branch where config/APT_overlays.d is not empty
+    Given I am working on the testing base branch
+    And the last version mentioned in debian/changelog is 0.11
+    And Tails 0.11 has not been released yet
+    And config/APT_overlays.d contains 'feature-foo'
+    And config/APT_overlays.d contains 'bugfix-bar'
+    When I successfully run tails-custom-apt-sources
+    Then I should see the 'testing' suite
+    And I should see the 'feature-foo' suite
+    And I should see the 'bugfix-bar' suite
+    But I should not see the '0.11' suite
+
+  Scenario: build from a tagged testing branch where the config/APT_overlays.d directory is empty
+    Given I am working on the testing base branch
+    And the last version mentioned in debian/changelog is 0.11
     And Tails 0.11 has been released
+    And the config/APT_overlays.d directory is empty
+    When I successfully run tails-custom-apt-sources
+    Then I should see only the '0.11' suite
+
+  Scenario: build from a tagged testing branch where config/APT_overlays.d is not empty
+    Given I am working on the testing base branch
+    And the last version mentioned in debian/changelog is 0.11
+    And Tails 0.11 has been released
+    And config/APT_overlays.d contains 'feature-foo'
     When I run tails-custom-apt-sources
-    Then I should see the '0.11' suite
-    And I should not see the 'testing' suite
+    Then it should fail
 
   Scenario: build a release candidate from a tagged testing branch
-    Given I am working on the testing branch
+    Given I am working on the testing base branch
     And Tails 0.11 has been released
-    And last released version mentioned in debian/changelog is 0.12~rc1
+    And the last version mentioned in debian/changelog is 0.12~rc1
     And Tails 0.12-rc1 has been tagged
-    When I run tails-custom-apt-sources
-    Then I should see the '0.12-rc1' suite
-    And I should not see the 'testing' suite
+    And the config/APT_overlays.d directory is empty
+    When I successfully run tails-custom-apt-sources
+    Then I should see only the '0.12-rc1' suite
 
-  Scenario: build from the devel branch
-    Given I am working on the devel branch
+  Scenario: build a release candidate from a tagged testing branch where config/APT_overlays.d is not empty
+    Given I am working on the testing base branch
+    And Tails 0.11 has been released
+    And the last version mentioned in debian/changelog is 0.12~rc1
+    And Tails 0.12-rc1 has been tagged
+    And config/APT_overlays.d contains 'bugfix-bar'
     When I run tails-custom-apt-sources
+    Then it should fail
+
+  Scenario: build from the devel branch without overlays
+    Given I am working on the devel base branch
+    And the config/APT_overlays.d directory is empty
+    When I successfully run tails-custom-apt-sources
+    Then I should see only the 'devel' suite
+
+  Scenario: build from the devel branch with overlays
+    Given I am working on the devel base branch
+    And config/APT_overlays.d contains 'feature-foo'
+    And config/APT_overlays.d contains 'bugfix-bar'
+    When I successfully run tails-custom-apt-sources
     Then I should see the 'devel' suite
+    And I should see the 'feature-foo' suite
+    And I should see the 'bugfix-bar' suite
+
+  Scenario: build from the feature/jessie branch without overlays
+    Given I am working on the feature/jessie base branch
+    And the config/APT_overlays.d directory is empty
+    When I successfully run tails-custom-apt-sources
+    Then I should see only the 'feature-jessie' suite
+
+  Scenario: build from the feature/jessie branch with overlays
+    Given I am working on the feature/jessie base branch
+    And config/APT_overlays.d contains 'feature-7756-reintroduce-whisperback'
+    When I successfully run tails-custom-apt-sources
+    Then I should see the 'feature-jessie' suite
+    And I should see the 'feature-7756-reintroduce-whisperback' suite
 
   Scenario: build from the experimental branch
-    Given I am working on the experimental branch
-    When I run tails-custom-apt-sources
-    Then I should see the 'experimental' suite
+    Given I am working on the experimental branch based on devel
+    And config/APT_overlays.d contains 'feature-foo'
+    And config/APT_overlays.d contains 'bugfix-bar'
+    When I successfully run tails-custom-apt-sources
+    Then I should see the 'devel' suite
+    And I should see the 'feature-foo' suite
+    And I should see the 'bugfix-bar' suite
 
-  Scenario: build from a feature branch based on devel
+  Scenario: build from a feature branch with overlays based on devel
     Given I am working on the feature/icedove branch based on devel
-    When I run tails-custom-apt-sources
+    And config/APT_overlays.d contains 'feature-icedove'
+    And config/APT_overlays.d contains 'bugfix-bar'
+    When I successfully run tails-custom-apt-sources
     Then I should see the 'devel' suite
     And I should see the 'feature-icedove' suite
+    And I should see the 'bugfix-bar' suite
+
+  Scenario: build from a feature branch without overlays based on devel
+    Given I am working on the feature/icedove branch based on devel
+    And the config/APT_overlays.d directory is empty
+    When I successfully run tails-custom-apt-sources
+    Then I should see only the 'devel' suite
+
+  Scenario: build from a feature branch with overlays based on feature/jessie
+    Given I am working on the feature/7756-reintroduce-whisperback branch based on feature/jessie
+    And config/APT_overlays.d contains 'feature-7756-reintroduce-whisperback'
+    And config/APT_overlays.d contains 'bugfix-bar'
+    When I successfully run tails-custom-apt-sources
+    Then I should see the 'feature-jessie' suite
+    And I should see the 'feature-7756-reintroduce-whisperback' suite
+    And I should see the 'bugfix-bar' suite
+
+  Scenario: build from a feature branch without overlays based on feature/jessie
+    Given I am working on the feature/icedove branch based on feature/jessie
+    And the config/APT_overlays.d directory is empty
+    When I successfully run tails-custom-apt-sources
+    Then I should see only the 'feature-jessie' suite
 
   Scenario: build from a feature branch based on devel with dots in its name
     Given I am working on the feature/live-boot-3.x branch based on devel
-    When I run tails-custom-apt-sources
+    And config/APT_overlays.d contains 'feature-live-boot-3.x'
+    When I successfully run tails-custom-apt-sources
     Then I should see the 'devel' suite
     And I should see the 'feature-live-boot-3.x' suite
+
+  Scenario: build from a branch that has no config/APT_overlays.d directory
+    Given I am working on the stable base branch
+    And the config/APT_overlays.d directory does not exist
+    When I run tails-custom-apt-sources
+    Then it should fail
+
+  Scenario: build from a branch that has no config/base_branch file
+    Given I am working on the stable base branch
+    And the config/base_branch file does not exist
+    When I run tails-custom-apt-sources
+    Then it should fail
+
+  Scenario: build from a branch where config/base_branch is empty
+    Given I am working on the stable base branch
+    And the config/base_branch file is empty
+    When I run tails-custom-apt-sources
+    Then it should fail
diff --git a/features/checks.feature b/features/checks.feature
index 277bdb9..24d3594 100644
--- a/features/checks.feature
+++ b/features/checks.feature
@@ -1,57 +1,107 @@
 @product
 Feature: Various checks
 
-  Background:
-    Given a computer
-    And I start Tails from DVD with network unplugged and I login
-    And I save the state so the background can be restored next scenario
-
   Scenario: AppArmor is enabled and has enforced profiles
+    Given I have started Tails from DVD without network and logged in
     Then AppArmor is enabled
     And some AppArmor profiles are enforced
 
+  Scenario: A screenshot is taken when the PRINTSCREEN key is pressed
+    Given I have started Tails from DVD without network and logged in
+    And there is no screenshot in the live user's Pictures directory
+    When I press the "PRINTSCREEN" key
+    Then a screenshot is saved to the live user's Pictures directory
+
   Scenario: VirtualBox guest modules are available
+    Given I have started Tails from DVD without network and logged in
     When Tails has booted a 64-bit kernel
     Then the VirtualBox guest modules are available
 
-   Scenario: The shipped Tails signing key is up-to-date
-    Given the network is plugged
+  Scenario: The shipped Tails OpenPGP keys are up-to-date
+    Given I have started Tails from DVD without network and logged in
+    Then the OpenPGP keys shipped with Tails will be valid for the next 3 months
+
+  Scenario: The Tails Debian repository key is up-to-date
+    Given I have started Tails from DVD without network and logged in
+    Then the shipped Debian repository key will be valid for the next 3 months
+
+  @doc @fragile
+  Scenario: The "Report an Error" launcher will open the support documentation
+    Given I have started Tails from DVD without network and logged in
+    And the network is plugged
     And Tor is ready
     And all notifications have disappeared
-    Then the shipped Tails signing key is not outdated
+    When I double-click the Report an Error launcher on the desktop
+    Then the support documentation page opens in Tor Browser
 
   Scenario: The live user is setup correctly
+    Given I have started Tails from DVD without network and logged in
     Then the live user has been setup by live-boot
-    And the live user is a member of only its own group and "audio cdrom dialout floppy video plugdev netdev fuse scanner lp lpadmin vboxsf"
+    And the live user is a member of only its own group and "audio cdrom dialout floppy video plugdev netdev scanner lp lpadmin vboxsf"
     And the live user owns its home dir and it has normal permissions
 
+  @fragile
   Scenario: No initial network
-    Given I wait between 30 and 60 seconds
+    Given I have started Tails from DVD without network and logged in
+    And I wait between 30 and 60 seconds
+    Then the Tor Status icon tells me that Tor is not usable
     When the network is plugged
-    And Tor is ready
+    Then Tor is ready
+    And the Tor Status icon tells me that Tor is usable
     And all notifications have disappeared
     And the time has synced
-    And process "vidalia" is running within 30 seconds
 
+  @fragile
+  Scenario: The 'Tor is ready' notification is shown when Tor has bootstrapped
+    Given I have started Tails from DVD without network and logged in
+    And the network is plugged
+    When I see the 'Tor is ready' notification
+    Then Tor is ready
+
+  @fragile
+  Scenario: The tor process should be confined with Seccomp
+    Given I have started Tails from DVD without network and logged in
+    And the network is plugged
+    And Tor is ready
+    Then the running process "tor" is confined with Seccomp in filter mode
+
+  @fragile
   Scenario: No unexpected network services
+    Given I have started Tails from DVD without network and logged in
     When the network is plugged
     And Tor is ready
     Then no unexpected services are listening for network connections
 
   Scenario: The emergency shutdown applet can shutdown Tails
+    Given I have started Tails from DVD without network and logged in
     When I request a shutdown using the emergency shutdown applet
     Then Tails eventually shuts down
 
   Scenario: The emergency shutdown applet can reboot Tails
+    Given I have started Tails from DVD without network and logged in
     When I request a reboot using the emergency shutdown applet
     Then Tails eventually restarts
 
-  # We ditch the background snapshot for this scenario since we cannot
-  # add a filesystem share to a live VM so it would have to be in the
-  # background above. However, there's a bug that seems to make shares
-  # impossible to have after a snapshot restore.
-  Scenario: MAT can clean a PDF file
-    Given a computer
-    And I setup a filesystem share containing a sample PDF
-    And I start Tails from DVD with network unplugged and I login
-    Then MAT can clean some sample PDF file
+  Scenario: tails-debugging-info does not leak information
+    Given I have started Tails from DVD without network and logged in
+    Then tails-debugging-info is not susceptible to symlink attacks
+
+  Scenario: Tails shuts down on DVD boot medium removal
+    Given I have started Tails from DVD without network and logged in
+    When I eject the boot medium
+    Then Tails eventually shuts down
+
+  #10720
+  @fragile
+  Scenario: Tails shuts down on USB boot medium removal
+    Given I have started Tails without network from a USB drive without a persistent partition and logged in
+    When I eject the boot medium
+    Then Tails eventually shuts down
+
+  Scenario: The Tails Greeter "disable all networking" option disables networking within Tails
+    Given I have started Tails from DVD without network and stopped at Tails Greeter's login screen
+    And I enable more Tails Greeter options
+    And I disable all networking in the Tails Greeter
+    And I log in to a new session
+    And the Tails desktop is ready
+    Then no network interfaces are enabled
diff --git a/features/config/defaults.yml b/features/config/defaults.yml
new file mode 100644
index 0000000..9c31214
--- /dev/null
+++ b/features/config/defaults.yml
@@ -0,0 +1,36 @@
+CAPTURE: false
+CAPTURE_ALL: false
+MAX_NEW_TOR_CIRCUIT_RETRIES: 10
+PAUSE_ON_FAIL: false
+SIKULI_RETRY_FINDFAILED: false
+TMPDIR: "/tmp/DebianToaster"
+
+Unsafe_SSH_private_key: |
+  -----BEGIN RSA PRIVATE KEY-----
+  MIIEowIBAAKCAQEAvMUNgUUM/kyuo26m+Xw7igG6zgGFMFbS3u8m5StGsJOn7zLi
+  J8P5Mml/R+4tdOS6owVU4RaZTPsNZZK/ClYmOPhmNvJ04pVChk2DZ8AARg/TANj3
+  qjKs3D+MeKbk1bt6EsA55kgGsTUky5Ti8cc2Wna25jqjagIiyM822PGG9mmI6/zL
+  YR6QLUizNaciXrRM3Q4R4sQkEreVlHeonPEiGUs9zx0swCpLtPM5UIYte1PVHgkw
+  ePsU6vM8UqVTK/VwtLLgLanXnsMFuzq7DTAXPq49+XSFNq4JlxbEF6+PQXZvYZ5N
+  eW00Gq7NSpPP8uoHr6f1J+mMxxnM85jzYtRx+QIDAQABAoIBAA8Bs1MlhCTrP67q
+  awfGYo1UGd+qq0XugREL/hGV4SbEdkNDzkrO/46MaHv1aVOzo0q2b8r9Gu7NvoDm
+  q51Mv/kjdizEFZq1tvYqT1n+H4dyVpnopbe4E5nmy2oECokbQFchRPkTnMSVrvko
+  OupxpdaHPX8MBlW1GcLRBlE00j/gfK1SXX5rcxkF5EHVND1b6iHddTPearDbU8yr
+  wga1XO6WeohAYzqmGtMD0zk6lOk0LmnTNG6WvHiFTAc/0yTiKub6rNOIEMS/82+V
+  l437H0hKcIN/7/mf6FpqRNPJTuhOVFf+L4G/ZQ8zHoMGVIbhuTiIPqZ/KMu3NaUF
+  R634jckCgYEA+jJ31hom/d65LfxWPkmiSkNTEOTfjbfcgpfc7sS3enPsYnfnmn5L
+  O3JJzAKShSVP8NVuPN5Mg5FGp9QLKrN3kV6QWQ3EnqeW748DXMU6zKGJQ5wo7ZVm
+  w2DhJ/3PAuBTL/5X4mjPQL+dr86Aq2JBDC7LHJs40I8O7UbhnsdMxKcCgYEAwSXc
+  3znAkAX8o2g37RiAl36HdONgxr2eaGK7OExp03pbKmoISw6bFbVpicBy6eTytn0A
+  2PuFcBKJRfKrViHyiE8UfAJ31JbUaxpg4bFF6UEszN4CmgKS8fnwEe1aX0qSjvkE
+  NQSuhN5AfykXY/1WVIaWuC500uB7Ow6M16RDyF8CgYEAqFTeNYlg5Hs+Acd9SukF
+  rItBTuN92P5z+NUtyuNFQrjNuK5Nf68q9LL/Hag5ZiVldHZUddVmizpp3C6Y2MDo
+  WEDUQ2Y0/D1rGoAQ1hDIb7bbAEcHblmPSzJaKirkZV4B+g9Yl7bGghypfggkn6o6
+  c3TkKLnybrdhZpjC4a3bY48CgYBnWRYdD27c4Ycz/GDoaZLs/NQIFF5FGVL4cdPR
+  pPl/IdpEEKZNWwxaik5lWedjBZFlWe+pKrRUqmZvWhCZruJyUzYXwM5Tnz0b7epm
+  +Q76Z1hMaoKj27q65UyymvkfQey3ucCpic7D45RJNjiA1R5rbfSZqqnx6BGoIPn1
+  rLxkKwKBgDXiWeUKJCydj0NfHryGBkQvaDahDE3Yigcma63b8vMZPBrJSC4SGAHJ
+  NWema+bArbaF0rKVJpwvpkZWGcr6qRn94Ts0kJAzR+VIVTOjB9sVwdxjadwWHRs5
+  kKnpY0tnSF7hyVRwN7GOsNDJEaFjCW7k4+55D2ZNBy2iN3beW8CZ
+  -----END RSA PRIVATE KEY-----
+Unsafe_SSH_public_key: = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC8xQ2BRQz+TK6jbqb5fDuKAbrOAYUwVtLe7yblK0awk6fvMuInw/kyaX9H7i105LqjBVThFplM+w1lkr8KViY4+GY28nTilUKGTYNnwABGD9MA2PeqMqzcP4x4puTVu3oSwDnmSAaxNSTLlOLxxzZadrbmOqNqAiLIzzbY8Yb2aYjr/MthHpAtSLM1pyJetEzdDhHixCQSt5WUd6ic8SIZSz3PHSzAKku08zlQhi17U9UeCTB4+xTq8zxSpVMr9XC0suAtqdeewwW7OrsNMBc+rj35dIU2rgmXFsQXr49Bdm9hnk15bTQars1Kk8/y6gevp/Un6YzHGczzmPNi1HH5 amnesia at amnesia"
diff --git a/features/dhcp.feature b/features/dhcp.feature
index c15ae0c..18874db 100644
--- a/features/dhcp.feature
+++ b/features/dhcp.feature
@@ -1,32 +1,22 @@
- at product
+ at product @fragile
 Feature: Getting a DHCP lease without leaking too much information
   As a Tails user
   when I connect to a network with a DHCP server
   I should be able to connect to the Internet
   and the hostname should not have been leaked on the network.
 
-  Scenario: Getting a DHCP lease with the default NetworkManager connection
-    Given a computer
+  Background:
+    Given I have started Tails from DVD without network and logged in
     And I capture all network traffic
-    And I start the computer
-    And the computer boots Tails
-    And I log in to a new session
-    And GNOME has started
+    And the network is plugged
     And Tor is ready
     And all notifications have disappeared
     And available upgrades have been checked
+
+  Scenario: Getting a DHCP lease with the default NetworkManager connection
     Then the hostname should not have been leaked on the network
 
   Scenario: Getting a DHCP lease with a manually configured NetworkManager connection
-    Given a computer
-    And I capture all network traffic
-    And I start the computer
-    And the computer boots Tails
-    And I log in to a new session
-    And GNOME has started
-    And Tor is ready
-    And all notifications have disappeared
-    And available upgrades have been checked
-    And I add a wired DHCP NetworkManager connection called "manually-added-con"
+    When I add a wired DHCP NetworkManager connection called "manually-added-con"
     And I switch to the "manually-added-con" NetworkManager connection
     Then the hostname should not have been leaked on the network
diff --git a/features/domains/default.xml b/features/domains/default.xml
index 6050d6b..f1004dc 100644
--- a/features/domains/default.xml
+++ b/features/domains/default.xml
@@ -12,6 +12,7 @@
     <apic/>
     <pae/>
   </features>
+  <cpu mode='host-model'/>
   <clock offset='utc'/>
   <on_poweroff>destroy</on_poweroff>
   <on_reboot>restart</on_reboot>
@@ -23,23 +24,17 @@
       <source file=''/>
       <target dev='hdc' bus='ide'/>
       <readonly/>
-      <address type='drive' controller='0' bus='1' target='0' unit='0'/>
     </disk>
-    <controller type='usb' index='0' model='ich9-ehci1'>
-      <address type='pci' domain='0x0000' bus='0x00' slot='0x05' function='0x7'/>
-    </controller>
+    <controller type='usb' index='0' model='ich9-ehci1'/>
     <controller type='usb' index='0' model='ich9-uhci1'>
       <master startport='0'/>
-      <address type='pci' domain='0x0000' bus='0x00' slot='0x05' function='0x0' multifunction='on'/>
-    </controller>
-    <controller type='ide' index='0'>
-      <address type='pci' domain='0x0000' bus='0x00' slot='0x01' function='0x1'/>
     </controller>
+    <controller type='ide' index='0'/>
+    <controller type='virtio-serial' index='0'/>
     <interface type='network'>
       <mac address='52:54:00:ac:dd:ee'/>
       <source network='DebianToasterNet'/>
       <model type='virtio'/>
-      <address type='pci' domain='0x0000' bus='0x00' slot='0x03' function='0x0'/>
       <link state='up'/>
     </interface>
     <serial type='tcp'>
@@ -47,18 +42,18 @@
       <target port='0'/>
     </serial>
     <input type='tablet' bus='usb'/>
-    <input type='mouse' bus='ps2'/>
-    <graphics type='vnc' port='-1' autoport='yes'/>
-    <sound model='ich6'>
-      <address type='pci' domain='0x0000' bus='0x00' slot='0x04' function='0x0'/>
-    </sound>
+    <channel type='spicevmc'>
+      <target type='virtio' name='com.redhat.spice.0'/>
+    </channel>
+    <graphics type='spice' port='-1' tlsPort='-1' autoport='yes'>
+      <mouse mode='client'/>
+    </graphics>
+    <sound model='ich6'/>
     <video>
-      <model type='qxl' vram='9216' heads='1'/>
+      <model type='qxl' ram='65536' vram='131072' vgamem='16384' heads='1'/>
       <address type='pci' domain='0x0000' bus='0x00' slot='0x02' function='0x0'/>
     </video>
-    <memballoon model='virtio'>
-      <address type='pci' domain='0x0000' bus='0x00' slot='0x06' function='0x0'/>
-    </memballoon>
+    <memballoon model='virtio'/>
   </devices>
 </domain>
 
diff --git a/features/domains/volume.xml b/features/domains/volume.xml
index 9159c26..702d5a0 100644
--- a/features/domains/volume.xml
+++ b/features/domains/volume.xml
@@ -8,7 +8,7 @@
     <permissions>
       <owner></owner>
       <group></group>
-      <mode>0774</mode>
+      <mode>0664</mode>
     </permissions>
   </target>
 </volume>
diff --git a/features/electrum.feature b/features/electrum.feature
new file mode 100644
index 0000000..e4e8d74
--- /dev/null
+++ b/features/electrum.feature
@@ -0,0 +1,34 @@
+#10497: wait_until_tor_is_working
+#10720: Tails Installer freezes on Jenkins
+ at product @check_tor_leaks @fragile
+Feature: Electrum Bitcoin client
+  As a Tails user
+  I might want to use a Bitcoin client
+  And all Internet traffic should flow only through Tor
+
+  Scenario: A warning will be displayed if Electrum is not persistent
+    Given I have started Tails from DVD and logged in and the network is connected
+    When I start Electrum through the GNOME menu
+    But persistence for "electrum" is not enabled
+    Then I see a warning that Electrum is not persistent
+
+  Scenario: Using a persistent Electrum configuration
+    Given I have started Tails without network from a USB drive with a persistent partition enabled and logged in
+    And the network is plugged
+    And Tor is ready
+    And available upgrades have been checked
+    And all notifications have disappeared
+    Then persistence for "electrum" is enabled
+    When I start Electrum through the GNOME menu
+    But a bitcoin wallet is not present
+    Then I am prompted to create a new wallet
+    When I create a new bitcoin wallet
+    Then a bitcoin wallet is present
+    And I see the main Electrum client window
+    And Electrum successfully connects to the network
+    And I shutdown Tails and wait for the computer to power off
+    Given I start Tails from USB drive "__internal" and I login with persistence enabled
+    When I start Electrum through the GNOME menu
+    And a bitcoin wallet is present
+    And I see the main Electrum client window
+    Then Electrum successfully connects to the network
diff --git a/features/encryption.feature b/features/encryption.feature
index 2f30d2a..52cc152 100644
--- a/features/encryption.feature
+++ b/features/encryption.feature
@@ -5,21 +5,25 @@ Feature: Encryption and verification using GnuPG
   And decrypt and verify GnuPG blocks
 
   Background:
-    Given a computer
-    And I start Tails from DVD with network unplugged and I login
+    Given I have started Tails from DVD without network and logged in
     And I generate an OpenPGP key named "test" with password "asdf"
-    And I save the state so the background can be restored next scenario
 
+  #10992
+  @fragile
   Scenario: Encryption and decryption using Tails OpenPGP Applet
     When I type a message into gedit
     And I encrypt the message using my OpenPGP key
     Then I can decrypt the encrypted message
 
+  #10992
+  @fragile
   Scenario: Signing and verification using Tails OpenPGP Applet
     When I type a message into gedit
     And I sign the message using my OpenPGP key
     Then I can verify the message's signature
 
+  #10991
+  @fragile
   Scenario: Encryption/signing and decryption/verification using Tails OpenPGP Applet
     When I type a message into gedit
     And I both encrypt and sign the message using my OpenPGP key
diff --git a/features/evince.feature b/features/evince.feature
index fe687f3..6fd27ec 100644
--- a/features/evince.feature
+++ b/features/evince.feature
@@ -4,50 +4,63 @@ Feature: Using Evince
   I want to view and print PDF files in Evince
   And AppArmor should prevent Evince from doing dangerous things
 
-  Background:
-    Given a computer
-    And I start Tails from DVD with network unplugged and I login
-    And I save the state so the background can be restored next scenario
-
+  #10994
+  @fragile
   Scenario: I can view and print a PDF file stored in /usr/share
+    Given I have started Tails from DVD without network and logged in
     When I open "/usr/share/cups/data/default-testpage.pdf" with Evince
-    Then I see "CupsTestPage.png" after at most 10 seconds
+    Then I see "CupsTestPage.png" after at most 20 seconds
     And I can print the current document to "/home/amnesia/output.pdf"
 
+  #10994
+  @fragile
   Scenario: I can view and print a PDF file stored in non-persistent /home/amnesia
-    Given I copy "/usr/share/cups/data/default-testpage.pdf" to "/home/amnesia" as user "amnesia"
+    Given I have started Tails from DVD without network and logged in
+    And I copy "/usr/share/cups/data/default-testpage.pdf" to "/home/amnesia" as user "amnesia"
     When I open "/home/amnesia/default-testpage.pdf" with Evince
-    Then I see "CupsTestPage.png" after at most 10 seconds
+    Then I see "CupsTestPage.png" after at most 20 seconds
     And I can print the current document to "/home/amnesia/output.pdf"
 
   Scenario: I cannot view a PDF file stored in non-persistent /home/amnesia/.gnupg
-    Given I copy "/usr/share/cups/data/default-testpage.pdf" to "/home/amnesia/.gnupg" as user "amnesia"
+    Given I have started Tails from DVD without network and logged in
+    And I copy "/usr/share/cups/data/default-testpage.pdf" to "/home/amnesia/.gnupg" as user "amnesia"
+    Then the file "/home/amnesia/.gnupg/default-testpage.pdf" exists
+    And the file "/lib/live/mount/overlay/home/amnesia/.gnupg/default-testpage.pdf" exists
+    And the file "/live/overlay/home/amnesia/.gnupg/default-testpage.pdf" exists
+    Given I start monitoring the AppArmor log of "/usr/bin/evince"
     When I try to open "/home/amnesia/.gnupg/default-testpage.pdf" with Evince
     Then I see "EvinceUnableToOpen.png" after at most 10 seconds
+    And AppArmor has denied "/usr/bin/evince" from opening "/home/amnesia/.gnupg/default-testpage.pdf"
+    When I close Evince
+    Given I restart monitoring the AppArmor log of "/usr/bin/evince"
+    When I try to open "/lib/live/mount/overlay/home/amnesia/.gnupg/default-testpage.pdf" with Evince
+    Then I see "EvinceUnableToOpen.png" after at most 10 seconds
+    And AppArmor has denied "/usr/bin/evince" from opening "/lib/live/mount/overlay/home/amnesia/.gnupg/default-testpage.pdf"
+    When I close Evince
+    Given I restart monitoring the AppArmor log of "/usr/bin/evince"
+    When I try to open "/live/overlay/home/amnesia/.gnupg/default-testpage.pdf" with Evince
+    Then I see "EvinceUnableToOpen.png" after at most 10 seconds
+    # Due to our AppArmor aliases, /live/overlay will be treated
+    # as /lib/live/mount/overlay.
+    And AppArmor has denied "/usr/bin/evince" from opening "/lib/live/mount/overlay/home/amnesia/.gnupg/default-testpage.pdf"
 
-  @keep_volumes
-  Scenario: Installing Tails on a USB drive, creating a persistent partition, copying PDF files to it
-    Given the USB drive "current" contains Tails with persistence configured and password "asdf"
-    And a computer
-    And I start Tails from USB drive "current" with network unplugged and I login with persistence password "asdf"
+  #10720: Tails Installer freezes on Jenkins
+  @fragile
+  Scenario: I can view and print a PDF file stored in persistent /home/amnesia/Persistent
+    Given I have started Tails without network from a USB drive with a persistent partition enabled and logged in
     And I copy "/usr/share/cups/data/default-testpage.pdf" to "/home/amnesia/Persistent" as user "amnesia"
     Then the file "/home/amnesia/Persistent/default-testpage.pdf" exists
-    And I copy "/usr/share/cups/data/default-testpage.pdf" to "/home/amnesia/.gnupg" as user "amnesia"
-    Then the file "/home/amnesia/.gnupg/default-testpage.pdf" exists
-    And I shutdown Tails and wait for the computer to power off
-
-  @keep_volumes
-  Scenario: I can view and print a PDF file stored in persistent /home/amnesia/Persistent
-    Given a computer
-    And I start Tails from USB drive "current" with network unplugged and I login with persistence password "asdf"
     When I open "/home/amnesia/Persistent/default-testpage.pdf" with Evince
-    Then I see "CupsTestPage.png" after at most 10 seconds
+    Then I see "CupsTestPage.png" after at most 20 seconds
     And I can print the current document to "/home/amnesia/Persistent/output.pdf"
 
-  @keep_volumes
+  #10720: Tails Installer freezes on Jenkins
+  @fragile
   Scenario: I cannot view a PDF file stored in persistent /home/amnesia/.gnupg
-    Given a computer
-    When I start Tails from USB drive "current" with network unplugged and I login with persistence password "asdf"
+    Given I have started Tails without network from a USB drive with a persistent partition enabled and logged in
+    And I copy "/usr/share/cups/data/default-testpage.pdf" to "/home/amnesia/.gnupg" as user "amnesia"
+    Then the file "/home/amnesia/.gnupg/default-testpage.pdf" exists
+    Given I start monitoring the AppArmor log of "/usr/bin/evince"
     And I try to open "/home/amnesia/.gnupg/default-testpage.pdf" with Evince
     Then I see "EvinceUnableToOpen.png" after at most 10 seconds
-
+    And AppArmor has denied "/usr/bin/evince" from opening "/home/amnesia/.gnupg/default-testpage.pdf"
diff --git a/features/firewall_leaks.feature b/features/firewall_leaks.feature
deleted file mode 100644
index 775c6e1..0000000
--- a/features/firewall_leaks.feature
+++ /dev/null
@@ -1,37 +0,0 @@
- at product
-Feature:
-  As a Tails developer
-  I want to ensure that the automated test suite detects firewall leaks reliably
-
-  Background:
-    Given a computer
-    And I capture all network traffic
-    And I start the computer
-    And the computer boots Tails
-    And I log in to a new session
-    And Tor is ready
-    And all notifications have disappeared
-    And available upgrades have been checked
-    And all Internet traffic has only flowed through Tor
-    And I save the state so the background can be restored next scenario
-
-  Scenario: Detecting IPv4 TCP leaks from the Unsafe Browser
-    When I successfully start the Unsafe Browser
-    And I open the address "https://check.torproject.org" in the Unsafe Browser
-    And I see "UnsafeBrowserTorCheckFail.png" after at most 60 seconds
-    Then the firewall leak detector has detected IPv4 TCP leaks
-
-  Scenario: Detecting IPv4 TCP leaks of TCP DNS lookups
-    Given I disable Tails' firewall
-    When I do a TCP DNS lookup of "torproject.org"
-    Then the firewall leak detector has detected IPv4 TCP leaks
-
-  Scenario: Detecting IPv4 non-TCP leaks (UDP) of UDP DNS lookups
-    Given I disable Tails' firewall
-    When I do a UDP DNS lookup of "torproject.org"
-    Then the firewall leak detector has detected IPv4 non-TCP leaks
-
-  Scenario: Detecting IPv4 non-TCP (ICMP) leaks of ping
-    Given I disable Tails' firewall
-    When I send some ICMP pings
-    Then the firewall leak detector has detected IPv4 non-TCP leaks
diff --git a/features/icedove.feature b/features/icedove.feature
new file mode 100644
index 0000000..e05f024
--- /dev/null
+++ b/features/icedove.feature
@@ -0,0 +1,39 @@
+ at product @check_tor_leaks @fragile
+Feature: Icedove email client
+  As a Tails user
+  I may want to use an email client
+
+  Background:
+    Given I have started Tails from DVD and logged in and the network is connected
+    When I start "Icedove" via the GNOME "Internet" applications menu
+    And Icedove has started
+    And I have not configured an email account
+    Then I am prompted to setup an email account
+
+  Scenario: Icedove defaults to using IMAP
+    Then IMAP is the default protocol
+
+  Scenario: Adblock is not enabled within Icedove
+    Given I cancel setting up an email account
+    When I open Icedove's Add-ons Manager
+    And I click the extensions tab
+    Then I see that Adblock is not installed in Icedove
+
+  Scenario: Enigmail is configured to use the correct keyserver
+    Given I cancel setting up an email account
+    And I go into Enigmail's preferences
+    When I click Enigmail's keyserver tab
+    Then I see that Enigmail is configured to use the correct keyserver
+    When I click Enigmail's advanced tab
+    Then I see that Enigmail is configured to use the correct SOCKS proxy
+
+  Scenario: Torbirdy is configured to use Tor
+    Given I cancel setting up an email account
+    And I open Torbirdy's preferences
+    Then I see that Torbirdy is configured to use Tor
+
+  Scenario: Icedove will work over Tor
+    Given I cancel setting up an email account
+    And I open Torbirdy's preferences
+    When I test Torbirdy's proxy settings
+    Then Torbirdy's proxy test is successful
diff --git a/features/localization.feature b/features/localization.feature
new file mode 100644
index 0000000..f533add
--- /dev/null
+++ b/features/localization.feature
@@ -0,0 +1,19 @@
+ at product @fragile
+Feature: Localization
+  As a Tails user
+  I want Tails to be localized in my native language
+  And various Tails features should still work
+
+  @doc
+  Scenario: The Report an Error launcher will open the support documentation in supported non-English locales
+    Given I have started Tails from DVD without network and stopped at Tails Greeter's login screen
+    And the network is plugged
+    And I log in to a new session in German
+    And Tails seems to have booted normally
+    And Tor is ready
+    When I double-click the Report an Error launcher on the desktop
+    Then the support documentation page opens in Tor Browser
+
+  Scenario: The Unsafe Browser can be used in all languages supported in Tails
+    Given I have started Tails from DVD and logged in and the network is connected
+    Then the Unsafe Browser works in all supported languages
diff --git a/features/mac_spoofing.feature b/features/mac_spoofing.feature
new file mode 100644
index 0000000..5777372
--- /dev/null
+++ b/features/mac_spoofing.feature
@@ -0,0 +1,71 @@
+ at product
+Feature: Spoofing MAC addresses
+  In order to not reveal information about the physical location
+  As a Tails user
+  I want to be able to control whether my network devices MAC addresses should be spoofed
+  And I want this feature to fail safe and notify me in case of errors
+
+  Background:
+    Given I have started Tails from DVD without network and stopped at Tails Greeter's login screen
+    And I capture all network traffic
+    And the network is plugged
+
+  @fragile
+  Scenario: MAC address spoofing is disabled
+    When I enable more Tails Greeter options
+    And I disable MAC spoofing in Tails Greeter
+    And I log in to a new session
+    And the Tails desktop is ready
+    And Tor is ready
+    Then 1 network interface is enabled
+    And the network device has its default MAC address configured
+    And the real MAC address was leaked
+
+  @fragile
+  Scenario: MAC address spoofing is successful
+    When I log in to a new session
+    And the Tails desktop is ready
+    And Tor is ready
+    Then 1 network interface is enabled
+    And the network device has a spoofed MAC address configured
+    And the real MAC address was not leaked
+
+  #10774
+  @fragile
+  Scenario: MAC address spoofing fails and macchanger returns false
+    Given macchanger will fail by not spoofing and always returns false
+    When I log in to a new session
+    And see the "Network card disabled" notification
+    And the Tails desktop is ready
+    Then no network interfaces are enabled
+    And the real MAC address was not leaked
+
+  #10774
+  @fragile
+  Scenario: MAC address spoofing fails and macchanger returns true
+    Given macchanger will fail by not spoofing and always returns true
+    When I log in to a new session
+    And see the "Network card disabled" notification
+    And the Tails desktop is ready
+    Then no network interfaces are enabled
+    And the real MAC address was not leaked
+
+  #10774
+  @fragile
+  Scenario: MAC address spoofing fails and the module is not removed
+    Given macchanger will fail by not spoofing and always returns true
+    And no network interface modules can be unloaded
+    When I log in to a new session
+    And see the "All networking disabled" notification
+    And the Tails desktop is ready
+    Then 1 network interface is enabled
+    But the MAC spoofing panic mode disabled networking
+    And the real MAC address was not leaked
+
+  Scenario: The MAC address is not leaked when booting Tails
+    Given a computer
+    And I capture all network traffic
+    When I start the computer
+    Then the computer boots Tails
+    And no network interfaces are enabled
+    And the real MAC address was not leaked
diff --git a/features/mat.feature b/features/mat.feature
new file mode 100644
index 0000000..e492b0f
--- /dev/null
+++ b/features/mat.feature
@@ -0,0 +1,13 @@
+ at product
+Feature: Metadata Anonymization Toolkit
+  As a Tails user
+  I want to be able to remove leaky metadata from documents and media files
+
+  # In this feature we cannot restore from snapshots since it's
+  # incompatible with filesystem shares.
+
+  Scenario: MAT can clean a PDF file
+    Given a computer
+    And I setup a filesystem share containing a sample PDF
+    And I start Tails from DVD with network unplugged and I login
+    Then MAT can clean some sample PDF file
diff --git a/features/persistence.feature b/features/persistence.feature
new file mode 100644
index 0000000..13f0af7
--- /dev/null
+++ b/features/persistence.feature
@@ -0,0 +1,55 @@
+#10720: Tails Installer freezes on Jenkins
+ at product @fragile
+Feature: Tails persistence
+  As a Tails user
+  I want to use Tails persistence feature
+
+  Scenario: Booting Tails from a USB drive with a disabled persistent partition
+    Given I have started Tails without network from a USB drive with a persistent partition and stopped at Tails Greeter's login screen
+    When I log in to a new session
+    Then Tails seems to have booted normally
+    And Tails is running from USB drive "__internal"
+    And persistence is disabled
+    But a Tails persistence partition exists on USB drive "__internal"
+
+  Scenario: Booting Tails from a USB drive with an enabled persistent partition
+    Given I have started Tails without network from a USB drive with a persistent partition enabled and logged in
+    Then Tails is running from USB drive "__internal"
+    And all persistence presets are enabled
+    And all persistent directories have safe access rights
+
+  @fragile
+  Scenario: Writing files first to a read/write-enabled persistent partition, and then to a read-only-enabled persistent partition
+    Given I have started Tails without network from a USB drive with a persistent partition enabled and logged in
+    And the network is plugged
+    And Tor is ready
+    And I take note of which persistence presets are available
+    When I write some files expected to persist
+    And I add a wired DHCP NetworkManager connection called "persistent-con"
+    And I shutdown Tails and wait for the computer to power off
+    # XXX: The next step succeeds (and the --debug output confirms that it's actually looking for the files) but will fail in a subsequent scenario restoring the same snapshot. This exactly what we want, but why does it work? What is guestfs's behaviour when qcow2 internal snapshots are involved?
+    Then only the expected files are present on the persistence partition on USB drive "__internal"
+    Given I start Tails from USB drive "__internal" with network unplugged and I login with read-only persistence enabled
+    And the network is plugged
+    And Tor is ready
+    Then Tails is running from USB drive "__internal"
+    And the boot device has safe access rights
+    And all persistence presets are enabled
+    And I switch to the "persistent-con" NetworkManager connection
+    And there is no GNOME bookmark for the persistent Tor Browser directory
+    And I write some files not expected to persist
+    And I remove some files expected to persist
+    And I take note of which persistence presets are available
+    And I shutdown Tails and wait for the computer to power off
+    Then only the expected files are present on the persistence partition on USB drive "__internal"
+
+  Scenario: Deleting a Tails persistent partition
+    Given I have started Tails without network from a USB drive with a persistent partition and stopped at Tails Greeter's login screen
+    And I log in to a new session
+    Then Tails is running from USB drive "__internal"
+    And the boot device has safe access rights
+    And persistence is disabled
+    But a Tails persistence partition exists on USB drive "__internal"
+    And all notifications have disappeared
+    When I delete the persistent partition
+    Then there is no persistence partition on USB drive "__internal"
diff --git a/features/pidgin.feature b/features/pidgin.feature
index 51d4a77..cbfddbe 100644
--- a/features/pidgin.feature
+++ b/features/pidgin.feature
@@ -1,4 +1,5 @@
- at product
+#10497: wait_until_tor_is_working
+ at product @fragile
 Feature: Chatting anonymously using Pidgin
   As a Tails user
   when I chat using Pidgin
@@ -7,39 +8,100 @@ Feature: Chatting anonymously using Pidgin
   And AppArmor should prevent Pidgin from doing dangerous things
   And all Internet traffic should flow only through Tor
 
-  Background:
-    Given a computer
-    And I capture all network traffic
-    When I start Tails from DVD and I login
-    Then Pidgin has the expected accounts configured with random nicknames
-    And I save the state so the background can be restored next scenario
+  @check_tor_leaks
+  Scenario: Chatting with some friend over XMPP
+    Given I have started Tails from DVD and logged in and the network is connected
+    When I start Pidgin through the GNOME menu
+    Then I see Pidgin's account manager window
+    When I create my XMPP account
+    And I close Pidgin's account manager window
+    Then Pidgin automatically enables my XMPP account
+    Given my XMPP friend goes online
+    When I start a conversation with my friend
+    And I say something to my friend
+    Then I receive a response from my friend
+
+  @check_tor_leaks
+  Scenario: Chatting with some friend over XMPP in a multi-user chat
+    Given I have started Tails from DVD and logged in and the network is connected
+    When I start Pidgin through the GNOME menu
+    Then I see Pidgin's account manager window
+    When I create my XMPP account
+    And I close Pidgin's account manager window
+    Then Pidgin automatically enables my XMPP account
+    When I join some empty multi-user chat
+    And I clear the multi-user chat's scrollback
+    And my XMPP friend goes online and joins the multi-user chat
+    Then I can see that my friend joined the multi-user chat
+    And I say something to my friend in the multi-user chat
+    Then I receive a response from my friend in the multi-user chat
 
+  @check_tor_leaks
+  Scenario: Chatting with some friend over XMPP and with OTR
+    Given I have started Tails from DVD and logged in and the network is connected
+    When I start Pidgin through the GNOME menu
+    Then I see Pidgin's account manager window
+    When I create my XMPP account
+    And I close Pidgin's account manager window
+    Then Pidgin automatically enables my XMPP account
+    Given my XMPP friend goes online
+    When I start a conversation with my friend
+    And I start an OTR session with my friend
+    Then Pidgin automatically generates an OTR key
+    And an OTR session was successfully started with my friend
+    When I say something to my friend
+    Then I receive a response from my friend
+
+  # 10376 - "the Tor Browser loads the (startup page|Tails roadmap)" step is fragile
+  # 10443 - OFTC tests are fragile
+  @check_tor_leaks @fragile
   Scenario: Connecting to the #tails IRC channel with the pre-configured account
+    Given I have started Tails from DVD and logged in and the network is connected
+    And Pidgin has the expected accounts configured with random nicknames
     When I start Pidgin through the GNOME menu
     Then I see Pidgin's account manager window
     When I activate the "irc.oftc.net" Pidgin account
     And I close Pidgin's account manager window
     Then Pidgin successfully connects to the "irc.oftc.net" account
     And I can join the "#tails" channel on "irc.oftc.net"
-    And all Internet traffic has only flowed through Tor
+    When I type "/topic"
+    And I press the "ENTER" key
+    Then I see the Tails roadmap URL
+    When I wait 10 seconds
+    And I click on the Tails roadmap URL
+    Then the Tor Browser has started and loaded the Tails roadmap
+    And the "irc.oftc.net" account only responds to PING and VERSION CTCP requests
 
   Scenario: Adding a certificate to Pidgin
+    Given I have started Tails from DVD and logged in and the network is connected
     And I start Pidgin through the GNOME menu
     And I see Pidgin's account manager window
     And I close Pidgin's account manager window
     Then I can add a certificate from the "/home/amnesia" directory to Pidgin
 
   Scenario: Failing to add a certificate to Pidgin
-    And I start Pidgin through the GNOME menu
+    Given I have started Tails from DVD and logged in and the network is connected
+    When I start Pidgin through the GNOME menu
     And I see Pidgin's account manager window
     And I close Pidgin's account manager window
     Then I cannot add a certificate from the "/home/amnesia/.gnupg" directory to Pidgin
+    When I close Pidgin's certificate import failure dialog
+    And I close Pidgin's certificate manager
+    Then I cannot add a certificate from the "/lib/live/mount/overlay/home/amnesia/.gnupg" directory to Pidgin
+    When I close Pidgin's certificate import failure dialog
+    And I close Pidgin's certificate manager
+    Then I cannot add a certificate from the "/live/overlay/home/amnesia/.gnupg" directory to Pidgin
 
-  @keep_volumes
+  #10443 - OFTC tests are fragile
+  #10720: Tails Installer freezes on Jenkins
+  @check_tor_leaks @fragile
   Scenario: Using a persistent Pidgin configuration
-    Given the USB drive "current" contains Tails with persistence configured and password "asdf"
-    And a computer
-    And I start Tails from USB drive "current" and I login with persistence password "asdf"
+    Given I have started Tails without network from a USB drive with a persistent partition enabled and logged in
+    And Pidgin has the expected accounts configured with random nicknames
+    And the network is plugged
+    And Tor is ready
+    And available upgrades have been checked
+    And all notifications have disappeared
     When I start Pidgin through the GNOME menu
     Then I see Pidgin's account manager window
     # And I generate an OTR key for the default Pidgin account
@@ -47,8 +109,7 @@ Feature: Chatting anonymously using Pidgin
     # And I take note of the OTR key for Pidgin's "irc.oftc.net" account
     And I shutdown Tails and wait for the computer to power off
     Given a computer
-    And I capture all network traffic
-    And I start Tails from USB drive "current" and I login with persistence password "asdf"
+    And I start Tails from USB drive "__internal" and I login with persistence enabled
     And Pidgin has the expected persistent accounts configured
     # And Pidgin has the expected persistent OTR keys
     When I start Pidgin through the GNOME menu
@@ -57,15 +118,18 @@ Feature: Chatting anonymously using Pidgin
     And I close Pidgin's account manager window
     Then Pidgin successfully connects to the "irc.oftc.net" account
     And I can join the "#tails" channel on "irc.oftc.net"
-    And all Internet traffic has only flowed through Tor
     # Exercise Pidgin AppArmor profile with persistence enabled.
     # This should really be in dedicated scenarios, but it would be
     # too costly to set up the virtual USB drive with persistence more
     # than once in this feature.
-    And I cannot add a certificate from the "/home/amnesia/.gnupg" directory to Pidgin
+    Given I start monitoring the AppArmor log of "/usr/bin/pidgin"
+    Then I cannot add a certificate from the "/home/amnesia/.gnupg" directory to Pidgin
+    And AppArmor has denied "/usr/bin/pidgin" from opening "/home/amnesia/.gnupg/test.crt"
     When I close Pidgin's certificate import failure dialog
     And I close Pidgin's certificate manager
+    Given I restart monitoring the AppArmor log of "/usr/bin/pidgin"
     Then I cannot add a certificate from the "/live/persistence/TailsData_unlocked/gnupg" directory to Pidgin
+    And AppArmor has denied "/usr/bin/pidgin" from opening "/live/persistence/TailsData_unlocked/gnupg/test.crt"
     When I close Pidgin's certificate import failure dialog
     And I close Pidgin's certificate manager
     Then I can add a certificate from the "/home/amnesia" directory to Pidgin
diff --git a/features/po.feature b/features/po.feature
new file mode 100644
index 0000000..91b8fd0
--- /dev/null
+++ b/features/po.feature
@@ -0,0 +1,9 @@
+ at source
+Feature: check PO files
+  As a Tails developer, when I build Tails, I want to make sure
+  the PO files in use are correct.
+
+  @doc
+  Scenario: check all PO files
+    Given I am in the Git branch being tested
+    Then all the PO files should be correct
diff --git a/features/root_access_control.feature b/features/root_access_control.feature
index 47aa0bc..569dd2a 100644
--- a/features/root_access_control.feature
+++ b/features/root_access_control.feature
@@ -6,13 +6,6 @@ Feature: Root access control enforcement
   But when I do not set an administration password
   I should not be able to attain administration privileges at all.
 
-  Background:
-    Given a computer
-    And the network is unplugged
-    And I start the computer
-    And the computer boots Tails
-    And I save the state so the background can be restored next scenario
-
   Scenario: If an administrative password is set in Tails Greeter the live user should be able to run arbitrary commands with administrative privileges.
     Given I set sudo password "asdf"
     And I log in to a new session
@@ -20,7 +13,7 @@ Feature: Root access control enforcement
     Then I should be able to run administration commands as the live user
 
   Scenario: If no administrative password is set in Tails Greeter the live user should not be able to run arbitrary commands administrative privileges.
-    Given I log in to a new session
+    Given I have started Tails from DVD without network and logged in
     And Tails Greeter has dealt with the sudo password
     Then I should not be able to run administration commands as the live user with the "" password
     And I should not be able to run administration commands as the live user with the "amnesia" password
@@ -35,8 +28,6 @@ Feature: Root access control enforcement
     Then I should be able to run a command as root with pkexec
 
   Scenario: If no administrative password is set in Tails Greeter the live user should not be able to get administrative privileges through PolicyKit with the standard passwords.
-    Given I log in to a new session
-    And Tails Greeter has dealt with the sudo password
-    And GNOME has started
+    Given I have started Tails from DVD without network and logged in
     And running a command as root with pkexec requires PolicyKit administrator privileges
     Then I should not be able to run a command as root with pkexec and the standard passwords
diff --git a/features/scripts/otr-bot.py b/features/scripts/otr-bot.py
new file mode 100755
index 0000000..0afd15a
--- /dev/null
+++ b/features/scripts/otr-bot.py
@@ -0,0 +1,206 @@
+#!/usr/bin/python
+import sys
+import jabberbot
+import xmpp
+import potr
+import logging
+from argparse import ArgumentParser
+
+class OtrContext(potr.context.Context):
+
+    def __init__(self, account, peer):
+        super(OtrContext, self).__init__(account, peer)
+
+    def getPolicy(self, key):
+        return True
+
+    def inject(self, msg, appdata = None):
+        mess = appdata["base_reply"]
+        mess.setBody(msg)
+        appdata["send_raw_message_fn"](mess)
+
+
+class BotAccount(potr.context.Account):
+
+    def __init__(self, jid, keyFilePath):
+        protocol = 'xmpp'
+        max_message_size = 10*1024
+        super(BotAccount, self).__init__(jid, protocol, max_message_size)
+        self.keyFilePath = keyFilePath
+
+    def loadPrivkey(self):
+        with open(self.keyFilePath, 'rb') as keyFile:
+            return potr.crypt.PK.parsePrivateKey(keyFile.read())[0]
+
+
+class OtrContextManager:
+
+    def __init__(self, jid, keyFilePath):
+        self.account = BotAccount(jid, keyFilePath)
+        self.contexts = {}
+
+    def start_context(self, other):
+        if not other in self.contexts:
+            self.contexts[other] = OtrContext(self.account, other)
+        return self.contexts[other]
+
+    def get_context_for_user(self, other):
+        return self.start_context(other)
+
+
+class OtrBot(jabberbot.JabberBot):
+
+    PING_FREQUENCY = 60
+
+    def __init__(self, account, password, otr_key_path,
+                 connect_server = None, log_file = None):
+        self.__connect_server = connect_server
+        self.__password = password
+        self.__log_file = log_file
+        super(OtrBot, self).__init__(account, password)
+        self.__otr_manager = OtrContextManager(account, otr_key_path)
+        self.send_raw_message_fn = super(OtrBot, self).send_message
+        self.__default_otr_appdata = {
+            "send_raw_message_fn": self.send_raw_message_fn
+            }
+
+    def __otr_appdata_for_mess(self, mess):
+        appdata = self.__default_otr_appdata.copy()
+        appdata["base_reply"] = mess
+        return appdata
+
+    # Unfortunately Jabberbot's connect() is not very friendly to
+    # overriding in subclasses so we have to re-implement it
+    # completely (copy-paste mostly) in order to add support for using
+    # an XMPP "Connect Server".
+    def connect(self):
+        logging.basicConfig(filename = self.__log_file,
+                            level = logging.DEBUG)
+        if not self.conn:
+            conn = xmpp.Client(self.jid.getDomain(), debug=[])
+            if self.__connect_server:
+                try:
+                    conn_server, conn_port = self.__connect_server.split(":", 1)
+                except ValueError:
+                    conn_server = self.__connect_server
+                    conn_port = 5222
+                conres = conn.connect((conn_server, int(conn_port)))
+            else:
+                conres = conn.connect()
+            if not conres:
+                return None
+            authres = conn.auth(self.jid.getNode(), self.__password, self.res)
+            if not authres:
+                return None
+            self.conn = conn
+            self.conn.sendInitPresence()
+            self.roster = self.conn.Roster.getRoster()
+            for (handler, callback) in self.handlers:
+                self.conn.RegisterHandler(handler, callback)
+        return self.conn
+
+    # Wrap OTR encryption around Jabberbot's most low-level method for
+    # sending messages.
+    def send_message(self, mess):
+        body = str(mess.getBody())
+        user = str(mess.getTo().getStripped())
+        otrctx = self.__otr_manager.get_context_for_user(user)
+        if otrctx.state == potr.context.STATE_ENCRYPTED:
+            otrctx.sendMessage(potr.context.FRAGMENT_SEND_ALL, body,
+                               appdata = self.__otr_appdata_for_mess(mess))
+        else:
+            self.send_raw_message_fn(mess)
+
+    # Wrap OTR decryption around Jabberbot's callback mechanism.
+    def callback_message(self, conn, mess):
+        body = str(mess.getBody())
+        user = str(mess.getFrom().getStripped())
+        otrctx = self.__otr_manager.get_context_for_user(user)
+        if mess.getType() == "chat":
+            try:
+                appdata = self.__otr_appdata_for_mess(mess.buildReply())
+                decrypted_body, tlvs = otrctx.receiveMessage(body,
+                                                             appdata = appdata)
+                otrctx.processTLVs(tlvs)
+            except potr.context.NotEncryptedError:
+                otrctx.authStartV2(appdata = appdata)
+                return
+            except (potr.context.UnencryptedMessage, potr.context.NotOTRMessage):
+                decrypted_body = body
+        else:
+            decrypted_body = body
+        if decrypted_body == None:
+            return
+        if mess.getType() == "groupchat":
+            bot_prefix = self.jid.getNode() + ": "
+            if decrypted_body.startswith(bot_prefix):
+                decrypted_body = decrypted_body[len(bot_prefix):]
+            else:
+                return
+        mess.setBody(decrypted_body)
+        super(OtrBot, self).callback_message(conn, mess)
+
+    # Override Jabberbot quitting on keep alive failure.
+    def on_ping_timeout(self):
+        self.__lastping = None
+
+    @jabberbot.botcmd
+    def ping(self, mess, args):
+        """Why not just test it?"""
+        return "pong"
+
+    @jabberbot.botcmd
+    def say(self, mess, args):
+        """Unleash my inner parrot"""
+        return args
+
+    @jabberbot.botcmd
+    def clear_say(self, mess, args):
+        """Make me speak in the clear even if we're in an OTR chat"""
+        self.send_raw_message_fn(mess.buildReply(args))
+        return ""
+
+    @jabberbot.botcmd
+    def start_otr(self, mess, args):
+        """Make me *initiate* (but not refresh) an OTR session"""
+        if mess.getType() == "groupchat":
+            return
+        return "?OTRv2?"
+
+    @jabberbot.botcmd
+    def end_otr(self, mess, args):
+        """Make me gracefully end the OTR session if there is one"""
+        if mess.getType() == "groupchat":
+            return
+        user = str(mess.getFrom().getStripped())
+        self.__otr_manager.get_context_for_user(user).disconnect(appdata =
+            self.__otr_appdata_for_mess(mess.buildReply()))
+        return ""
+
+if __name__ == '__main__':
+    parser = ArgumentParser()
+    parser.add_argument("account",
+                        help = "the user account, given as user at domain")
+    parser.add_argument("password",
+                        help = "the user account's password")
+    parser.add_argument("otr_key_path",
+                        help = "the path to the account's OTR key file")
+    parser.add_argument("-c", "--connect-server", metavar = 'ADDRESS',
+                        help = "use a Connect Server, given as host[:port] " +
+                        "(port defaults to 5222)")
+    parser.add_argument("-j", "--auto-join", nargs = '+', metavar = 'ROOMS',
+                        help = "auto-join multi-user chatrooms on start")
+    parser.add_argument("-l", "--log-file", metavar = 'LOGFILE',
+                        help = "Log to file instead of stderr")
+    args = parser.parse_args()
+    otr_bot_opt_args = dict()
+    if args.connect_server:
+        otr_bot_opt_args["connect_server"] = args.connect_server
+    if args.log_file:
+        otr_bot_opt_args["log_file"] = args.log_file
+    otr_bot = OtrBot(args.account, args.password, args.otr_key_path,
+                     **otr_bot_opt_args)
+    if args.auto_join:
+        for room in args.auto_join:
+            otr_bot.join_room(room)
+    otr_bot.serve_forever()
diff --git a/features/scripts/vm-execute b/features/scripts/vm-execute
new file mode 100755
index 0000000..fc1bf45
--- /dev/null
+++ b/features/scripts/vm-execute
@@ -0,0 +1,52 @@
+#!/usr/bin/env ruby
+
+require 'optparse'
+begin
+  require "#{`git rev-parse --show-toplevel`.chomp}/features/support/helpers/exec_helper.rb"
+rescue LoadError => e
+  raise "This script must be run from within Tails' Git directory."
+end
+$config = Hash.new
+
+def debug_log(*args) ; end
+
+class FakeVM
+  def get_remote_shell_port
+    1337
+  end
+end
+
+cmd_opts = {
+  :spawn => false,
+  :user  => "root"
+}
+
+opt_parser = OptionParser.new do |opts|
+  opts.banner = "Usage: features/scripts/vm-execute [opts] COMMAND"
+  opts.separator ""
+  opts.separator "Runs commands in the VM guest being tested. This script " \
+                    "must be run from within Tails' Git directory."
+  opts.separator ""
+  opts.separator "Options:"
+
+  opts.on("-h", "--help", "Show this message") do
+    puts opts
+    exit
+  end
+
+  opts.on("-u", "--user USER", "Run command as USER") do |user|
+    cmd_opts[:user] = user
+  end
+
+  opts.on("-s", "--spawn",
+          "Run command in non-blocking mode") do |type|
+    cmd_opts[:spawn] = true
+  end
+end
+opt_parser.parse!(ARGV)
+cmd = ARGV.join(" ")
+c = VMCommand.new(FakeVM.new, cmd, cmd_opts)
+puts "Return status: #{c.returncode}"
+puts "STDOUT:\n#{c.stdout}"
+puts "STDERR:\n#{c.stderr}"
+exit c.returncode
diff --git a/features/ssh.feature b/features/ssh.feature
new file mode 100644
index 0000000..8528999
--- /dev/null
+++ b/features/ssh.feature
@@ -0,0 +1,31 @@
+#10497: wait_until_tor_is_working
+#10498: SSH tests are fragile
+ at product @fragile
+Feature: Logging in via SSH
+  As a Tails user
+  When I connect to SSH servers on the Internet
+  all Internet traffic should flow only through Tor
+
+  Background:
+    Given I have started Tails from DVD and logged in and the network is connected
+
+  @check_tor_leaks
+  Scenario: Connecting to an SSH server on the Internet
+    Given I have the SSH key pair for an SSH server
+    When I connect to an SSH server on the Internet
+    And I verify the SSH fingerprint for the SSH server
+    Then I have sucessfully logged into the SSH server
+
+  @check_tor_leaks
+  Scenario: Connecting to an SSH server on the LAN
+    Given I have the SSH key pair for an SSH server
+    And an SSH server is running on the LAN
+    When I connect to an SSH server on the LAN
+    Then I am prompted to verify the SSH fingerprint for the SSH server
+
+  @check_tor_leaks
+  Scenario: Connecting to an SFTP server on the Internet using the GNOME "Connect to Server" feature
+    Given I have the SSH key pair for an SFTP server
+    When I connect to an SFTP server on the Internet
+    And I verify the SSH fingerprint for the SFTP server
+    Then I successfully connect to the SFTP server
diff --git a/features/step_definitions/apt.rb b/features/step_definitions/apt.rb
index fa351c8..c69d259 100644
--- a/features/step_definitions/apt.rb
+++ b/features/step_definitions/apt.rb
@@ -1,9 +1,8 @@
 require 'uri'
 
 Given /^the only hosts in APT sources are "([^"]*)"$/ do |hosts_str|
-  next if @skip_steps_while_restoring_background
   hosts = hosts_str.split(',')
-  @vm.file_content("/etc/apt/sources.list /etc/apt/sources.list.d/*").chomp.each_line { |line|
+  $vm.file_content("/etc/apt/sources.list /etc/apt/sources.list.d/*").chomp.each_line { |line|
     next if ! line.start_with? "deb"
     source_host = URI(line.split[1]).host
     if !hosts.include?(source_host)
@@ -12,69 +11,46 @@ Given /^the only hosts in APT sources are "([^"]*)"$/ do |hosts_str|
   }
 end
 
-When /^I update APT using apt-get$/ do
-  next if @skip_steps_while_restoring_background
-  Timeout::timeout(10*60) do
-    cmd = @vm.execute("echo #{@sudo_password} | " +
-                      "sudo -S apt-get update", $live_user)
-    if !cmd.success?
-      STDERR.puts cmd.stderr
-    end
+When /^I update APT using apt$/ do
+  Timeout::timeout(30*60) do
+    $vm.execute_successfully("echo #{@sudo_password} | " +
+                             "sudo -S apt update", :user => LIVE_USER)
   end
 end
 
-Then /^I should be able to install a package using apt-get$/ do
-  next if @skip_steps_while_restoring_background
+Then /^I should be able to install a package using apt$/ do
   package = "cowsay"
   Timeout::timeout(120) do
-    cmd = @vm.execute("echo #{@sudo_password} | " +
-                      "sudo -S apt-get install #{package}", $live_user)
-    if !cmd.success?
-      STDERR.puts cmd.stderr
-    end
+    $vm.execute_successfully("echo #{@sudo_password} | " +
+                             "sudo -S apt install #{package}",
+                             :user => LIVE_USER)
   end
   step "package \"#{package}\" is installed"
 end
 
 When /^I update APT using Synaptic$/ do
-  next if @skip_steps_while_restoring_background
-  # Upon start the interface will be frozen while Synaptic loads the
-  # package list. Since the frozen GUI is so similar to the unfrozen
-  # one there's no easy way to reliably wait for the latter. Hence we
-  # spam reload until it's performed, which is easier to detect.
-  try_for(60, :msg => "Failed to reload the package list in Synaptic") {
-    @screen.type("r", Sikuli::KeyModifier.CTRL)
-    @screen.find('SynapticReloadPrompt.png')
-  }
+  @screen.click('SynapticReloadButton.png')
+  @screen.wait('SynapticReloadPrompt.png', 20)
   @screen.waitVanish('SynapticReloadPrompt.png', 30*60)
 end
 
 Then /^I should be able to install a package using Synaptic$/ do
-  next if @skip_steps_while_restoring_background
   package = "cowsay"
-  # We do this after a Reload, so the interface will be frozen until
-  # the package list has been loaded
-  try_for(60, :msg => "Failed to open the Synaptic 'Find' window") {
-    @screen.type("f", Sikuli::KeyModifier.CTRL)  # Find key
-    @screen.find('SynapticSearch.png')
-  }
+  try_for(60) do
+    @screen.wait_and_click('SynapticSearchButton.png', 10)
+    @screen.wait_and_click('SynapticSearchWindow.png', 10)
+  end
   @screen.type(package + Sikuli::Key.ENTER)
-  @screen.wait_and_click('SynapticCowsaySearchResult.png', 20)
-  sleep 5
-  @screen.type("i", Sikuli::KeyModifier.CTRL)    # Mark for installation
-  sleep 5
-  @screen.type("p", Sikuli::KeyModifier.CTRL)    # Apply
+  @screen.wait_and_double_click('SynapticCowsaySearchResult.png', 20)
+  @screen.wait_and_click('SynapticApplyButton.png', 10)
   @screen.wait('SynapticApplyPrompt.png', 60)
-  @screen.type("a", Sikuli::KeyModifier.ALT)     # Verify apply
-  @screen.wait('SynapticChangesAppliedPrompt.png', 120)
+  @screen.type(Sikuli::Key.ENTER)
+  @screen.wait('SynapticChangesAppliedPrompt.png', 240)
   step "package \"#{package}\" is installed"
 end
 
 When /^I start Synaptic$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.wait_and_click("GnomeApplicationsMenu.png", 10)
-  @screen.wait_and_click("GnomeApplicationsSystem.png", 10)
-  @screen.wait_and_click("GnomeApplicationsAdministration.png", 10)
-  @screen.wait_and_click("GnomeApplicationsSynaptic.png", 20)
-  deal_with_polkit_prompt('SynapticPolicyKitAuthPrompt.png', @sudo_password)
+  step 'I start "Synaptic" via the GNOME "System" applications menu'
+  deal_with_polkit_prompt('PolicyKitAuthPrompt.png', @sudo_password)
+  @screen.wait('SynapticReloadButton.png', 30)
 end
diff --git a/features/step_definitions/browser.rb b/features/step_definitions/browser.rb
new file mode 100644
index 0000000..84ef1d3
--- /dev/null
+++ b/features/step_definitions/browser.rb
@@ -0,0 +1,195 @@
+Then /^I see the (Unsafe|I2P) Browser start notification and wait for it to close$/ do |browser_type|
+  robust_notification_wait("#{browser_type}BrowserStartNotification.png", 60)
+end
+
+Then /^the (Unsafe|I2P) Browser has started$/ do |browser_type|
+  case browser_type
+  when 'Unsafe'
+    @screen.wait("UnsafeBrowserHomepage.png", 360)
+  when 'I2P'
+    step 'the I2P router console is displayed in I2P Browser'
+  end
+end
+
+When /^I start the (Unsafe|I2P) Browser(?: through the GNOME menu)?$/ do |browser_type|
+  step "I start \"#{browser_type}Browser\" via the GNOME \"Internet\" applications menu"
+end
+
+When /^I successfully start the (Unsafe|I2P) Browser$/ do |browser_type|
+  step "I start the #{browser_type} Browser"
+  step "I see and accept the Unsafe Browser start verification" unless browser_type == 'I2P'
+  step "I see the #{browser_type} Browser start notification and wait for it to close"
+  step "the #{browser_type} Browser has started"
+end
+
+When /^I close the (?:Unsafe|I2P) Browser$/ do
+  @screen.type("q", Sikuli::KeyModifier.CTRL)
+end
+
+Then /^I see the (Unsafe|I2P) Browser stop notification$/ do |browser_type|
+  robust_notification_wait("#{browser_type}BrowserStopNotification.png", 60)
+end
+
+def xul_application_info(application)
+  binary = $vm.execute_successfully(
+    'echo ${TBB_INSTALL}/firefox', :libs => 'tor-browser'
+  ).stdout.chomp
+  address_bar_image = "BrowserAddressBar.png"
+  unused_tbb_libs = ['libnssdbm3.so']
+  case application
+  when "Tor Browser"
+    user = LIVE_USER
+    cmd_regex = "#{binary} .* -profile /home/#{user}/\.tor-browser/profile\.default"
+    chroot = ""
+    new_tab_button_image = "TorBrowserNewTabButton.png"
+  when "Unsafe Browser"
+    user = "clearnet"
+    cmd_regex = "#{binary} .* -profile /home/#{user}/\.unsafe-browser/profile\.default"
+    chroot = "/var/lib/unsafe-browser/chroot"
+    new_tab_button_image = "UnsafeBrowserNewTabButton.png"
+  when "I2P Browser"
+    user = "i2pbrowser"
+    cmd_regex = "#{binary} .* -profile /home/#{user}/\.i2p-browser/profile\.default"
+    chroot = "/var/lib/i2p-browser/chroot"
+    new_tab_button_image = "I2PBrowserNewTabButton.png"
+  when "Tor Launcher"
+    user = "tor-launcher"
+    # We do not enable AppArmor confinement for the Tor Launcher.
+    binary = "#{binary}-unconfined"
+    tor_launcher_install = $vm.execute_successfully(
+      'echo ${TOR_LAUNCHER_INSTALL}', :libs => 'tor-browser'
+    ).stdout.chomp
+    cmd_regex = "#{binary}\s+-app #{tor_launcher_install}/application\.ini.*"
+    chroot = ""
+    new_tab_button_image = nil
+    address_bar_image = nil
+    # The standalone Tor Launcher uses fewer libs than the full
+    # browser.
+    unused_tbb_libs.concat(["libfreebl3.so", "libnssckbi.so", "libsoftokn3.so"])
+  else
+    raise "Invalid browser or XUL application: #{application}"
+  end
+  return {
+    :user => user,
+    :cmd_regex => cmd_regex,
+    :chroot => chroot,
+    :new_tab_button_image => new_tab_button_image,
+    :address_bar_image => address_bar_image,
+    :unused_tbb_libs => unused_tbb_libs,
+  }
+end
+
+When /^I open a new tab in the (.*)$/ do |browser|
+  info = xul_application_info(browser)
+  @screen.click(info[:new_tab_button_image])
+  @screen.wait(info[:address_bar_image], 10)
+end
+
+When /^I open the address "([^"]*)" in the (.*)$/ do |address, browser|
+  step "I open a new tab in the #{browser}"
+  info = xul_application_info(browser)
+  open_address = Proc.new do
+    @screen.click(info[:address_bar_image])
+    # This static here since we have no reliable visual indicators
+    # that we can watch to know when typing is "safe".
+    sleep 5
+    # The browser sometimes loses keypresses when suggestions are
+    # shown, which we work around by pasting the address from the
+    # clipboard, in one go.
+    $vm.set_clipboard(address)
+    @screen.type('v', Sikuli::KeyModifier.CTRL)
+    @screen.type(Sikuli::Key.ENTER)
+  end
+  open_address.call
+  if browser == "Tor Browser"
+    recovery_on_failure = Proc.new do
+      @screen.type(Sikuli::Key.ESC)
+      @screen.waitVanish('BrowserReloadButton.png', 3)
+      open_address.call
+    end
+    retry_tor(recovery_on_failure) do
+      @screen.wait('BrowserReloadButton.png', 120)
+    end
+  end
+end
+
+Then /^the (.*) has no plugins installed$/ do |browser|
+  step "I open the address \"about:plugins\" in the #{browser}"
+  step "I see \"TorBrowserNoPlugins.png\" after at most 30 seconds"
+end
+
+def xul_app_shared_lib_check(pid, chroot, expected_absent_tbb_libs = [])
+  absent_tbb_libs = []
+  unwanted_native_libs = []
+  tbb_libs = $vm.execute_successfully("ls -1 #{chroot}${TBB_INSTALL}/*.so",
+                                      :libs => 'tor-browser').stdout.split
+  firefox_pmap_info = $vm.execute("pmap --show-path #{pid}").stdout
+  for lib in tbb_libs do
+    lib_name = File.basename lib
+    if not /\W#{lib}$/.match firefox_pmap_info
+      absent_tbb_libs << lib_name
+    end
+    native_libs = $vm.execute_successfully(
+                       "find /usr/lib /lib -name \"#{lib_name}\""
+                                           ).stdout.split
+    for native_lib in native_libs do
+      if /\W#{native_lib}$"/.match firefox_pmap_info
+        unwanted_native_libs << lib_name
+      end
+    end
+  end
+  absent_tbb_libs -= expected_absent_tbb_libs
+  assert(absent_tbb_libs.empty? && unwanted_native_libs.empty?,
+         "The loaded shared libraries for the firefox process are not the " +
+         "way we expect them.\n" +
+         "Expected TBB libs that are absent: #{absent_tbb_libs}\n" +
+         "Native libs that we don't want: #{unwanted_native_libs}")
+end
+
+Then /^the (.*) uses all expected TBB shared libraries$/ do |application|
+  info = xul_application_info(application)
+  pid = $vm.execute_successfully("pgrep --uid #{info[:user]} --full --exact '#{info[:cmd_regex]}'").stdout.chomp
+  assert(/\A\d+\z/.match(pid), "It seems like #{application} is not running")
+  xul_app_shared_lib_check(pid, info[:chroot], info[:unused_tbb_libs])
+end
+
+Then /^the (.*) chroot is torn down$/ do |browser|
+  info = xul_application_info(browser)
+  try_for(30, :msg => "The #{browser} chroot '#{info[:chroot]}' was " \
+                      "not removed") do
+    !$vm.execute("test -d '#{info[:chroot]}'").success?
+  end
+end
+
+Then /^the (.*) runs as the expected user$/ do |browser|
+  info = xul_application_info(browser)
+  assert_vmcommand_success($vm.execute(
+    "pgrep --full --exact '#{info[:cmd_regex]}'"),
+    "The #{browser} is not running")
+  assert_vmcommand_success($vm.execute(
+    "pgrep --uid #{info[:user]} --full --exact '#{info[:cmd_regex]}'"),
+    "The #{browser} is not running as the #{info[:user]} user")
+end
+
+When /^I download some file in the Tor Browser$/ do
+  @some_file = 'tails-signing.key'
+  some_url = "https://tails.boum.org/#{@some_file}"
+  step "I open the address \"#{some_url}\" in the Tor Browser"
+end
+
+Then /^I get the browser download dialog$/ do
+  @screen.wait('BrowserDownloadDialog.png', 60)
+  @screen.wait('BrowserDownloadDialogSaveAsButton.png', 10)
+end
+
+When /^I save the file to the default Tor Browser download directory$/ do
+  @screen.click('BrowserDownloadDialogSaveAsButton.png')
+  @screen.wait('BrowserDownloadFileToDialog.png', 10)
+  @screen.type(Sikuli::Key.ENTER)
+end
+
+Then /^the file is saved to the default Tor Browser download directory$/ do
+  assert_not_nil(@some_file)
+  expected_path = "/home/#{LIVE_USER}/Tor Browser/#{@some_file}"
+  try_for(10) { $vm.file_exist?(expected_path) }
+end
diff --git a/features/step_definitions/build.rb b/features/step_definitions/build.rb
index 2e597a4..fd001ff 100644
--- a/features/step_definitions/build.rb
+++ b/features/step_definitions/build.rb
@@ -1,6 +1,8 @@
 Given /^Tails ([[:alnum:].]+) has been released$/ do |version|
   create_git unless git_exists?
 
+  old_branch = current_branch
+
   fatal_system "git checkout --quiet stable"
   old_entries = File.open('debian/changelog') { |f| f.read }
   File.open('debian/changelog', 'w') do |changelog|
@@ -16,6 +18,11 @@ END_OF_CHANGELOG
   end
   fatal_system "git commit --quiet debian/changelog -m 'Release #{version}'"
   fatal_system "git tag '#{version}'"
+
+  if old_branch != 'stable'
+    fatal_system "git checkout --quiet '#{old_branch}'"
+    fatal_system "git merge    --quiet 'stable'"
+  end
 end
 
 Given /^Tails ([[:alnum:].-]+) has been tagged$/ do |version|
@@ -26,7 +33,7 @@ Given /^Tails ([[:alnum:].]+) has not been released yet$/ do |version|
   !File.exists? ".git/refs/tags/#{version}"
 end
 
-Given /^last released version mentioned in debian\/changelog is ([[:alnum:]~.]+)$/ do |version|
+Given /^the last version mentioned in debian\/changelog is ([[:alnum:]~.]+)$/ do |version|
   last = `dpkg-parsechangelog | awk '/^Version: / { print $2 }'`.strip
   raise StandardError.new('dpkg-parsechangelog failed.') if $? != 0
 
@@ -35,37 +42,74 @@ Given /^last released version mentioned in debian\/changelog is ([[:alnum:]~.]+)
   end
 end
 
-Given %r{I am working on the ([[:alnum:]./_-]+) branch$} do |branch|
+Given %r{I am working on the ([[:alnum:]./_-]+) base branch$} do |branch|
   create_git unless git_exists?
 
-  current_branch = `git branch | awk '/^\*/ { print $2 }'`.strip
-  raise StandardError.new('git-branch failed.') if $? != 0
-
   if current_branch != branch
     fatal_system "git checkout --quiet '#{branch}'"
   end
+
+  File.open('config/base_branch', 'w+') do |base_branch_file|
+    base_branch_file.write("#{branch}\n")
+  end
 end
 
 Given %r{I am working on the ([[:alnum:]./_-]+) branch based on ([[:alnum:]./_-]+)$} do |branch, base|
   create_git unless git_exists?
 
-  current_branch = `git branch | awk '/^\*/ { print $2 }'`.strip
-  raise StandardError.new('git-branch failed.') if $? != 0
-
   if current_branch != branch
     fatal_system "git checkout --quiet -b '#{branch}' '#{base}'"
   end
+
+  File.open('config/base_branch', 'w+') do |base_branch_file|
+    base_branch_file.write("#{base}\n")
+  end
 end
 
-When /^I run ([[:alnum:]-]+)$/ do |command|
+When /^I successfully run ([[:alnum:]-]+)$/ do |command|
   @output = `#{File.expand_path("../../../auto/scripts/#{command}", __FILE__)}`
   raise StandardError.new("#{command} failed. Exit code: #{$?}") if $? != 0
 end
 
+When /^I run ([[:alnum:]-]+)$/ do |command|
+  @output = `#{File.expand_path("../../../auto/scripts/#{command}", __FILE__)}`
+  @exit_code = $?.exitstatus
+end
+
 Then /^I should see the ['"]?([[:alnum:].-]+)['"]? suite$/ do |suite|
   @output.should have_suite(suite)
 end
 
+Then /^I should see only the ['"]?([[:alnum:].-]+)['"]? suite$/ do |suite|
+  assert_equal(1, @output.lines.count)
+  @output.should have_suite(suite)
+end
+
 Then /^I should not see the ['"]?([[:alnum:].-]+)['"]? suite$/ do |suite|
   @output.should_not have_suite(suite)
 end
+
+Given(/^the config\/APT_overlays\.d directory is empty$/) do
+  Dir.glob('config/APT_overlays.d/*').empty? \
+  or raise "config/APT_overlays.d/ is not empty"
+end
+
+Given(/^config\/APT_overlays\.d contains ['"]?([[:alnum:].-]+)['"]?$/) do |suite|
+  FileUtils.touch("config/APT_overlays.d/#{suite}")
+end
+
+Then(/^it should fail$/) do
+  assert_not_equal(0, @exit_code)
+end
+
+Given(/^the (config\/base_branch) file does not exist$/) do |file|
+  File.delete(file)
+end
+
+Given(/^the (config\/APT_overlays\.d) directory does not exist$/) do |dir|
+  Dir.rmdir(dir)
+end
+
+Given(/^the config\/base_branch file is empty$/) do
+  File.truncate('config/base_branch', 0)
+end
diff --git a/features/step_definitions/checks.rb b/features/step_definitions/checks.rb
index 76cfe67..423b839 100644
--- a/features/step_definitions/checks.rb
+++ b/features/step_definitions/checks.rb
@@ -1,40 +1,55 @@
-Then /^the shipped Tails signing key is not outdated$/ do
-  # "old" here is w.r.t. the one we fetch from Tails' website
-  next if @skip_steps_while_restoring_background
-  sig_key_fingerprint = "0D24B36AA9A2A651787876451202821CBE2CD9C1"
-  fresh_sig_key = "/tmp/tails-signing.key"
-  tmp_keyring = "/tmp/tmp-keyring.gpg"
-  key_url = "https://tails.boum.org/tails-signing.key"
-  @vm.execute("curl --silent --socks5-hostname localhost:9062 " +
-              "#{key_url} -o #{fresh_sig_key}", $live_user)
-  @vm.execute("gpg --batch --no-default-keyring --keyring #{tmp_keyring} " +
-              "--import #{fresh_sig_key}", $live_user)
-  fresh_sig_key_info =
-    @vm.execute("gpg --batch --no-default-keyring --keyring #{tmp_keyring} " +
-                "--list-key #{sig_key_fingerprint}", $live_user).stdout
-  shipped_sig_key_info = @vm.execute("gpg --batch --list-key #{sig_key_fingerprint}",
-                                     $live_user).stdout
-  assert_equal(fresh_sig_key_info, shipped_sig_key_info,
-         "The Tails signing key shipped inside Tails is outdated:\n" +
-         "Shipped key:\n" +
-         shipped_sig_key_info +
-         "Newly fetched key from #{key_url}:\n" +
-         fresh_sig_key_info)
+def shipped_openpgp_keys
+  shipped_gpg_keys = $vm.execute_successfully('gpg --batch --with-colons --fingerprint --list-key', :user => LIVE_USER).stdout
+  openpgp_fingerprints = shipped_gpg_keys.scan(/^fpr:::::::::([A-Z0-9]+):$/).flatten
+  return openpgp_fingerprints
+end
+
+Then /^the OpenPGP keys shipped with Tails will be valid for the next (\d+) months$/ do |months|
+  invalid = Array.new
+  shipped_openpgp_keys.each do |key|
+    begin
+      step "the shipped OpenPGP key #{key} will be valid for the next #{months} months"
+    rescue Test::Unit::AssertionFailedError
+      invalid << key
+      next
+    end
+  end
+  assert(invalid.empty?, "The following key(s) will not be valid in #{months} months: #{invalid.join(', ')}")
+end
+
+Then /^the shipped (?:Debian repository key|OpenPGP key ([A-Z0-9]+)) will be valid for the next (\d+) months$/ do |fingerprint, max_months|
+  if fingerprint
+    cmd = 'gpg'
+    user = LIVE_USER
+  else
+    fingerprint = TAILS_DEBIAN_REPO_KEY
+    cmd = 'apt-key adv'
+    user = 'root'
+  end
+  shipped_sig_key_info = $vm.execute_successfully("#{cmd} --batch --list-key #{fingerprint}", :user => user).stdout
+  m = /\[expire[ds]: ([0-9-]*)\]/.match(shipped_sig_key_info)
+  if m
+    expiration_date = Date.parse(m[1])
+    assert((expiration_date << max_months.to_i) > DateTime.now,
+           "The shipped key #{fingerprint} will not be valid #{max_months} months from now.")
+  end
+end
+
+Then /^I double-click the Report an Error launcher on the desktop$/ do
+  @screen.wait_and_double_click('DesktopReportAnError.png', 30)
 end
 
 Then /^the live user has been setup by live\-boot$/ do
-  next if @skip_steps_while_restoring_background
-  assert(@vm.execute("test -e /var/lib/live/config/user-setup").success?,
+  assert($vm.execute("test -e /var/lib/live/config/user-setup").success?,
          "live-boot failed its user-setup")
-  actual_username = @vm.execute(". /etc/live/config/username.conf; " +
+  actual_username = $vm.execute(". /etc/live/config/username.conf; " +
                                 "echo $LIVE_USERNAME").stdout.chomp
-  assert_equal($live_user, actual_username)
+  assert_equal(LIVE_USER, actual_username)
 end
 
 Then /^the live user is a member of only its own group and "(.*?)"$/ do |groups|
-  next if @skip_steps_while_restoring_background
-  expected_groups = groups.split(" ") << $live_user
-  actual_groups = @vm.execute("groups #{$live_user}").stdout.chomp.sub(/^#{$live_user} : /, "").split(" ")
+  expected_groups = groups.split(" ") << LIVE_USER
+  actual_groups = $vm.execute("groups #{LIVE_USER}").stdout.chomp.sub(/^#{LIVE_USER} : /, "").split(" ")
   unexpected = actual_groups - expected_groups
   missing = expected_groups - actual_groups
   assert_equal(0, unexpected.size,
@@ -44,26 +59,17 @@ Then /^the live user is a member of only its own group and "(.*?)"$/ do |groups|
 end
 
 Then /^the live user owns its home dir and it has normal permissions$/ do
-  next if @skip_steps_while_restoring_background
-  home = "/home/#{$live_user}"
-  assert(@vm.execute("test -d #{home}").success?,
+  home = "/home/#{LIVE_USER}"
+  assert($vm.execute("test -d #{home}").success?,
          "The live user's home doesn't exist or is not a directory")
-  owner = @vm.execute("stat -c %U:%G #{home}").stdout.chomp
-  perms = @vm.execute("stat -c %a #{home}").stdout.chomp
-  assert_equal("#{$live_user}:#{$live_user}", owner)
+  owner = $vm.execute("stat -c %U:%G #{home}").stdout.chomp
+  perms = $vm.execute("stat -c %a #{home}").stdout.chomp
+  assert_equal("#{LIVE_USER}:#{LIVE_USER}", owner)
   assert_equal("700", perms)
 end
 
-Given /^I wait between (\d+) and (\d+) seconds$/ do |min, max|
-  next if @skip_steps_while_restoring_background
-  time = rand(max.to_i - min.to_i + 1) + min.to_i
-  puts "Slept for #{time} seconds"
-  sleep(time)
-end
-
 Then /^no unexpected services are listening for network connections$/ do
-  next if @skip_steps_while_restoring_background
-  netstat_cmd = @vm.execute("netstat -ltupn")
+  netstat_cmd = $vm.execute("netstat -ltupn")
   assert netstat_cmd.success?
   for line in netstat_cmd.stdout.chomp.split("\n") do
     splitted = line.split(/[[:blank:]]+/)
@@ -79,8 +85,8 @@ Then /^no unexpected services are listening for network connections$/ do
     proc = splitted[proc_index].split("/")[1]
     # Services listening on loopback is not a threat
     if /127(\.[[:digit:]]{1,3}){3}/.match(laddr).nil?
-      if $services_expected_on_all_ifaces.include? [proc, laddr, lport] or
-         $services_expected_on_all_ifaces.include? [proc, laddr, "*"]
+      if SERVICES_EXPECTED_ON_ALL_IFACES.include? [proc, laddr, lport] or
+         SERVICES_EXPECTED_ON_ALL_IFACES.include? [proc, laddr, "*"]
         puts "Service '#{proc}' is listening on #{laddr}:#{lport} " +
              "but has an exception"
       else
@@ -91,53 +97,156 @@ Then /^no unexpected services are listening for network connections$/ do
 end
 
 When /^Tails has booted a 64-bit kernel$/ do
-  next if @skip_steps_while_restoring_background
-  assert(@vm.execute("uname -r | grep -qs 'amd64$'").success?,
+  assert($vm.execute("uname -r | grep -qs 'amd64$'").success?,
          "Tails has not booted a 64-bit kernel.")
 end
 
+Then /^there is no screenshot in the live user's Pictures directory$/ do
+  pictures_directory = "/home/#{LIVE_USER}/Pictures"
+  assert($vm.execute(
+          "find '#{pictures_directory}' -name 'Screenshot*.png' -maxdepth 1"
+        ).stdout.empty?,
+         "Existing screenshots were found in the live user's Pictures directory.")
+end
+
+Then /^a screenshot is saved to the live user's Pictures directory$/ do
+  pictures_directory = "/home/#{LIVE_USER}/Pictures"
+  try_for(10, :msg=> "No screenshot was created in #{pictures_directory}") do
+    !$vm.execute(
+      "find '#{pictures_directory}' -name 'Screenshot*.png' -maxdepth 1"
+    ).stdout.empty?
+  end
+end
+
 Then /^the VirtualBox guest modules are available$/ do
-  next if @skip_steps_while_restoring_background
-  assert(@vm.execute("modinfo vboxguest").success?,
+  assert($vm.execute("modinfo vboxguest").success?,
          "The vboxguest module is not available.")
 end
 
-def shared_pdf_dir_on_guest
-  "/tmp/shared_pdf_dir"
+Given /^I setup a filesystem share containing a sample PDF$/ do
+  shared_pdf_dir_on_host = "#{$config["TMPDIR"]}/shared_pdf_dir"
+  @shared_pdf_dir_on_guest = "/tmp/shared_pdf_dir"
+  FileUtils.mkdir_p(shared_pdf_dir_on_host)
+  Dir.glob("#{MISC_FILES_DIR}/*.pdf") do |pdf_file|
+    FileUtils.cp(pdf_file, shared_pdf_dir_on_host)
+  end
+  add_after_scenario_hook { FileUtils.rm_r(shared_pdf_dir_on_host) }
+  $vm.add_share(shared_pdf_dir_on_host, @shared_pdf_dir_on_guest)
 end
 
-Given /^I setup a filesystem share containing a sample PDF$/ do
-  next if @skip_steps_while_restoring_background
-  @vm.add_share($misc_files_dir, shared_pdf_dir_on_guest)
+Then /^the support documentation page opens in Tor Browser$/ do
+  @screen.wait("SupportDocumentation#{@language}.png", 120)
 end
 
 Then /^MAT can clean some sample PDF file$/ do
-  next if @skip_steps_while_restoring_background
-  for pdf_on_host in Dir.glob("#{$misc_files_dir}/*.pdf") do
+  for pdf_on_host in Dir.glob("#{MISC_FILES_DIR}/*.pdf") do
     pdf_name = File.basename(pdf_on_host)
-    pdf_on_guest = "/home/#{$live_user}/#{pdf_name}"
-    step "I copy \"#{shared_pdf_dir_on_guest}/#{pdf_name}\" to \"#{pdf_on_guest}\" as user \"#{$live_user}\""
-    @vm.execute("mat --display '#{pdf_on_guest}'",
-                $live_user).stdout
-    check_before = @vm.execute("mat --check '#{pdf_on_guest}'",
-                               $live_user).stdout
-    if check_before.include?("#{pdf_on_guest} is clean")
-      STDERR.puts "warning: '#{pdf_on_host}' is already clean so it is a " +
-                  "bad candidate for testing MAT"
-    end
-    @vm.execute("mat '#{pdf_on_guest}'", $live_user)
-    check_after = @vm.execute("mat --check '#{pdf_on_guest}'",
-                              $live_user).stdout
+    pdf_on_guest = "/home/#{LIVE_USER}/#{pdf_name}"
+    step "I copy \"#{@shared_pdf_dir_on_guest}/#{pdf_name}\" to \"#{pdf_on_guest}\" as user \"#{LIVE_USER}\""
+    check_before = $vm.execute_successfully("mat --check '#{pdf_on_guest}'",
+                                            :user => LIVE_USER).stdout
+    assert(check_before.include?("#{pdf_on_guest} is not clean"),
+           "MAT failed to see that '#{pdf_on_host}' is dirty")
+    $vm.execute_successfully("mat '#{pdf_on_guest}'", :user => LIVE_USER)
+    check_after = $vm.execute_successfully("mat --check '#{pdf_on_guest}'",
+                                           :user => LIVE_USER).stdout
     assert(check_after.include?("#{pdf_on_guest} is clean"),
            "MAT failed to clean '#{pdf_on_host}'")
+    $vm.execute_successfully("rm '#{pdf_on_guest}'")
   end
 end
 
 Then /^AppArmor is enabled$/ do
-  assert(@vm.execute("aa-status").success?, "AppArmor is not enabled")
+  assert($vm.execute("aa-status").success?, "AppArmor is not enabled")
 end
 
 Then /^some AppArmor profiles are enforced$/ do
-  assert(@vm.execute("aa-status --enforced").stdout.chomp.to_i > 0,
+  assert($vm.execute("aa-status --enforced").stdout.chomp.to_i > 0,
          "No AppArmor profile is enforced")
 end
+
+def get_seccomp_status(process)
+  assert($vm.has_process?(process), "Process #{process} not running.")
+  pid = $vm.pidof(process)[0]
+  status = $vm.file_content("/proc/#{pid}/status")
+  return status.match(/^Seccomp:\s+([0-9])/)[1].chomp.to_i
+end
+
+def get_apparmor_status(pid)
+  apparmor_status = $vm.file_content("/proc/#{pid}/attr/current").chomp
+  if apparmor_status.include?(')')
+    # matches something like     /usr/sbin/cupsd (enforce)
+    # and only returns what's in the parentheses
+    return apparmor_status.match(/[^\s]+\s+\((.+)\)$/)[1].chomp
+  else
+    return apparmor_status
+  end
+end
+
+Then /^the running process "(.+)" is confined with AppArmor in (complain|enforce) mode$/ do |process, mode|
+  if process == 'i2p'
+    $vm.execute_successfully('service i2p status')
+    pid = $vm.file_content('/run/i2p/i2p.pid').chomp
+  else
+    assert($vm.has_process?(process), "Process #{process} not running.")
+    pid = $vm.pidof(process)[0]
+  end
+  assert_equal(mode, get_apparmor_status(pid))
+end
+
+Then /^the running process "(.+)" is confined with Seccomp in (filter|strict) mode$/ do |process,mode|
+  status = get_seccomp_status(process)
+  if mode == 'strict'
+    assert_equal(1, status, "#{process} not confined with Seccomp in strict mode")
+  elsif mode == 'filter'
+    assert_equal(2, status, "#{process} not confined with Seccomp in filter mode")
+  else
+    raise "Unsupported mode #{mode} passed"
+  end
+end
+
+Then /^tails-debugging-info is not susceptible to symlink attacks$/ do
+  secret_file = '/secret'
+  secret_contents = 'T0P S3Cr1t -- 3yEs oN1y'
+  $vm.file_append(secret_file, secret_contents)
+  $vm.execute_successfully("chmod u=rw,go= #{secret_file}")
+  $vm.execute_successfully("chown root:root #{secret_file}")
+  script_path = '/usr/local/sbin/tails-debugging-info'
+  script_lines = $vm.file_content(script_path).split("\n")
+  script_lines.grep(/^debug_file\s+/).each do |line|
+    _, user, debug_file = line.split
+    # root can always mount symlink attacks
+    next if user == 'root'
+    # Remove quoting around the file
+    debug_file.gsub!(/["']/, '')
+    # Skip files that do not exist, or cannot be removed (e.g. the
+    # ones in /proc).
+    next if not($vm.execute("rm #{debug_file}").success?)
+    # Check what would happen *if* the amnesia user managed to replace
+    # the debugging file with a symlink to the secret.
+    $vm.execute_successfully("ln -s #{secret_file} #{debug_file}")
+    $vm.execute_successfully("chown --no-dereference #{LIVE_USER}:#{LIVE_USER} #{debug_file}")
+    if $vm.execute("sudo /usr/local/sbin/tails-debugging-info | " +
+                   "grep '#{secret_contents}'",
+                   :user => LIVE_USER).success?
+      raise "The secret was leaked by tails-debugging-info via '#{debug_file}'"
+    end
+    # Remove the secret so it cannot possibly interfere with the
+    # following iterations (even though it should not).
+    $vm.execute_successfully("echo > #{debug_file}")
+  end
+end
+
+When /^I disable all networking in the Tails Greeter$/ do
+  begin
+    @screen.click('TailsGreeterDisableAllNetworking.png')
+  rescue FindFailed
+    @screen.type(Sikuli::Key.PAGE_DOWN)
+    @screen.click('TailsGreeterDisableAllNetworking.png')
+  end
+end
+
+Then /^the Tor Status icon tells me that Tor is( not)? usable$/ do |not_usable|
+  picture = not_usable ? 'TorStatusNotUsable' : 'TorStatusUsable'
+  @screen.find("#{picture}.png")
+end
diff --git a/features/step_definitions/common_steps.rb b/features/step_definitions/common_steps.rb
index f173a94..d7097a0 100644
--- a/features/step_definitions/common_steps.rb
+++ b/features/step_definitions/common_steps.rb
@@ -17,29 +17,81 @@ def activate_filesystem_shares
   # "probe of virtio2 failed with error -2" (in dmesg) which makes the
   # shares unavailable. Hence we leave this code commented for now.
   #for mod in ["9pnet_virtio", "9p"] do
-  #  @vm.execute("modprobe #{mod}")
+  #  $vm.execute("modprobe #{mod}")
   #end
 
-  @vm.list_shares.each do |share|
-    @vm.execute("mkdir -p #{share}")
-    @vm.execute("mount -t 9p -o trans=virtio #{share} #{share}")
+  $vm.list_shares.each do |share|
+    $vm.execute("mkdir -p #{share}")
+    $vm.execute("mount -t 9p -o trans=virtio #{share} #{share}")
+  end
+end
+
+def context_menu_helper(top, bottom, menu_item)
+  try_for(60) do
+    t = @screen.wait(top, 10)
+    b = @screen.wait(bottom, 10)
+    # In Sikuli, lower x == closer to the left, lower y == closer to the top
+    assert(t.y < b.y)
+    center = Sikuli::Location.new(((t.x + t.w) + b.x)/2,
+                                  ((t.y + t.h) + b.y)/2)
+    @screen.right_click(center)
+    @screen.hide_cursor
+    @screen.wait_and_click(menu_item, 10)
+    return
   end
 end
 
 def deactivate_filesystem_shares
-  @vm.list_shares.each do |share|
-    @vm.execute("umount #{share}")
+  $vm.list_shares.each do |share|
+    $vm.execute("umount #{share}")
   end
 
   # XXX-9p: See XXX-9p above
   #for mod in ["9p", "9pnet_virtio"] do
-  #  @vm.execute("modprobe -r #{mod}")
+  #  $vm.execute("modprobe -r #{mod}")
   #end
 end
 
-def restore_background
-  @vm.restore_snapshot($background_snapshot)
-  @vm.wait_until_remote_shell_is_up
+# This helper requires that the notification image is the one shown in
+# the notification applet's list, not the notification pop-up.
+def robust_notification_wait(notification_image, time_to_wait)
+  error_msg = "Didn't not manage to open the notification applet"
+  wait_start = Time.now
+  try_for(time_to_wait, :delay => 0, :msg => error_msg) do
+    @screen.hide_cursor
+    @screen.click("GnomeNotificationApplet.png")
+    @screen.wait("GnomeNotificationAppletOpened.png", 10)
+  end
+
+  error_msg = "Didn't not see notification '#{notification_image}'"
+  time_to_wait -= (Time.now - wait_start).ceil
+  try_for(time_to_wait, :delay => 0, :msg => error_msg) do
+    found = false
+    entries = @screen.findAll("GnomeNotificationEntry.png")
+    while(entries.hasNext) do
+      entry = entries.next
+      @screen.hide_cursor
+      @screen.click(entry)
+      close_entry = @screen.wait("GnomeNotificationEntryClose.png", 10)
+      if @screen.exists(notification_image)
+        found = true
+        @screen.click(close_entry)
+        break
+      else
+        @screen.click(entry)
+      end
+    end
+    found
+  end
+
+  # Click anywhere to close the notification applet
+  @screen.hide_cursor
+  @screen.click("GnomeApplicationsMenu.png")
+  @screen.hide_cursor
+end
+
+def post_snapshot_restore_hook
+  $vm.wait_until_remote_shell_is_up
   post_vm_start_hook
 
   # XXX-9p: See XXX-9p above
@@ -49,115 +101,119 @@ def restore_background
   # The guest's Tor's circuits' states are likely to get out of sync
   # with the other relays, so we ensure that we have fresh circuits.
   # Time jumps and incorrect clocks also confuses Tor in many ways.
-  #if @vm.has_network?
-  #  if @vm.execute("service tor status").success?
-  #    @vm.execute("service tor stop")
-  #    @vm.execute("rm -f /var/log/tor/log")
-  #    @vm.execute("killall vidalia")
-  #    @vm.host_to_guest_time_sync
-  #    @vm.execute("service tor start")
+  #if $vm.has_network?
+  #  if $vm.execute("systemctl --quiet is-active tor at default.service").success?
+  #    $vm.execute("systemctl stop tor at default.service")
+  #    $vm.execute("rm -f /var/log/tor/log")
+  #    $vm.execute("systemctl --no-block restart tails-tor-has-bootstrapped.target")
+  #    $vm.host_to_guest_time_sync
+  #    $vm.spawn("restart-tor")
   #    wait_until_tor_is_working
-  #    @vm.spawn("/usr/local/sbin/restart-vidalia")
+  #    if $vm.file_content('/proc/cmdline').include?(' i2p')
+  #      $vm.execute_successfully('/usr/local/sbin/tails-i2p stop')
+  #      # we "killall tails-i2p" to prevent multiple
+  #      # copies of the script from running
+  #      $vm.execute_successfully('killall tails-i2p')
+  #      $vm.spawn('/usr/local/sbin/tails-i2p start')
+  #    end
   #  end
+  #else
+  #  $vm.host_to_guest_time_sync
   #end
 end
 
 Given /^a computer$/ do
-  @vm.destroy if @vm
-  @vm = VM.new($vm_xml_path, $x_display)
+  $vm.destroy_and_undefine if $vm
+  $vm = VM.new($virt, VM_XML_PATH, $vmnet, $vmstorage, DISPLAY)
 end
 
 Given /^the computer has (\d+) ([[:alpha:]]+) of RAM$/ do |size, unit|
-  next if @skip_steps_while_restoring_background
-  @vm.set_ram_size(size, unit)
+  $vm.set_ram_size(size, unit)
 end
 
 Given /^the computer is set to boot from the Tails DVD$/ do
-  next if @skip_steps_while_restoring_background
-  @vm.set_cdrom_boot($tails_iso)
+  $vm.set_cdrom_boot(TAILS_ISO)
 end
 
 Given /^the computer is set to boot from (.+?) drive "(.+?)"$/ do |type, name|
-  next if @skip_steps_while_restoring_background
-  @vm.set_disk_boot(name, type.downcase)
+  $vm.set_disk_boot(name, type.downcase)
 end
 
-Given /^I plug ([[:alpha:]]+) drive "([^"]+)"$/ do |bus, name|
-  next if @skip_steps_while_restoring_background
-  @vm.plug_drive(name, bus.downcase)
-  if @vm.is_running?
+Given /^I (temporarily )?create a (\d+) ([[:alpha:]]+) disk named "([^"]+)"$/ do |temporary, size, unit, name|
+  $vm.storage.create_new_disk(name, {:size => size, :unit => unit,
+                                     :type => "qcow2"})
+  add_after_scenario_hook { $vm.storage.delete_volume(name) } if temporary
+end
+
+Given /^I plug (.+) drive "([^"]+)"$/ do |bus, name|
+  $vm.plug_drive(name, bus.downcase)
+  if $vm.is_running?
     step "drive \"#{name}\" is detected by Tails"
   end
 end
 
 Then /^drive "([^"]+)" is detected by Tails$/ do |name|
-  next if @skip_steps_while_restoring_background
-  if @vm.is_running?
-    try_for(10, :msg => "Drive '#{name}' is not detected by Tails") {
-      @vm.disk_detected?(name)
-    }
-  else
-    STDERR.puts "Cannot tell if drive '#{name}' is detected by Tails: " +
-                "Tails is not running"
+  raise "Tails is not running" unless $vm.is_running?
+  try_for(10, :msg => "Drive '#{name}' is not detected by Tails") do
+    $vm.disk_detected?(name)
   end
 end
 
 Given /^the network is plugged$/ do
-  next if @skip_steps_while_restoring_background
-  @vm.plug_network
+  $vm.plug_network
 end
 
 Given /^the network is unplugged$/ do
-  next if @skip_steps_while_restoring_background
-  @vm.unplug_network
+  $vm.unplug_network
+end
+
+Given /^the hardware clock is set to "([^"]*)"$/ do |time|
+  $vm.set_hardware_clock(DateTime.parse(time).to_time)
 end
 
 Given /^I capture all network traffic$/ do
-  # Note: We don't want skip this particular stpe if
-  # @skip_steps_while_restoring_background is set since it starts
-  # something external to the VM state.
-  @sniffer = Sniffer.new("TestSniffer", @vm.net.bridge_name)
+  @sniffer = Sniffer.new("sniffer", $vmnet)
   @sniffer.capture
+  add_after_scenario_hook do
+    @sniffer.stop
+    @sniffer.clear
+  end
 end
 
 Given /^I set Tails to boot with options "([^"]*)"$/ do |options|
-  next if @skip_steps_while_restoring_background
   @boot_options = options
 end
 
 When /^I start the computer$/ do
-  next if @skip_steps_while_restoring_background
-  assert(!@vm.is_running?,
+  assert(!$vm.is_running?,
          "Trying to start a VM that is already running")
-  @vm.start
+  $vm.start
   post_vm_start_hook
 end
 
-Given /^I start Tails from DVD(| with network unplugged) and I login$/ do |network_unplugged|
-  # we don't @skip_steps_while_restoring_background as we're only running
-  # other steps, that are taking care of it *if* they have to
-  step "the computer is set to boot from the Tails DVD"
-  if network_unplugged.empty?
+Given /^I start Tails( from DVD)?( with network unplugged)?( and I login)?$/ do |dvd_boot, network_unplugged, do_login|
+  step "the computer is set to boot from the Tails DVD" if dvd_boot
+  if network_unplugged.nil?
     step "the network is plugged"
   else
     step "the network is unplugged"
   end
   step "I start the computer"
   step "the computer boots Tails"
-  step "I log in to a new session"
-  step "Tails seems to have booted normally"
-  if network_unplugged.empty?
-    step "Tor is ready"
-    step "all notifications have disappeared"
-    step "available upgrades have been checked"
-  else
-    step "all notifications have disappeared"
+  if do_login
+    step "I log in to a new session"
+    step "Tails seems to have booted normally"
+    if network_unplugged.nil?
+      step "Tor is ready"
+      step "all notifications have disappeared"
+      step "available upgrades have been checked"
+    else
+      step "all notifications have disappeared"
+    end
   end
 end
 
-Given /^I start Tails from (.+?) drive "(.+?)"(| with network unplugged) and I login(| with(| read-only) persistence password "([^"]+)")$/ do |drive_type, drive_name, network_unplugged, persistence_on, persistence_ro, persistence_pwd|
-  # we don't @skip_steps_while_restoring_background as we're only running
-  # other steps, that are taking care of it *if* they have to
+Given /^I start Tails from (.+?) drive "(.+?)"(| with network unplugged)( and I login(| with(| read-only) persistence enabled))?$/ do |drive_type, drive_name, network_unplugged, do_login, persistence_on, persistence_ro|
   step "the computer is set to boot from #{drive_type} drive \"#{drive_name}\""
   if network_unplugged.empty?
     step "the network is plugged"
@@ -166,52 +222,57 @@ Given /^I start Tails from (.+?) drive "(.+?)"(| with network unplugged) and I l
   end
   step "I start the computer"
   step "the computer boots Tails"
-  if ! persistence_on.empty?
-    assert(! persistence_pwd.empty?, "A password must be provided when enabling persistence")
-    if persistence_ro.empty?
-      step "I enable persistence with password \"#{persistence_pwd}\""
+  if do_login
+    if ! persistence_on.empty?
+      if persistence_ro.empty?
+        step "I enable persistence"
+      else
+        step "I enable read-only persistence"
+      end
+    end
+    step "I log in to a new session"
+    step "Tails seems to have booted normally"
+    if network_unplugged.empty?
+      step "Tor is ready"
+      step "all notifications have disappeared"
+      step "available upgrades have been checked"
     else
-      step "I enable read-only persistence with password \"#{persistence_pwd}\""
+      step "all notifications have disappeared"
     end
   end
-  step "I log in to a new session"
-  step "Tails seems to have booted normally"
-  if network_unplugged.empty?
-    step "Tor is ready"
-    step "all notifications have disappeared"
-    step "available upgrades have been checked"
-  else
-    step "all notifications have disappeared"
-  end
 end
 
 When /^I power off the computer$/ do
-  next if @skip_steps_while_restoring_background
-  assert(@vm.is_running?,
+  assert($vm.is_running?,
          "Trying to power off an already powered off VM")
-  @vm.power_off
+  $vm.power_off
 end
 
 When /^I cold reboot the computer$/ do
-  next if @skip_steps_while_restoring_background
   step "I power off the computer"
   step "I start the computer"
 end
 
 When /^I destroy the computer$/ do
-  next if @skip_steps_while_restoring_background
-  @vm.destroy
+  $vm.destroy_and_undefine
 end
 
 Given /^the computer (re)?boots DebianLive(|\d+)$/ do |reboot,version|
   next if @skip_steps_while_restoring_background
 
+def bootsplash
   case @os_loader
   when "UEFI"
-    assert(!reboot, "Testing of reboot with UEFI enabled is not implemented")
-    bootsplash = 'TailsBootSplashUEFI.png'
-    bootsplash_tab_msg = 'TailsBootSplashTabMsgUEFI.png'
-    boot_timeout = 30
+    'TailsBootSplashUEFI.png'
+  else
+    'TailsBootSplash.png'
+  end
+end
+
+def bootsplash_tab_msg
+  case @os_loader
+  when "UEFI"
+    'TailsBootSplashTabMsgUEFI.png'
   else
     if reboot
       bootsplash = 'TailsBootSplashPostReset.png'
@@ -223,22 +284,38 @@ Given /^the computer (re)?boots DebianLive(|\d+)$/ do |reboot,version|
       boot_timeout = 30
     end
   end
+end
+
+Given /^the computer (re)?boots Tails$/ do |reboot|
+
+  boot_timeout = 30
+  # We need some extra time for memory wiping if rebooting
+  boot_timeout += 90 if reboot
 
   @screen.wait(bootsplash, boot_timeout)
   @screen.wait(bootsplash_tab_msg, 10)
   @screen.type(Sikuli::Key.TAB)
   @screen.waitVanish(bootsplash_tab_msg, 1)
 
-  @screen.type(" autotest_never_use_this_option #{@boot_options}" +
+  @screen.type(" autotest_never_use_this_option blacklist=psmouse #{@boot_options}" +
                Sikuli::Key.ENTER)
   @screen.wait("DebianLive#{version}Greeter.png", 5*60)
   @vm.wait_until_remote_shell_is_up
   activate_filesystem_shares
 end
 
-Given /^I log in to a new session$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.wait_and_click('TailsGreeterLoginButton.png', 10)
+Given /^I log in to a new session(?: in )?(|German)$/ do |lang|
+  case lang
+  when 'German'
+    @language = "German"
+    @screen.wait_and_click('TailsGreeterLanguage.png', 10)
+    @screen.wait_and_click("TailsGreeterLanguage#{@language}.png", 10)
+    @screen.wait_and_click("TailsGreeterLoginButton#{@language}.png", 10)
+  when ''
+    @screen.wait_and_click('TailsGreeterLoginButton.png', 10)
+  else
+    raise "Unsupported language: #{lang}"
+  end
 end
 
 Given /^I set sudo password "([^"]*)"$/ do |password|
@@ -251,86 +328,83 @@ Given /^I set sudo password "([^"]*)"$/ do |password|
 end
 
 Given /^Tails Greeter has dealt with the sudo password$/ do
-  next if @skip_steps_while_restoring_background
   f1 = "/etc/sudoers.d/tails-greeter"
   f2 = "#{f1}-no-password-lecture"
   try_for(20) {
-    @vm.execute("test -e '#{f1}' -o -e '#{f2}'").success?
+    $vm.execute("test -e '#{f1}' -o -e '#{f2}'").success?
   }
 end
 
-Given /^GNOME has started$/ do
-  next if @skip_steps_while_restoring_background
-  case @theme
-  when "windows"
-    desktop_started_picture = 'WindowsStartButton.png'
-  else
-    desktop_started_picture = 'GnomeApplicationsMenu.png'
-  end
+Given /^the Tails desktop is ready$/ do
+  desktop_started_picture = "GnomeApplicationsMenu#{@language}.png"
+  # We wait for the Florence icon to be displayed to ensure reliable systray icon clicking.
+  @screen.wait("GnomeSystrayFlorence.png", 180)
   @screen.wait(desktop_started_picture, 180)
+  # Disable screen blanking since we sometimes need to wait long
+  # enough for it to activate, which can mess with Sikuli wait():ing
+  # for some image.
+  $vm.execute_successfully(
+    'gsettings set org.gnome.desktop.session idle-delay 0',
+    :user => LIVE_USER
+  )
 end
 
 Then /^Tails seems to have booted normally$/ do
-  next if @skip_steps_while_restoring_background
-  step "GNOME has started"
+  step "the Tails desktop is ready"
 end
 
-Given /^Tor is ready$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.wait("GnomeTorIsReady.png", 300)
-  @screen.waitVanish("GnomeTorIsReady.png", 15)
+When /^I see the 'Tor is ready' notification$/ do
+  robust_notification_wait('TorIsReadyNotification.png', 300)
+end
 
-  # Having seen the "Tor is ready" notification implies that Tor has
-  # built a circuit, but let's check it directly to be on the safe side.
+Given /^Tor is ready$/ do
   step "Tor has built a circuit"
-
   step "the time has synced"
+  if $vm.execute('systemctl is-system-running').failure?
+    units_status = $vm.execute('systemctl').stdout
+    raise "At least one system service failed to start:\n#{units_status}"
+  end
 end
 
 Given /^Tor has built a circuit$/ do
-  next if @skip_steps_while_restoring_background
   wait_until_tor_is_working
 end
 
 Given /^the time has synced$/ do
-  next if @skip_steps_while_restoring_background
   ["/var/run/tordate/done", "/var/run/htpdate/success"].each do |file|
-    try_for(300) { @vm.execute("test -e #{file}").success? }
+    try_for(300) { $vm.execute("test -e #{file}").success? }
   end
 end
 
 Given /^available upgrades have been checked$/ do
-  next if @skip_steps_while_restoring_background
   try_for(300) {
-    @vm.execute("test -e '/var/run/tails-upgrader/checked_upgrades'").success?
+    $vm.execute("test -e '/var/run/tails-upgrader/checked_upgrades'").success?
   }
 end
 
 Given /^the Tor Browser has started$/ do
-  next if @skip_steps_while_restoring_background
-  case @theme
-  when "windows"
-    tor_browser_picture = "WindowsTorBrowserWindow.png"
-  else
-    tor_browser_picture = "TorBrowserWindow.png"
-  end
-
+  tor_browser_picture = "TorBrowserWindow.png"
   @screen.wait(tor_browser_picture, 60)
 end
 
-Given /^the Tor Browser has started and loaded the startup page$/ do
-  next if @skip_steps_while_restoring_background
+Given /^the Tor Browser (?:has started and )?load(?:ed|s) the (startup page|Tails roadmap)$/ do |page|
+  case page
+  when "startup page"
+    picture = "TorBrowserStartupPage.png"
+  when "Tails roadmap"
+    picture = "TorBrowserTailsRoadmap.png"
+  else
+    raise "Unsupported page: #{page}"
+  end
   step "the Tor Browser has started"
-  @screen.wait("TorBrowserStartupPage.png", 120)
+  @screen.wait(picture, 120)
 end
 
 Given /^the Tor Browser has started in offline mode$/ do
-  next if @skip_steps_while_restoring_background
   @screen.wait("TorBrowserOffline.png", 60)
 end
 
 Given /^I add a bookmark to eff.org in the Tor Browser$/ do
-  next if @skip_steps_while_restoring_background
   url = "https://www.eff.org"
   step "I open the address \"#{url}\" in the Tor Browser"
   @screen.wait("TorBrowserOffline.png", 5)
@@ -340,271 +414,150 @@ Given /^I add a bookmark to eff.org in the Tor Browser$/ do
 end
 
 Given /^the Tor Browser has a bookmark to eff.org$/ do
-  next if @skip_steps_while_restoring_background
   @screen.type("b", Sikuli::KeyModifier.ALT)
   @screen.wait("TorBrowserEFFBookmark.png", 10)
 end
 
 Given /^all notifications have disappeared$/ do
-  next if @skip_steps_while_restoring_background
-  case @theme
-  when "windows"
-    notification_picture = "WindowsNotificationX.png"
-  else
-    notification_picture = "GnomeNotificationX.png"
-  end
-  @screen.waitVanish(notification_picture, 60)
-end
-
-Given /^I save the state so the background can be restored next scenario$/ do
-  if @skip_steps_while_restoring_background
-    assert(File.size?($background_snapshot),
-           "We have been skipping steps but there is no snapshot to restore")
-  else
-    # To be sure we run the feature from scratch we remove any
-    # leftover snapshot that wasn't removed.
-    if File.exist?($background_snapshot)
-      File.delete($background_snapshot)
+  next if not(@screen.exists("GnomeNotificationApplet.png"))
+  @screen.click("GnomeNotificationApplet.png")
+  @screen.wait("GnomeNotificationAppletOpened.png", 10)
+  begin
+    entries = @screen.findAll("GnomeNotificationEntry.png")
+    while(entries.hasNext) do
+      entry = entries.next
+      @screen.hide_cursor
+      @screen.click(entry)
+      @screen.wait_and_click("GnomeNotificationEntryClose.png", 10)
     end
-    # Workaround: when libvirt takes ownership of the snapshot it may
-    # become unwritable for the user running this script so it cannot
-    # be removed during clean up.
-    FileUtils.touch($background_snapshot)
-    FileUtils.chmod(0666, $background_snapshot)
-
-    # Snapshots cannot be saved while filesystem shares are mounted
-    # XXX-9p: See XXX-9p above.
-    #deactivate_filesystem_shares
-
-    @vm.save_snapshot($background_snapshot)
+  rescue FindFailed
+    # No notifications, so we're good to go.
   end
-  restore_background
-  # Now we stop skipping steps from the snapshot restore.
-  @skip_steps_while_restoring_background = false
-end
-
-Then /^I see "([^"]*)" after at most (\d+) seconds$/ do |image, time|
-  next if @skip_steps_while_restoring_background
-  @screen.wait(image, time.to_i)
+  @screen.hide_cursor
+  # Click anywhere to close the notification applet
+  @screen.click("GnomeApplicationsMenu.png")
+  @screen.hide_cursor
 end
 
-Then /^all Internet traffic has only flowed through Tor$/ do
-  next if @skip_steps_while_restoring_background
-  leaks = FirewallLeakCheck.new(@sniffer.pcap_file, get_tor_relays)
-  if !leaks.empty?
-    if !leaks.ipv4_tcp_leaks.empty?
-      puts "The following IPv4 TCP non-Tor Internet hosts were contacted:"
-      puts leaks.ipv4_tcp_leaks.join("\n")
-      puts
-    end
-    if !leaks.ipv4_nontcp_leaks.empty?
-      puts "The following IPv4 non-TCP Internet hosts were contacted:"
-      puts leaks.ipv4_nontcp_leaks.join("\n")
-      puts
-    end
-    if !leaks.ipv6_leaks.empty?
-      puts "The following IPv6 Internet hosts were contacted:"
-      puts leaks.ipv6_leaks.join("\n")
-      puts
-    end
-    if !leaks.nonip_leaks.empty?
-      puts "Some non-IP packets were sent\n"
-    end
-    save_pcap_file
-    raise "There were network leaks!"
+Then /^I (do not )?see "([^"]*)" after at most (\d+) seconds$/ do |negation, image, time|
+  begin
+    @screen.wait(image, time.to_i)
+    raise "found '#{image}' while expecting not to" if negation
+  rescue FindFailed => e
+    raise e if not(negation)
   end
 end
 
-Given /^I enter the sudo password in the gksu prompt$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.wait('GksuAuthPrompt.png', 60)
-  sleep 1 # wait for weird fade-in to unblock the "Ok" button
-  @screen.type(@sudo_password)
-  @screen.type(Sikuli::Key.ENTER)
-  @screen.waitVanish('GksuAuthPrompt.png', 10)
+Then /^all Internet traffic has only flowed through Tor$/ do
+  leaks = FirewallLeakCheck.new(@sniffer.pcap_file,
+                                :accepted_hosts => get_all_tor_nodes)
+  leaks.assert_no_leaks
 end
 
 Given /^I enter the sudo password in the pkexec prompt$/ do
-  next if @skip_steps_while_restoring_background
   step "I enter the \"#{@sudo_password}\" password in the pkexec prompt"
 end
 
 def deal_with_polkit_prompt (image, password)
   @screen.wait(image, 60)
-  sleep 1 # wait for weird fade-in to unblock the "Ok" button
   @screen.type(password)
   @screen.type(Sikuli::Key.ENTER)
   @screen.waitVanish(image, 10)
 end
 
 Given /^I enter the "([^"]*)" password in the pkexec prompt$/ do |password|
-  next if @skip_steps_while_restoring_background
   deal_with_polkit_prompt('PolicyKitAuthPrompt.png', password)
 end
 
-Given /^process "([^"]+)" is running$/ do |process|
-  next if @skip_steps_while_restoring_background
-  assert(@vm.has_process?(process),
-         "Process '#{process}' is not running")
+Given /^process "([^"]+)" is (not )?running$/ do |process, not_running|
+  if not_running
+    assert(!$vm.has_process?(process), "Process '#{process}' is running")
+  else
+    assert($vm.has_process?(process), "Process '#{process}' is not running")
+  end
 end
 
 Given /^process "([^"]+)" is running within (\d+) seconds$/ do |process, time|
-  next if @skip_steps_while_restoring_background
   try_for(time.to_i, :msg => "Process '#{process}' is not running after " +
                              "waiting for #{time} seconds") do
-    @vm.has_process?(process)
+    $vm.has_process?(process)
   end
 end
 
-Given /^process "([^"]+)" is not running$/ do |process|
-  next if @skip_steps_while_restoring_background
-  assert(!@vm.has_process?(process),
-         "Process '#{process}' is running")
+Given /^process "([^"]+)" has stopped running after at most (\d+) seconds$/ do |process, time|
+  try_for(time.to_i, :msg => "Process '#{process}' is still running after " +
+                             "waiting for #{time} seconds") do
+    not $vm.has_process?(process)
+  end
 end
 
 Given /^I kill the process "([^"]+)"$/ do |process|
-  next if @skip_steps_while_restoring_background
-  @vm.execute("killall #{process}")
+  $vm.execute("killall #{process}")
   try_for(10, :msg => "Process '#{process}' could not be killed") {
-    !@vm.has_process?(process)
+    !$vm.has_process?(process)
   }
 end
 
 Then /^Tails eventually shuts down$/ do
-  next if @skip_steps_while_restoring_background
-  nr_gibs_of_ram = (detected_ram_in_MiB.to_f/(2**10)).ceil
+  nr_gibs_of_ram = convert_from_bytes($vm.get_ram_size_in_bytes, 'GiB').ceil
   timeout = nr_gibs_of_ram*5*60
   try_for(timeout, :msg => "VM is still running after #{timeout} seconds") do
-    ! @vm.is_running?
+    ! $vm.is_running?
   end
 end
 
 Then /^Tails eventually restarts$/ do
-  next if @skip_steps_while_restoring_background
-  nr_gibs_of_ram = (detected_ram_in_MiB.to_f/(2**10)).ceil
-  @screen.wait('TailsBootSplashPostReset.png', nr_gibs_of_ram*5*60)
+  nr_gibs_of_ram = convert_from_bytes($vm.get_ram_size_in_bytes, 'GiB').ceil
+  @screen.wait('TailsBootSplash.png', nr_gibs_of_ram*5*60)
 end
 
 Given /^I shutdown Tails and wait for the computer to power off$/ do
-  next if @skip_steps_while_restoring_background
-  @vm.execute("poweroff")
+  $vm.spawn("poweroff")
   step 'Tails eventually shuts down'
 end
 
 When /^I request a shutdown using the emergency shutdown applet$/ do
-  next if @skip_steps_while_restoring_background
   @screen.hide_cursor
   @screen.wait_and_click('TailsEmergencyShutdownButton.png', 10)
-  @screen.hide_cursor
   @screen.wait_and_click('TailsEmergencyShutdownHalt.png', 10)
 end
 
 When /^I warm reboot the computer$/ do
-  next if @skip_steps_while_restoring_background
-  @vm.execute("reboot")
+  $vm.spawn("reboot")
 end
 
 When /^I request a reboot using the emergency shutdown applet$/ do
-  next if @skip_steps_while_restoring_background
   @screen.hide_cursor
   @screen.wait_and_click('TailsEmergencyShutdownButton.png', 10)
-  @screen.hide_cursor
   @screen.wait_and_click('TailsEmergencyShutdownReboot.png', 10)
 end
 
 Given /^package "([^"]+)" is installed$/ do |package|
-  next if @skip_steps_while_restoring_background
-  assert(@vm.execute("dpkg -s '#{package}' 2>/dev/null | grep -qs '^Status:.*installed$'").success?,
+  assert($vm.execute("dpkg -s '#{package}' 2>/dev/null | grep -qs '^Status:.*installed$'").success?,
          "Package '#{package}' is not installed")
 end
 
 When /^I start the Tor Browser$/ do
-  next if @skip_steps_while_restoring_background
-  case @theme
-  when "windows"
-    step 'I click the start menu'
-    @screen.wait_and_click("WindowsApplicationsInternet.png", 10)
-    @screen.wait_and_click("WindowsApplicationsTorBrowser.png", 10)
-  else
-    @screen.wait_and_click("GnomeApplicationsMenu.png", 10)
-    @screen.wait_and_click("GnomeApplicationsInternet.png", 10)
-    @screen.wait_and_click("GnomeApplicationsTorBrowser.png", 10)
-  end
+  step 'I start "TorBrowser" via the GNOME "Internet" applications menu'
 end
 
-When /^I start the Tor Browser in offline mode$/ do
-  next if @skip_steps_while_restoring_background
-  step "I start the Tor Browser"
-  case @theme
-  when "windows"
-    @screen.wait_and_click("WindowsTorBrowserOfflinePrompt.png", 10)
-    @screen.click("WindowsTorBrowserOfflinePromptStart.png")
-  else
-    @screen.wait_and_click("TorBrowserOfflinePrompt.png", 10)
-    @screen.click("TorBrowserOfflinePromptStart.png")
-  end
-end
-
-def xul_app_shared_lib_check(pid, chroot)
-  expected_absent_tbb_libs = ['libnssdbm3.so']
-  absent_tbb_libs = []
-  unwanted_native_libs = []
-  tbb_libs = @vm.execute_successfully(
-                 ". /usr/local/lib/tails-shell-library/tor-browser.sh; " +
-                 "ls -1 #{chroot}${TBB_INSTALL}/Browser/*.so"
-                                      ).stdout.split
-  firefox_pmap_info = @vm.execute("pmap #{pid}").stdout
-  for lib in tbb_libs do
-    lib_name = File.basename lib
-    if not /\W#{lib}$/.match firefox_pmap_info
-      absent_tbb_libs << lib_name
-    end
-    native_libs = @vm.execute_successfully(
-                       "find /usr/lib /lib -name \"#{lib_name}\""
-                                           ).stdout.split
-    for native_lib in native_libs do
-      if /\W#{native_lib}$"/.match firefox_pmap_info
-        unwanted_native_libs << lib_name
-      end
-    end
-  end
-  absent_tbb_libs -= expected_absent_tbb_libs
-  assert(absent_tbb_libs.empty? && unwanted_native_libs.empty?,
-         "The loaded shared libraries for the firefox process are not the " +
-         "way we expect them.\n" +
-         "Expected TBB libs that are absent: #{absent_tbb_libs}\n" +
-         "Native libs that we don't want: #{unwanted_native_libs}")
+When /^I request a new identity using Torbutton$/ do
+  @screen.wait_and_click('TorButtonIcon.png', 30)
+  @screen.wait_and_click('TorButtonNewIdentity.png', 30)
 end
 
-Then /^(.*) uses all expected TBB shared libraries$/ do |application|
-  next if @skip_steps_while_restoring_background
-  binary = @vm.execute_successfully(
-                '. /usr/local/lib/tails-shell-library/tor-browser.sh; ' +
-                'echo ${TBB_INSTALL}/Browser/firefox'
-                                    ).stdout.chomp
-  case application
-  when "the Tor Browser"
-    user = $live_user
-    cmd_regex = "#{binary} .* -profile /home/#{user}/\.tor-browser/profile\.default"
-    chroot = ""
-  when "the Unsafe Browser"
-    user = "clearnet"
-    cmd_regex = "#{binary} .* -profile /home/#{user}/\.tor-browser/profile\.default"
-    chroot = "/var/lib/unsafe-browser/chroot"
-  when "Tor Launcher"
-    user = "tor-launcher"
-    cmd_regex = "#{binary} -app /home/#{user}/\.tor-launcher/tor-launcher-standalone/application\.ini"
-    chroot = ""
-  else
-    raise "Invalid browser or XUL application: #{application}"
-  end
-  pid = @vm.execute_successfully("pgrep --uid #{user} --full --exact '#{cmd_regex}'").stdout.chomp
-  assert(/\A\d+\z/.match(pid), "It seems like #{application} is not running")
-  xul_app_shared_lib_check(pid, chroot)
+When /^I acknowledge Torbutton's New Identity confirmation prompt$/ do
+  @screen.wait('GnomeQuestionDialogIcon.png', 30)
+  step 'I type "y"'
+end
+
+When /^I start the Tor Browser in offline mode$/ do
+  step "I start the Tor Browser"
+  @screen.wait_and_click("TorBrowserOfflinePrompt.png", 10)
+  @screen.click("TorBrowserOfflinePromptStart.png")
 end
 
 Given /^I add a wired DHCP NetworkManager connection called "([^"]+)"$/ do |con_name|
-  next if @skip_steps_while_restoring_background
   con_content = <<EOF
 [802-3-ethernet]
 duplex=full
@@ -622,59 +575,369 @@ method=auto
 method=auto
 EOF
   con_content.split("\n").each do |line|
-    @vm.execute("echo '#{line}' >> /tmp/NM.#{con_name}")
+    $vm.execute("echo '#{line}' >> /tmp/NM.#{con_name}")
   end
-  @vm.execute("install -m 0600 '/tmp/NM.#{con_name}' '/etc/NetworkManager/system-connections/#{con_name}'")
+  con_file = "/etc/NetworkManager/system-connections/#{con_name}"
+  $vm.execute("install -m 0600 '/tmp/NM.#{con_name}' '#{con_file}'")
+  $vm.execute_successfully("nmcli connection load '#{con_file}'")
   try_for(10) {
-    nm_con_list = @vm.execute("nmcli --terse --fields NAME con list").stdout
+    nm_con_list = $vm.execute("nmcli --terse --fields NAME connection show").stdout
     nm_con_list.split("\n").include? "#{con_name}"
   }
 end
 
 Given /^I switch to the "([^"]+)" NetworkManager connection$/ do |con_name|
-  next if @skip_steps_while_restoring_background
-  @vm.execute("nmcli con up id #{con_name}")
-  try_for(60) {
-    @vm.execute("nmcli --terse --fields NAME,STATE con status").stdout.chomp == "#{con_name}:activated"
-  }
+  $vm.execute("nmcli connection up id #{con_name}")
+  try_for(60) do
+    $vm.execute("nmcli --terse --fields NAME,STATE connection show").stdout.chomp.split("\n").include?("#{con_name}:activated")
+  end
 end
 
 When /^I start and focus GNOME Terminal$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.wait_and_click("GnomeApplicationsMenu.png", 10)
-  @screen.wait_and_click("GnomeApplicationsAccessories.png", 10)
-  @screen.wait_and_click("GnomeApplicationsTerminal.png", 20)
-  @screen.wait_and_click('GnomeTerminalWindow.png', 20)
+  step 'I start "Terminal" via the GNOME "Utilities" applications menu'
+  @screen.wait('GnomeTerminalWindow.png', 20)
 end
 
 When /^I run "([^"]+)" in GNOME Terminal$/ do |command|
-  next if @skip_steps_while_restoring_background
-  step "I start and focus GNOME Terminal"
+  if !$vm.has_process?("gnome-terminal-server")
+    step "I start and focus GNOME Terminal"
+  else
+    @screen.wait_and_click('GnomeTerminalWindow.png', 20)
+  end
   @screen.type(command + Sikuli::Key.ENTER)
 end
 
-When /^the file "([^"]+)" exists$/ do |file|
-  next if @skip_steps_while_restoring_background
-  assert(@vm.file_exist?(file))
+When /^the file "([^"]+)" exists(?:| after at most (\d+) seconds)$/ do |file, timeout|
+  timeout = 0 if timeout.nil?
+  try_for(
+    timeout.to_i,
+    :msg => "The file #{file} does not exist after #{timeout} seconds"
+  ) {
+    $vm.file_exist?(file)
+  }
+end
+
+When /^the file "([^"]+)" does not exist$/ do |file|
+  assert(! ($vm.file_exist?(file)))
+end
+
+When /^the directory "([^"]+)" exists$/ do |directory|
+  assert($vm.directory_exist?(directory))
+end
+
+When /^the directory "([^"]+)" does not exist$/ do |directory|
+  assert(! ($vm.directory_exist?(directory)))
 end
 
 When /^I copy "([^"]+)" to "([^"]+)" as user "([^"]+)"$/ do |source, destination, user|
-  next if @skip_steps_while_restoring_background
-  c = @vm.execute("cp \"#{source}\" \"#{destination}\"", $live_user)
+  c = $vm.execute("cp \"#{source}\" \"#{destination}\"", :user => LIVE_USER)
   assert(c.success?, "Failed to copy file:\n#{c.stdout}\n#{c.stderr}")
 end
 
-Given /^the USB drive "([^"]+)" contains Tails with persistence configured and password "([^"]+)"$/ do |drive, password|
-    step "a computer"
-    step "I start Tails from DVD with network unplugged and I login"
-    step "I create a new 4 GiB USB drive named \"#{drive}\""
-    step "I plug USB drive \"#{drive}\""
-    step "I \"Clone & Install\" Tails to USB drive \"#{drive}\""
-    step "there is no persistence partition on USB drive \"#{drive}\""
-    step "I shutdown Tails and wait for the computer to power off"
-    step "a computer"
-    step "I start Tails from USB drive \"#{drive}\" with network unplugged and I login"
-    step "I create a persistent partition with password \"#{password}\""
-    step "a Tails persistence partition with password \"#{password}\" exists on USB drive \"#{drive}\""
-    step "I shutdown Tails and wait for the computer to power off"
+def is_persistent?(app)
+  conf = get_persistence_presets(true)["#{app}"]
+  c = $vm.execute("findmnt --noheadings --output SOURCE --target '#{conf}'")
+  # This check assumes that we haven't enabled read-only persistence.
+  c.success? and c.stdout.chomp != "aufs"
+end
+
+Then /^persistence for "([^"]+)" is (|not )enabled$/ do |app, enabled|
+  case enabled
+  when ''
+    assert(is_persistent?(app), "Persistence should be enabled.")
+  when 'not '
+    assert(!is_persistent?(app), "Persistence should not be enabled.")
+  end
+end
+
+def gnome_app_menu_click_helper(click_me, verify_me = nil)
+  try_for(30) do
+    @screen.hide_cursor
+    # The sensitivity for submenus to open by just hovering past them
+    # is extremely high, and may result in the wrong one
+    # opening. Hence we better avoid hovering over undesired submenus
+    # entirely by "approaching" the menu strictly horizontally.
+    r = @screen.wait(click_me, 10)
+    @screen.hover_point(@screen.w, r.getY)
+    @screen.click(r)
+    @screen.wait(verify_me, 10) if verify_me
+    return
+  end
+end
+
+Given /^I start "([^"]+)" via the GNOME "([^"]+)" applications menu$/ do |app, submenu|
+  menu_button = "GnomeApplicationsMenu.png"
+  sub_menu_entry = "GnomeApplications" + submenu + ".png"
+  application_entry = "GnomeApplications" + app + ".png"
+  try_for(120) do
+    begin
+      gnome_app_menu_click_helper(menu_button, sub_menu_entry)
+      gnome_app_menu_click_helper(sub_menu_entry, application_entry)
+      gnome_app_menu_click_helper(application_entry)
+    rescue Exception => e
+      # Close menu, if still open
+      @screen.type(Sikuli::Key.ESC)
+      raise e
+    end
+    true
+  end
+end
+
+Given /^I start "([^"]+)" via the GNOME "([^"]+)"\/"([^"]+)" applications menu$/ do |app, submenu, subsubmenu|
+  menu_button = "GnomeApplicationsMenu.png"
+  sub_menu_entry = "GnomeApplications" + submenu + ".png"
+  sub_sub_menu_entry = "GnomeApplications" + subsubmenu + ".png"
+  application_entry = "GnomeApplications" + app + ".png"
+  try_for(120) do
+    begin
+      gnome_app_menu_click_helper(menu_button, sub_menu_entry)
+      gnome_app_menu_click_helper(sub_menu_entry, sub_sub_menu_entry)
+      gnome_app_menu_click_helper(sub_sub_menu_entry, application_entry)
+      gnome_app_menu_click_helper(application_entry)
+    rescue Exception => e
+      # Close menu, if still open
+      @screen.type(Sikuli::Key.ESC)
+      raise e
+    end
+    true
+  end
+end
+
+When /^I type "([^"]+)"$/ do |string|
+  @screen.type(string)
+end
+
+When /^I press the "([^"]+)" key$/ do |key|
+  begin
+    @screen.type(eval("Sikuli::Key.#{key}"))
+  rescue RuntimeError
+    raise "unsupported key #{key}"
+  end
+end
+
+Then /^the (amnesiac|persistent) Tor Browser directory (exists|does not exist)$/ do |persistent_or_not, mode|
+  case persistent_or_not
+  when "amnesiac"
+    dir = "/home/#{LIVE_USER}/Tor Browser"
+  when "persistent"
+    dir = "/home/#{LIVE_USER}/Persistent/Tor Browser"
+  end
+  step "the directory \"#{dir}\" #{mode}"
+end
+
+Then /^there is a GNOME bookmark for the (amnesiac|persistent) Tor Browser directory$/ do |persistent_or_not|
+  case persistent_or_not
+  when "amnesiac"
+    bookmark_image = 'TorBrowserAmnesicFilesBookmark.png'
+  when "persistent"
+    bookmark_image = 'TorBrowserPersistentFilesBookmark.png'
+  end
+  @screen.wait_and_click('GnomePlaces.png', 10)
+  @screen.wait(bookmark_image, 40)
+  @screen.type(Sikuli::Key.ESC)
+end
+
+Then /^there is no GNOME bookmark for the persistent Tor Browser directory$/ do
+  try_for(65) do
+    @screen.wait_and_click('GnomePlaces.png', 10)
+    @screen.wait("GnomePlacesWithoutTorBrowserPersistent.png", 10)
+    @screen.type(Sikuli::Key.ESC)
+  end
+end
+
+def pulseaudio_sink_inputs
+  pa_info = $vm.execute_successfully('pacmd info', :user => LIVE_USER).stdout
+  sink_inputs_line = pa_info.match(/^\d+ sink input\(s\) available\.$/)[0]
+  return sink_inputs_line.match(/^\d+/)[0].to_i
+end
+
+When /^(no|\d+) application(?:s?) (?:is|are) playing audio(?:| after (\d+) seconds)$/ do |nb, wait_time|
+  nb = 0 if nb == "no"
+  sleep wait_time.to_i if ! wait_time.nil?
+  assert_equal(nb.to_i, pulseaudio_sink_inputs)
+end
+
+When /^I double-click on the "Tails documentation" link on the Desktop$/ do
+  @screen.wait_and_double_click("DesktopTailsDocumentationIcon.png", 10)
+end
+
+When /^I click the blocked video icon$/ do
+  @screen.wait_and_click("TorBrowserBlockedVideo.png", 30)
+end
+
+When /^I accept to temporarily allow playing this video$/ do
+  @screen.wait_and_click("TorBrowserOkButton.png", 10)
+end
+
+When /^I click the HTML5 play button$/ do
+  @screen.wait_and_click("TorBrowserHtml5PlayButton.png", 30)
+end
+
+When /^I (can|cannot) save the current page as "([^"]+[.]html)" to the (.*) directory$/ do |should_work, output_file, output_dir|
+  should_work = should_work == 'can' ? true : false
+  @screen.type("s", Sikuli::KeyModifier.CTRL)
+  @screen.wait("TorBrowserSaveDialog.png", 10)
+  if output_dir == "persistent Tor Browser"
+    output_dir = "/home/#{LIVE_USER}/Persistent/Tor Browser"
+    @screen.wait_and_click("GtkTorBrowserPersistentBookmark.png", 10)
+    @screen.wait("GtkTorBrowserPersistentBookmarkSelected.png", 10)
+    # The output filename (without its extension) is already selected,
+    # let's use the keyboard shortcut to focus its field
+    @screen.type("n", Sikuli::KeyModifier.ALT)
+    @screen.wait("TorBrowserSaveOutputFileSelected.png", 10)
+  elsif output_dir == "default downloads"
+    output_dir = "/home/#{LIVE_USER}/Tor Browser"
+  else
+    @screen.type(output_dir + '/')
+  end
+  # Only the part of the filename before the .html extension can be easily replaced
+  # so we have to remove it before typing it into the arget filename entry widget.
+  @screen.type(output_file.sub(/[.]html$/, ''))
+  @screen.type(Sikuli::Key.ENTER)
+  if should_work
+    try_for(10, :msg => "The page was not saved to #{output_dir}/#{output_file}") {
+      $vm.file_exist?("#{output_dir}/#{output_file}")
+    }
+  else
+    @screen.wait("TorBrowserCannotSavePage.png", 10)
+  end
+end
+
+When /^I can print the current page as "([^"]+[.]pdf)" to the (default downloads|persistent Tor Browser) directory$/ do |output_file, output_dir|
+  if output_dir == "persistent Tor Browser"
+    output_dir = "/home/#{LIVE_USER}/Persistent/Tor Browser"
+  else
+    output_dir = "/home/#{LIVE_USER}/Tor Browser"
+  end
+  @screen.type("p", Sikuli::KeyModifier.CTRL)
+  @screen.wait("TorBrowserPrintDialog.png", 20)
+  @screen.wait_and_click("BrowserPrintToFile.png", 10)
+  @screen.wait_and_double_click("TorBrowserPrintOutputFile.png", 10)
+  @screen.hide_cursor
+  @screen.wait("TorBrowserPrintOutputFileSelected.png", 10)
+  # Only the file's basename is selected by double-clicking,
+  # so we type only the desired file's basename to replace it
+  @screen.type(output_dir + '/' + output_file.sub(/[.]pdf$/, '') + Sikuli::Key.ENTER)
+  try_for(30, :msg => "The page was not printed to #{output_dir}/#{output_file}") {
+    $vm.file_exist?("#{output_dir}/#{output_file}")
+  }
+end
+
+Given /^a web server is running on the LAN$/ do
+  web_server_ip_addr = $vmnet.bridge_ip_addr
+  web_server_port = 8000
+  @web_server_url = "http://#{web_server_ip_addr}:#{web_server_port}"
+  web_server_hello_msg = "Welcome to the LAN web server!"
+
+  # I've tested ruby Thread:s, fork(), etc. but nothing works due to
+  # various strange limitations in the ruby interpreter. For instance,
+  # apparently concurrent IO has serious limits in the thread
+  # scheduler (e.g. sikuli's wait() would block WEBrick from reading
+  # from its socket), and fork():ing results in a lot of complex
+  # cucumber stuff (like our hooks!) ending up in the child process,
+  # breaking stuff in the parent process. After asking some supposed
+  # ruby pros, I've settled on the following.
+  code = <<-EOF
+  require "webrick"
+  STDOUT.reopen("/dev/null", "w")
+  STDERR.reopen("/dev/null", "w")
+  server = WEBrick::HTTPServer.new(:BindAddress => "#{web_server_ip_addr}",
+                                   :Port => #{web_server_port},
+                                   :DocumentRoot => "/dev/null")
+  server.mount_proc("/") do |req, res|
+    res.body = "#{web_server_hello_msg}"
+  end
+  server.start
+EOF
+  proc = IO.popen(['ruby', '-e', code])
+  try_for(10, :msg => "It seems the LAN web server failed to start") do
+    Process.kill(0, proc.pid) == 1
+  end
+
+  add_after_scenario_hook { Process.kill("TERM", proc.pid) }
+
+  # It seems necessary to actually check that the LAN server is
+  # serving, possibly because it isn't doing so reliably when setting
+  # up. If e.g. the Unsafe Browser (which *should* be able to access
+  # the web server) tries to access it too early, Firefox seems to
+  # take some random amount of time to retry fetching. Curl gives a
+  # more consistent result, so let's rely on that instead. Note that
+  # this forces us to capture traffic *after* this step in case
+  # accessing this server matters, like when testing the Tor Browser..
+  try_for(30, :msg => "Something is wrong with the LAN web server") do
+    msg = $vm.execute_successfully("curl #{@web_server_url}",
+                                   :user => LIVE_USER).stdout.chomp
+    web_server_hello_msg == msg
+  end
+end
+
+When /^I open a page on the LAN web server in the (.*)$/ do |browser|
+  step "I open the address \"#{@web_server_url}\" in the #{browser}"
+end
+
+Given /^I wait (?:between (\d+) and )?(\d+) seconds$/ do |min, max|
+  if min
+    time = rand(max.to_i - min.to_i + 1) + min.to_i
+  else
+    time = max.to_i
+  end
+  puts "Slept for #{time} seconds"
+  sleep(time)
+end
+
+Given /^I (?:re)?start monitoring the AppArmor log of "([^"]+)"$/ do |profile|
+  # AppArmor log entries may be dropped if printk rate limiting is
+  # enabled.
+  $vm.execute_successfully('sysctl -w kernel.printk_ratelimit=0')
+  # We will only care about entries for this profile from this time
+  # and on.
+  guest_time = $vm.execute_successfully(
+    'date +"%Y-%m-%d %H:%M:%S"').stdout.chomp
+  @apparmor_profile_monitoring_start ||= Hash.new
+  @apparmor_profile_monitoring_start[profile] = guest_time
+end
+
+When /^AppArmor has (not )?denied "([^"]+)" from opening "([^"]+)"(?: after at most (\d+) seconds)?$/ do |anti_test, profile, file, time|
+  assert(@apparmor_profile_monitoring_start &&
+         @apparmor_profile_monitoring_start[profile],
+         "It seems the profile '#{profile}' isn't being monitored by the " +
+         "'I monitor the AppArmor log of ...' step")
+  audit_line_regex = 'apparmor="DENIED" operation="open" profile="%s" name="%s"' % [profile, file]
+  block = Proc.new do
+    audit_log = $vm.execute(
+      "journalctl --full --no-pager " +
+      "--since='#{@apparmor_profile_monitoring_start[profile]}' " +
+      "SYSLOG_IDENTIFIER=kernel | grep -w '#{audit_line_regex}'"
+    ).stdout.chomp
+    assert(audit_log.empty? == (anti_test ? true : false))
+    true
+  end
+  begin
+    if time
+      try_for(time.to_i) { block.call }
+    else
+      block.call
+    end
+  rescue Timeout::Error, Test::Unit::AssertionFailedError => e
+    raise e, "AppArmor has #{anti_test ? "" : "not "}denied the operation"
+  end
+end
+
+Then /^I force Tor to use a new circuit$/ do
+  debug_log("Forcing new Tor circuit...")
+  $vm.execute_successfully('tor_control_send "signal NEWNYM"', :libs => 'tor')
+end
+
+When /^I eject the boot medium$/ do
+  dev = boot_device
+  dev_type = device_info(dev)['ID_TYPE']
+  case dev_type
+  when 'cd'
+    $vm.remove_cdrom
+  when 'disk'
+    boot_disk_name = $vm.disk_name(dev)
+    $vm.unplug_drive(boot_disk_name)
+  else
+    raise "Unsupported medium type '#{dev_type}' for boot device '#{dev}'"
+  end
 end
diff --git a/features/step_definitions/dhcp.rb b/features/step_definitions/dhcp.rb
index 78ee8f2..ef4d9e1 100644
--- a/features/step_definitions/dhcp.rb
+++ b/features/step_definitions/dhcp.rb
@@ -1,6 +1,5 @@
 Then /^the hostname should not have been leaked on the network$/ do
-  next if @skip_steps_while_restoring_background
-  hostname = @vm.execute("hostname").stdout.chomp
+  hostname = $vm.execute("hostname").stdout.chomp
   packets = PacketFu::PcapFile.new.file_to_array(:filename => @sniffer.pcap_file)
   packets.each do |p|
     # if PacketFu::TCPPacket.can_parse?(p)
@@ -10,7 +9,7 @@ Then /^the hostname should not have been leaked on the network$/ do
     elsif PacketFu::IPv6Packet.can_parse?(p)
       payload = PacketFu::IPv6Packet.parse(p).payload
     else
-      save_pcap_file
+      @sniffer.save_pcap_file
       raise "Found something in the pcap file that either is non-IP, or cannot be parsed"
     end
     if payload.match(hostname)
diff --git a/features/step_definitions/electrum.rb b/features/step_definitions/electrum.rb
new file mode 100644
index 0000000..447983d
--- /dev/null
+++ b/features/step_definitions/electrum.rb
@@ -0,0 +1,52 @@
+Then /^I start Electrum through the GNOME menu$/ do
+  step "I start \"Electrum\" via the GNOME \"Internet\" applications menu"
+end
+
+When /^a bitcoin wallet is (|not )present$/ do |existing|
+  wallet = "/home/#{LIVE_USER}/.electrum/wallets/default_wallet"
+  case existing
+  when ""
+    step "the file \"#{wallet}\" exists after at most 10 seconds"
+  when "not "
+    step "the file \"#{wallet}\" does not exist"
+  else
+    raise "Unknown value specified for #{existing}"
+  end
+end
+
+When /^I create a new bitcoin wallet$/ do
+  @screen.wait("ElectrumNoWallet.png", 10)
+  @screen.wait_and_click("ElectrumNextButton.png", 10)
+  @screen.wait("ElectrumWalletGenerationSeed.png", 15)
+  @screen.wait_and_click("ElectrumWalletSeedTextbox.png", 15)
+  @screen.type('a', Sikuli::KeyModifier.CTRL) # select wallet seed
+  @screen.type('c', Sikuli::KeyModifier.CTRL) # copy seed to clipboard
+  seed = $vm.get_clipboard
+  @screen.wait_and_click("ElectrumNextButton.png", 15)
+  @screen.wait("ElectrumWalletSeedTextbox.png", 15)
+  @screen.type(seed) # Confirm seed
+  @screen.wait_and_click("ElectrumNextButton.png", 10)
+  @screen.wait_and_click("ElectrumEncryptWallet.png", 10)
+  @screen.type("asdf" + Sikuli::Key.TAB) # set password
+  @screen.type("asdf" + Sikuli::Key.TAB) # confirm password
+  @screen.type(Sikuli::Key.ENTER)
+  @screen.wait("ElectrumConnectServer.png", 20)
+  @screen.wait_and_click("ElectrumNextButton.png", 10)
+  @screen.wait("ElectrumPreferencesButton.png", 30)
+end
+
+Then /^I see a warning that Electrum is not persistent$/ do
+  @screen.wait('GnomeQuestionDialogIcon.png', 30)
+end
+
+Then /^I am prompted to create a new wallet$/ do
+  @screen.wait('ElectrumNoWallet.png', 60)
+end
+
+Then /^I see the main Electrum client window$/ do
+  @screen.wait('ElectrumPreferencesButton.png', 20)
+end
+
+Then /^Electrum successfully connects to the network$/ do
+  @screen.wait('ElectrumStatus.png', 180)
+end
diff --git a/features/step_definitions/encryption.rb b/features/step_definitions/encryption.rb
index 404890a..9f7f1b9 100644
--- a/features/step_definitions/encryption.rb
+++ b/features/step_definitions/encryption.rb
@@ -1,7 +1,16 @@
+def seahorse_menu_click_helper(main, sub, verify = nil)
+  try_for(60) do
+    step "process \"#{verify}\" is running" if verify
+    @screen.hide_cursor
+    @screen.wait_and_click(main, 10)
+    @screen.wait_and_click(sub, 10)
+    return
+  end
+end
+
 Given /^I generate an OpenPGP key named "([^"]+)" with password "([^"]+)"$/ do |name, pwd|
   @passphrase = pwd
   @key_name = name
-  next if @skip_steps_while_restoring_background
   gpg_key_recipie = <<EOF
      Key-Type: RSA
      Key-Length: 4096
@@ -15,26 +24,30 @@ Given /^I generate an OpenPGP key named "([^"]+)" with password "([^"]+)"$/ do |
      %commit
 EOF
   gpg_key_recipie.split("\n").each do |line|
-    @vm.execute("echo '#{line}' >> /tmp/gpg_key_recipie", $live_user)
+    $vm.execute("echo '#{line}' >> /tmp/gpg_key_recipie", :user => LIVE_USER)
   end
-  c = @vm.execute("gpg --batch --gen-key < /tmp/gpg_key_recipie", $live_user)
+  c = $vm.execute("gpg --batch --gen-key < /tmp/gpg_key_recipie",
+                  :user => LIVE_USER)
   assert(c.success?, "Failed to generate OpenPGP key:\n#{c.stderr}")
 end
 
 When /^I type a message into gedit$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.wait_and_click("GnomeApplicationsMenu.png", 10)
-  @screen.wait_and_click("GnomeApplicationsAccessories.png", 10)
-  @screen.wait_and_click("GnomeApplicationsGedit.png", 20)
-  @screen.wait_and_click("GeditWindow.png", 10)
-  sleep 0.5
+  step 'I start "Gedit" via the GNOME "Accessories" applications menu'
+  @screen.wait_and_click("GeditWindow.png", 20)
+  # We don't have a good visual indicator for when we can continue. Without the
+  # sleep we may start typing in the gedit window far too soon, causing
+  # keystrokes to go missing.
+  sleep 5
   @screen.type("ATTACK AT DAWN")
 end
 
 def maybe_deal_with_pinentry
   begin
-    @screen.wait_and_click("PinEntryPrompt.png", 3)
-    sleep 1
+    @screen.wait_and_click("PinEntryPrompt.png", 10)
+    # Without this sleep here (and reliable visual indicators) we can sometimes
+    # miss keystrokes by typing too soon. This sleep prevents this problem from
+    # coming up.
+    sleep 5
     @screen.type(@passphrase + Sikuli::Key.ENTER)
   rescue FindFailed
     # The passphrase was cached or we wasn't prompted at all (e.g. when
@@ -42,98 +55,79 @@ def maybe_deal_with_pinentry
   end
 end
 
+def gedit_copy_all_text
+  context_menu_helper('GeditWindow.png', 'GeditStatusBar.png', 'GeditSelectAll.png')
+  context_menu_helper('GeditWindow.png', 'GeditStatusBar.png', 'GeditCopy.png')
+end
+
+def paste_into_a_new_tab
+  @screen.wait_and_click("GeditNewTab.png", 20)
+  context_menu_helper('GeditWindow.png', 'GeditStatusBar.png', 'GeditPaste.png')
+end
+
 def encrypt_sign_helper
-  @screen.wait_and_click("GeditWindow.png", 10)
-  @screen.type("a", Sikuli::KeyModifier.CTRL)
-  sleep 0.5
-  @screen.click("GpgAppletIconNormal.png")
-  sleep 2
-  @screen.type("k")
+  gedit_copy_all_text
+  seahorse_menu_click_helper('GpgAppletIconNormal.png', 'GpgAppletSignEncrypt.png')
   @screen.wait_and_click("GpgAppletChooseKeyWindow.png", 30)
-  sleep 0.5
+  # We don't have a good visual indicator for when we can continue without
+  # keystrokes being lost.
+  sleep 5
   yield
   maybe_deal_with_pinentry
-  @screen.wait_and_click("GeditWindow.png", 10)
-  sleep 0.5
-  @screen.type("n", Sikuli::KeyModifier.CTRL)
-  sleep 0.5
-  @screen.type("v", Sikuli::KeyModifier.CTRL)
+  paste_into_a_new_tab
 end
 
 def decrypt_verify_helper(icon)
-  @screen.wait_and_click("GeditWindow.png", 10)
-  @screen.type("a", Sikuli::KeyModifier.CTRL)
-  sleep 0.5
-  @screen.click(icon)
-  sleep 2
-  @screen.type("d")
+  gedit_copy_all_text
+  seahorse_menu_click_helper(icon, 'GpgAppletDecryptVerify.png')
   maybe_deal_with_pinentry
-  @screen.wait("GpgAppletResults.png", 10)
-  @screen.wait("GpgAppletResultsMsg.png", 10)
+  @screen.wait("GpgAppletResults.png", 20)
+  @screen.wait("GpgAppletResultsMsg.png", 20)
 end
 
 When /^I encrypt the message using my OpenPGP key$/ do
-  next if @skip_steps_while_restoring_background
   encrypt_sign_helper do
     @screen.type(@key_name + Sikuli::Key.ENTER + Sikuli::Key.ENTER)
   end
 end
 
 Then /^I can decrypt the encrypted message$/ do
-  next if @skip_steps_while_restoring_background
   decrypt_verify_helper("GpgAppletIconEncrypted.png")
-  @screen.wait("GpgAppletResultsEncrypted.png", 10)
+  @screen.wait("GpgAppletResultsEncrypted.png", 20)
 end
 
 When /^I sign the message using my OpenPGP key$/ do
-  next if @skip_steps_while_restoring_background
   encrypt_sign_helper do
     @screen.type(Sikuli::Key.TAB + Sikuli::Key.DOWN + Sikuli::Key.ENTER)
-    @screen.wait("PinEntryPrompt.png", 10)
-    @screen.type(@passphrase + Sikuli::Key.ENTER)
   end
 end
 
 Then /^I can verify the message's signature$/ do
-  next if @skip_steps_while_restoring_background
   decrypt_verify_helper("GpgAppletIconSigned.png")
-  @screen.wait("GpgAppletResultsSigned.png", 10)
+  @screen.wait("GpgAppletResultsSigned.png", 20)
 end
 
 When /^I both encrypt and sign the message using my OpenPGP key$/ do
-  next if @skip_steps_while_restoring_background
   encrypt_sign_helper do
-    @screen.type(@key_name + Sikuli::Key.ENTER)
+    @screen.wait_and_click('GpgAppletEncryptionKey.png', 20)
+    @screen.type(Sikuli::Key.SPACE)
+    @screen.wait('GpgAppletKeySelected.png', 10)
     @screen.type(Sikuli::Key.TAB + Sikuli::Key.DOWN + Sikuli::Key.ENTER)
-    @screen.wait("PinEntryPrompt.png", 10)
-    @screen.type(@passphrase + Sikuli::Key.ENTER)
+    @screen.type(Sikuli::Key.ENTER)
   end
 end
 
 Then /^I can decrypt and verify the encrypted message$/ do
-  next if @skip_steps_while_restoring_background
   decrypt_verify_helper("GpgAppletIconEncrypted.png")
-  @screen.wait("GpgAppletResultsEncrypted.png", 10)
-  @screen.wait("GpgAppletResultsSigned.png", 10)
+  @screen.wait("GpgAppletResultsEncrypted.png", 20)
+  @screen.wait("GpgAppletResultsSigned.png", 20)
 end
 
 When /^I symmetrically encrypt the message with password "([^"]+)"$/ do |pwd|
   @passphrase = pwd
-  next if @skip_steps_while_restoring_background
-  @screen.wait_and_click("GeditWindow.png", 10)
-  @screen.type("a", Sikuli::KeyModifier.CTRL)
-  sleep 0.5
-  @screen.click("GpgAppletIconNormal.png")
-  sleep 2
-  @screen.type("p")
-  @screen.wait("PinEntryPrompt.png", 10)
-  @screen.type(@passphrase + Sikuli::Key.ENTER)
-  sleep 1
-  @screen.wait("PinEntryPrompt.png", 10)
-  @screen.type(@passphrase + Sikuli::Key.ENTER)
-  @screen.wait_and_click("GeditWindow.png", 10)
-  sleep 0.5
-  @screen.type("n", Sikuli::KeyModifier.CTRL)
-  sleep 0.5
-  @screen.type("v", Sikuli::KeyModifier.CTRL)
+  gedit_copy_all_text
+  seahorse_menu_click_helper('GpgAppletIconNormal.png', 'GpgAppletEncryptPassphrase.png')
+  maybe_deal_with_pinentry # enter password
+  maybe_deal_with_pinentry # confirm password
+  paste_into_a_new_tab
 end
diff --git a/features/step_definitions/evince.rb b/features/step_definitions/evince.rb
index d9bb42c..9411ac4 100644
--- a/features/step_definitions/evince.rb
+++ b/features/step_definitions/evince.rb
@@ -1,20 +1,25 @@
 When /^I(?:| try to) open "([^"]+)" with Evince$/ do |filename|
-  next if @skip_steps_while_restoring_background
   step "I run \"evince #{filename}\" in GNOME Terminal"
 end
 
 Then /^I can print the current document to "([^"]+)"$/ do |output_file|
-  next if @skip_steps_while_restoring_background
   @screen.type("p", Sikuli::KeyModifier.CTRL)
   @screen.wait("EvincePrintDialog.png", 10)
   @screen.wait_and_click("EvincePrintToFile.png", 10)
-  @screen.wait_and_double_click("EvincePrintOutputFile.png", 10)
-  @screen.hide_cursor
-  @screen.wait("EvincePrintOutputFileSelected.png", 10)
+  @screen.wait_and_click("EvincePrintOutputFileButton.png", 10)
+  @screen.wait("EvincePrintFileDialog.png", 10)
   # Only the file's basename is selected by double-clicking,
   # so we type only the desired file's basename to replace it
-  @screen.type(output_file.sub(/[.]pdf$/, '') + Sikuli::Key.ENTER)
+  $vm.set_clipboard(output_file.sub(/[.]pdf$/, ''))
+  @screen.type('v', Sikuli::KeyModifier.CTRL)
+  @screen.type(Sikuli::Key.ENTER)
+  @screen.wait_and_click("EvincePrintButton.png", 10)
   try_for(10, :msg => "The document was not printed to #{output_file}") {
-    @vm.file_exist?(output_file)
+    $vm.file_exist?(output_file)
   }
 end
+
+When /^I close Evince$/ do
+  @screen.type("w", Sikuli::KeyModifier.CTRL)
+  step 'process "evince" has stopped running after at most 20 seconds'
+end
diff --git a/features/step_definitions/firewall_leaks.rb b/features/step_definitions/firewall_leaks.rb
index 79ae0de..942d00b 100644
--- a/features/step_definitions/firewall_leaks.rb
+++ b/features/step_definitions/firewall_leaks.rb
@@ -1,25 +1,25 @@
 Then(/^the firewall leak detector has detected (.*?) leaks$/) do |type|
-  next if @skip_steps_while_restoring_background
-  leaks = FirewallLeakCheck.new(@sniffer.pcap_file, get_tor_relays)
+  leaks = FirewallLeakCheck.new(@sniffer.pcap_file,
+                                :accepted_hosts => get_all_tor_nodes)
   case type.downcase
   when 'ipv4 tcp'
     if leaks.ipv4_tcp_leaks.empty?
-      save_pcap_file
+      leaks.save_pcap_file
       raise "Couldn't detect any IPv4 TCP leaks"
     end
   when 'ipv4 non-tcp'
     if leaks.ipv4_nontcp_leaks.empty?
-      save_pcap_file
+      leaks.save_pcap_file
       raise "Couldn't detect any IPv4 non-TCP leaks"
     end
   when 'ipv6'
     if leaks.ipv6_leaks.empty?
-      save_pcap_file
+      leaks.save_pcap_file
       raise "Couldn't detect any IPv6 leaks"
     end
   when 'non-ip'
     if leaks.nonip_leaks.empty?
-      save_pcap_file
+      leaks.save_pcap_file
       raise "Couldn't detect any non-IP leaks"
     end
   else
@@ -28,9 +28,8 @@ Then(/^the firewall leak detector has detected (.*?) leaks$/) do |type|
 end
 
 Given(/^I disable Tails' firewall$/) do
-  next if @skip_steps_while_restoring_background
-  @vm.execute("/usr/local/sbin/do_not_ever_run_me")
-  iptables = @vm.execute("iptables -L -n -v").stdout.chomp.split("\n")
+  $vm.execute("/usr/local/lib/do_not_ever_run_me")
+  iptables = $vm.execute("iptables -L -n -v").stdout.chomp.split("\n")
   for line in iptables do
     if !line[/Chain (INPUT|OUTPUT|FORWARD) \(policy ACCEPT/] and
        !line[/pkts[[:blank:]]+bytes[[:blank:]]+target/] and
@@ -41,20 +40,17 @@ Given(/^I disable Tails' firewall$/) do
 end
 
 When(/^I do a TCP DNS lookup of "(.*?)"$/) do |host|
-  next if @skip_steps_while_restoring_background
-  lookup = @vm.execute("host -T #{host} #{$some_dns_server}", $live_user)
+  lookup = $vm.execute("host -T #{host} #{SOME_DNS_SERVER}", :user => LIVE_USER)
   assert(lookup.success?, "Failed to resolve #{host}:\n#{lookup.stdout}")
 end
 
 When(/^I do a UDP DNS lookup of "(.*?)"$/) do |host|
-  next if @skip_steps_while_restoring_background
-  lookup = @vm.execute("host #{host} #{$some_dns_server}", $live_user)
+  lookup = $vm.execute("host #{host} #{SOME_DNS_SERVER}", :user => LIVE_USER)
   assert(lookup.success?, "Failed to resolve #{host}:\n#{lookup.stdout}")
 end
 
 When(/^I send some ICMP pings$/) do
-  next if @skip_steps_while_restoring_background
   # We ping an IP address to avoid a DNS lookup
-  ping = @vm.execute("ping -c 5 #{$some_dns_server}", $live_user)
-  assert(ping.success?, "Failed to ping #{$some_dns_server}:\n#{ping.stderr}")
+  ping = $vm.execute("ping -c 5 #{SOME_DNS_SERVER}")
+  assert(ping.success?, "Failed to ping #{SOME_DNS_SERVER}:\n#{ping.stderr}")
 end
diff --git a/features/step_definitions/git.rb b/features/step_definitions/git.rb
new file mode 100644
index 0000000..bf6f869
--- /dev/null
+++ b/features/step_definitions/git.rb
@@ -0,0 +1,6 @@
+Then /^the Git repository "([\S]+)" has been cloned successfully$/ do |repo|
+  assert($vm.directory_exist?("/home/#{LIVE_USER}/#{repo}/.git"))
+  assert($vm.file_exist?("/home/#{LIVE_USER}/#{repo}/.git/config"))
+  $vm.execute_successfully("cd '/home/#{LIVE_USER}/#{repo}/' && git status",
+                           :user => LIVE_USER)
+end
diff --git a/features/step_definitions/icedove.rb b/features/step_definitions/icedove.rb
new file mode 100644
index 0000000..d367289
--- /dev/null
+++ b/features/step_definitions/icedove.rb
@@ -0,0 +1,94 @@
+Then /^Icedove has started$/ do
+  step 'process "icedove" is running within 30 seconds'
+  @screen.wait('IcedoveMainWindow.png', 60)
+end
+
+When /^I have not configured an email account$/ do
+  icedove_prefs = $vm.file_content("/home/#{LIVE_USER}/.icedove/profile.default/prefs.js").chomp
+  assert(!icedove_prefs.include?('mail.accountmanager.accounts'))
+end
+
+Then /^I am prompted to setup an email account$/ do
+  $vm.focus_window('Mail Account Setup')
+  @screen.wait('IcedoveMailAccountSetup.png', 30)
+end
+
+Then /^IMAP is the default protocol$/ do
+  $vm.focus_window('Mail Account Setup')
+  @screen.wait('IcedoveProtocolIMAP.png', 10)
+end
+
+Then /^I cancel setting up an email account$/ do
+  $vm.focus_window('Mail Account Setup')
+  @screen.type(Sikuli::Key.ESC)
+  @screen.waitVanish('IcedoveMailAccountSetup.png', 10)
+end
+
+Then /^I open Icedove's Add-ons Manager$/ do
+  $vm.focus_window('Icedove')
+  @screen.wait_and_click('MozillaMenuButton.png', 10)
+  @screen.wait_and_click('IcedoveToolsMenuAddOns.png', 10)
+  @screen.wait('MozillaAddonsManagerExtensions.png', 30)
+end
+
+Then /^I click the extensions tab$/ do
+  @screen.wait_and_click('MozillaAddonsManagerExtensions.png', 10)
+end
+
+Then /^I see that Adblock is not installed in Icedove$/ do
+  if @screen.exists('MozillaExtensionsAdblockPlus.png')
+    raise 'Adblock should not be enabled within Icedove'
+  end
+end
+
+When /^I go into Enigmail's preferences$/ do
+  $vm.focus_window('Icedove')
+  @screen.type("a", Sikuli::KeyModifier.ALT)
+  @screen.wait_and_click('IcedoveEnigmailPreferences.png', 10)
+  @screen.wait('IcedoveEnigmailPreferencesWindow.png', 10)
+  @screen.click('IcedoveEnigmailExpertSettingsButton.png')
+  @screen.wait('IcedoveEnigmailKeyserverTab.png', 10)
+end
+
+When /^I click Enigmail's keyserver tab$/ do
+  @screen.wait_and_click('IcedoveEnigmailKeyserverTab.png', 10)
+end
+
+Then /^I see that Enigmail is configured to use the correct keyserver$/ do
+  @screen.wait('IcedoveEnigmailKeyserver.png', 10)
+end
+
+Then /^I click Enigmail's advanced tab$/ do
+  @screen.wait_and_click('IcedoveEnigmailAdvancedTab.png', 10)
+end
+
+Then /^I see that Enigmail is configured to use the correct SOCKS proxy$/ do
+  @screen.click('IcedoveEnigmailAdvancedParameters.png')
+  @screen.type(Sikuli::Key.END)
+  @screen.wait('IcedoveEnigmailProxy.png', 10)
+end
+
+Then /^I see that Torbirdy is configured to use Tor$/ do
+  @screen.wait('IcedoveTorbirdyEnabled.png', 10)
+end
+
+When /^I open Torbirdy's preferences$/ do
+  step "I open Icedove's Add-ons Manager"
+  step 'I click the extensions tab'
+  @screen.wait_and_click('MozillaExtensionsTorbirdy.png', 10)
+  @screen.type(Sikuli::Key.TAB)   # Select 'More' link
+  @screen.type(Sikuli::Key.TAB)   # Select 'Preferences' button
+  @screen.type(Sikuli::Key.SPACE) # Press 'Preferences' button
+  @screen.wait('GnomeQuestionDialogIcon.png', 10)
+  @screen.type(Sikuli::Key.ENTER)
+end
+
+When /^I test Torbirdy's proxy settings$/ do
+  @screen.wait('IcedoveTorbirdyPreferencesWindow.png', 10)
+  @screen.click('IcedoveTorbirdyTestProxySettingsButton.png')
+  @screen.wait('IcedoveTorbirdyCongratulationsTab.png', 180)
+end
+
+Then /^Torbirdy's proxy test is successful$/ do
+  @screen.wait('IcedoveTorbirdyCongratulationsTab.png', 180)
+end
diff --git a/features/step_definitions/mac_spoofing.rb b/features/step_definitions/mac_spoofing.rb
new file mode 100644
index 0000000..a4aa871
--- /dev/null
+++ b/features/step_definitions/mac_spoofing.rb
@@ -0,0 +1,108 @@
+def all_ethernet_nics
+  $vm.execute_successfully(
+    "get_all_ethernet_nics", :libs => 'hardware'
+  ).stdout.split
+end
+
+When /^I disable MAC spoofing in Tails Greeter$/ do
+  @screen.wait_and_click("TailsGreeterMACSpoofing.png", 30)
+end
+
+Then /^the network device has (its default|a spoofed) MAC address configured$/ do |mode|
+  is_spoofed = (mode == "a spoofed")
+  nic = "eth0"
+  assert_equal([nic], all_ethernet_nics,
+               "We only expected NIC #{nic} but these are present: " +
+               all_ethernet_nics.join(", "))
+  nic_real_mac = $vm.real_mac
+  nic_current_mac = $vm.execute_successfully(
+    "get_current_mac_of_nic #{nic}", :libs => 'hardware'
+  ).stdout.chomp
+  if is_spoofed
+    if nic_real_mac == nic_current_mac
+      save_pcap_file
+      raise "The MAC address was expected to be spoofed but wasn't"
+    end
+  else
+    if nic_real_mac != nic_current_mac
+      save_pcap_file
+      raise "The MAC address is spoofed but was expected to not be"
+    end
+  end
+end
+
+Then /^the real MAC address was (not )?leaked$/ do |mode|
+  is_leaking = mode.nil?
+  leaks = FirewallLeakCheck.new(@sniffer.pcap_file)
+  mac_leaks = leaks.mac_leaks
+  if is_leaking
+    if !mac_leaks.include?($vm.real_mac)
+      save_pcap_file
+      raise "The real MAC address was expected to leak but didn't. We " +
+            "observed the following MAC addresses: #{mac_leaks}"
+    end
+  else
+    if mac_leaks.include?($vm.real_mac)
+      save_pcap_file
+      raise "The real MAC address was leaked but was expected not to. We " +
+            "observed the following MAC addresses: #{mac_leaks}"
+    end
+  end
+end
+
+Given /^macchanger will fail by not spoofing and always returns ([\S]+)$/ do |mode|
+  $vm.execute_successfully("mv /usr/bin/macchanger /usr/bin/macchanger.orig")
+  $vm.execute_successfully("ln -s /bin/#{mode} /usr/bin/macchanger")
+end
+
+Given /^no network interface modules can be unloaded$/ do
+  # Note that the real /sbin/modprobe is a symlink to /bin/kmod, and
+  # for it to run in modprobe compatibility mode the name must be
+  # exactly "modprobe", so we just move it somewhere our of the path
+  # instead of renaming it ".real" or whatever we usuablly do when
+  # diverting executables for wrappers.
+  modprobe_divert = "/usr/local/lib/modprobe"
+  $vm.execute_successfully(
+    "dpkg-divert --add --rename --divert '#{modprobe_divert}' /sbin/modprobe"
+  )
+  fake_modprobe_wrapper = <<EOF
+#!/bin/sh
+if echo "${@}" | grep -q -- -r; then
+    exit 1
+fi
+exec '#{modprobe_divert}' "${@}"
+EOF
+  $vm.file_append('/sbin/modprobe', fake_modprobe_wrapper)
+  $vm.execute_successfully("chmod a+rx /sbin/modprobe")
+end
+
+When /^see the "Network card disabled" notification$/ do
+  robust_notification_wait("MACSpoofNetworkCardDisabled.png", 60)
+end
+
+When /^see the "All networking disabled" notification$/ do
+  robust_notification_wait("MACSpoofNetworkingDisabled.png", 60)
+end
+
+Then /^(\d+|no) network interface(?:s)? (?:is|are) enabled$/ do |expected_nr_nics|
+  # note that "no".to_i => 0 in Ruby.
+  expected_nr_nics = expected_nr_nics.to_i
+  nr_nics = all_ethernet_nics.size
+  assert_equal(expected_nr_nics, nr_nics)
+end
+
+Then /^the MAC spoofing panic mode disabled networking$/ do
+  nm_state = $vm.execute_successfully('systemctl show NetworkManager').stdout
+  nm_is_disabled = $vm.pidof('NetworkManager').empty? &&
+                   nm_state[/^LoadState=masked$/] &&
+                   nm_state[/^ActiveState=inactive$/]
+  assert(nm_is_disabled, "NetworkManager was not disabled")
+  all_ethernet_nics.each do |nic|
+    ["nic_ipv4_addr", "nic_ipv6_addr"].each do |function|
+      addr = $vm.execute_successfully(
+        "#{function} #{nic}", :libs => 'hardware'
+      ).stdout.chomp
+      assert_equal("", addr, "NIC #{nic} was assigned address #{addr}")
+    end
+  end
+end
diff --git a/features/step_definitions/pidgin.rb b/features/step_definitions/pidgin.rb
index 23b48e2..3f5ed93 100644
--- a/features/step_definitions/pidgin.rb
+++ b/features/step_definitions/pidgin.rb
@@ -1,7 +1,210 @@
+# Extracts the secrets for the XMMP account `account_name`.
+def xmpp_account(account_name, required_options = [])
+  begin
+    account = $config["Pidgin"]["Accounts"]["XMPP"][account_name]
+    check_keys = ["username", "domain", "password"] + required_options
+    for key in check_keys do
+      assert(account.has_key?(key))
+      assert_not_nil(account[key])
+      assert(!account[key].empty?)
+    end
+  rescue NoMethodError, Test::Unit::AssertionFailedError
+    raise(
+<<EOF
+Your Pidgin:Accounts:XMPP:#{account} is incorrect or missing from your local configuration file (#{LOCAL_CONFIG_FILE}). See wiki/src/contribute/release_process/test/usage.mdwn for the format.
+EOF
+)
+  end
+  return account
+end
+
+def wait_and_focus(img, time = 10, window)
+  begin
+    @screen.wait(img, time)
+  rescue FindFailed
+    $vm.focus_window(window)
+    @screen.wait(img, time)
+  end
+end
+
+def focus_pidgin_irc_conversation_window(account)
+  if account == 'I2P'
+    # After connecting to Irc2P messages are sent from services. Most of the
+    # time the services will send their messages right away. If there's lag we
+    # may in fact join the channel _before_ the message is received. We'll look
+    # for a message from InfoServ first then default to looking for '#i2p'
+    try_for(20) do
+      begin
+        $vm.focus_window('InfoServ')
+      rescue ExecutionFailedInVM
+        $vm.focus_window('#i2p')
+      end
+    end
+  else
+    account = account.sub(/^irc\./, '')
+    try_for(20) do
+      $vm.focus_window(".*#{Regexp.escape(account)}$")
+    end
+  end
+end
+
+When /^I create my XMPP account$/ do
+  account = xmpp_account("Tails_account")
+  @screen.click("PidginAccountManagerAddButton.png")
+  @screen.wait("PidginAddAccountWindow.png", 20)
+  @screen.click_mid_right_edge("PidginAddAccountProtocolLabel.png")
+  @screen.click("PidginAddAccountProtocolXMPP.png")
+  # We first wait for some field that is shown for XMPP but not the
+  # default (IRC) since we otherwise may decide where we click before
+  # the GUI has updated after switching protocol.
+  @screen.wait("PidginAddAccountXMPPDomain.png", 5)
+  @screen.click_mid_right_edge("PidginAddAccountXMPPUsername.png")
+  @screen.type(account["username"])
+  @screen.click_mid_right_edge("PidginAddAccountXMPPDomain.png")
+  @screen.type(account["domain"])
+  @screen.click_mid_right_edge("PidginAddAccountXMPPPassword.png")
+  @screen.type(account["password"])
+  @screen.click("PidginAddAccountXMPPRememberPassword.png")
+  if account["connect_server"]
+    @screen.click("PidginAddAccountXMPPAdvancedTab.png")
+    @screen.click_mid_right_edge("PidginAddAccountXMPPConnectServer.png")
+    @screen.type(account["connect_server"])
+  end
+  @screen.click("PidginAddAccountXMPPAddButton.png")
+end
+
+Then /^Pidgin automatically enables my XMPP account$/ do
+  $vm.focus_window('Buddy List')
+  @screen.wait("PidginAvailableStatus.png", 60*3)
+end
+
+Given /^my XMPP friend goes online( and joins the multi-user chat)?$/ do |join_chat|
+  account = xmpp_account("Friend_account", ["otr_key"])
+  bot_opts = account.select { |k, v| ["connect_server"].include?(k) }
+  if join_chat
+    bot_opts["auto_join"] = [@chat_room_jid]
+  end
+  @friend_name = account["username"]
+  @chatbot = ChatBot.new(account["username"] + "@" + account["domain"],
+                         account["password"], account["otr_key"], bot_opts)
+  @chatbot.start
+  add_after_scenario_hook { @chatbot.stop }
+  $vm.focus_window('Buddy List')
+  @screen.wait("PidginFriendOnline.png", 60)
+end
+
+When /^I start a conversation with my friend$/ do
+  $vm.focus_window('Buddy List')
+  # Clicking the middle, bottom of this image should query our
+  # friend, given it's the only subscribed user that's online, which
+  # we assume.
+  r = @screen.find("PidginFriendOnline.png")
+  bottom_left = r.getBottomLeft()
+  x = bottom_left.getX + r.getW/2
+  y = bottom_left.getY
+  @screen.doubleClick_point(x, y)
+  # Since Pidgin sets the window name to the contact, we have no good
+  # way to identify the conversation window. Let's just look for the
+  # expected menu bar.
+  @screen.wait("PidginConversationWindowMenuBar.png", 10)
+end
+
+And /^I say something to my friend( in the multi-user chat)?$/ do |multi_chat|
+  msg = "ping" + Sikuli::Key.ENTER
+  if multi_chat
+    $vm.focus_window(@chat_room_jid.split("@").first)
+    msg = @friend_name + ": " + msg
+  else
+    $vm.focus_window(@friend_name)
+  end
+  @screen.type(msg)
+end
+
+Then /^I receive a response from my friend( in the multi-user chat)?$/ do |multi_chat|
+  if multi_chat
+    $vm.focus_window(@chat_room_jid.split("@").first)
+  else
+    $vm.focus_window(@friend_name)
+  end
+  @screen.wait("PidginFriendExpectedAnswer.png", 20)
+end
+
+When /^I start an OTR session with my friend$/ do
+  $vm.focus_window(@friend_name)
+  @screen.click("PidginConversationOTRMenu.png")
+  @screen.hide_cursor
+  @screen.click("PidginOTRMenuStartSession.png")
+end
+
+Then /^Pidgin automatically generates an OTR key$/ do
+  @screen.wait("PidginOTRKeyGenPrompt.png", 30)
+  @screen.wait_and_click("PidginOTRKeyGenPromptDoneButton.png", 30)
+end
+
+Then /^an OTR session was successfully started with my friend$/ do
+  $vm.focus_window(@friend_name)
+  @screen.wait("PidginConversationOTRUnverifiedSessionStarted.png", 10)
+end
+
+# The reason the chat must be empty is to guarantee that we don't mix
+# up messages/events from other users with the ones we expect from the
+# bot.
+When /^I join some empty multi-user chat$/ do
+  $vm.focus_window('Buddy List')
+  @screen.click("PidginBuddiesMenu.png")
+  @screen.wait_and_click("PidginBuddiesMenuJoinChat.png", 10)
+  @screen.wait_and_click("PidginJoinChatWindow.png", 10)
+  @screen.click_mid_right_edge("PidginJoinChatRoomLabel.png")
+  account = xmpp_account("Tails_account")
+  if account.has_key?("chat_room") && \
+     !account["chat_room"].nil? && \
+     !account["chat_room"].empty?
+    chat_room = account["chat_room"]
+  else
+    chat_room = random_alnum_string(10, 15)
+  end
+  @screen.type(chat_room)
+
+  # We will need the conference server later, when starting the bot.
+  @screen.click_mid_right_edge("PidginJoinChatServerLabel.png")
+  @screen.type("a", Sikuli::KeyModifier.CTRL)
+  @screen.type("c", Sikuli::KeyModifier.CTRL)
+  conference_server =
+    $vm.execute_successfully("xclip -o", :user => LIVE_USER).stdout.chomp
+  @chat_room_jid = chat_room + "@" + conference_server
+
+  @screen.click("PidginJoinChatButton.png")
+  # The following will both make sure that the we joined the chat, and
+  # that it is empty. We'll also deal with the *potential* "Create New
+  # Room" prompt that Pidgin shows for some server configurations.
+  images = ["PidginCreateNewRoomPrompt.png",
+            "PidginChat1UserInRoom.png"]
+  image_found, _ = @screen.waitAny(images, 30)
+  if image_found == "PidginCreateNewRoomPrompt.png"
+    @screen.click("PidginCreateNewRoomAcceptDefaultsButton.png")
+  end
+  $vm.focus_window(@chat_room_jid)
+  @screen.wait("PidginChat1UserInRoom.png", 10)
+end
+
+# Since some servers save the scrollback, and sends it when joining,
+# it's safer to clear it so we do not get false positives from old
+# messages when looking for a particular response, or similar.
+When /^I clear the multi-user chat's scrollback$/ do
+  $vm.focus_window(@chat_room_jid)
+  @screen.click("PidginConversationMenu.png")
+  @screen.wait_and_click("PidginConversationMenuClearScrollback.png", 10)
+end
+
+Then /^I can see that my friend joined the multi-user chat$/ do
+  $vm.focus_window(@chat_room_jid)
+  @screen.wait("PidginChat2UsersInRoom.png", 60)
+end
+
 def configured_pidgin_accounts
-  accounts = []
-  xml = REXML::Document.new(@vm.file_content('$HOME/.purple/accounts.xml',
-                                             $live_user))
+  accounts = Hash.new
+  xml = REXML::Document.new($vm.file_content('$HOME/.purple/accounts.xml',
+                                             LIVE_USER))
   xml.elements.each("account/account") do |e|
     account   = e.elements["name"].text
     account_name, network = account.split("@")
@@ -9,14 +212,14 @@ def configured_pidgin_accounts
     port      = e.elements["settings/setting[@name='port']"].text
     nickname  = e.elements["settings/setting[@name='username']"].text
     real_name = e.elements["settings/setting[@name='realname']"].text
-    accounts.push({
-                    'name'      => account_name,
-                    'network'   => network,
-                    'protocol'  => protocol,
-                    'port'      => port,
-                    'nickname'  => nickname,
-                    'real_name' => real_name,
-                  })
+    accounts[network] = {
+      'name'      => account_name,
+      'network'   => network,
+      'protocol'  => protocol,
+      'port'      => port,
+      'nickname'  => nickname,
+      'real_name' => real_name,
+    }
   end
 
   return accounts
@@ -26,10 +229,17 @@ def chan_image (account, channel, image)
   images = {
     'irc.oftc.net' => {
       '#tails' => {
-        'roaster'          => 'PidginTailsChannelEntry',
+        'roster'           => 'PidginTailsChannelEntry',
         'conversation_tab' => 'PidginTailsConversationTab',
         'welcome'          => 'PidginTailsChannelWelcome',
       }
+    },
+    'I2P' => {
+      '#i2p'    => {
+        'roster'           => 'PidginI2PChannelEntry',
+        'conversation_tab' => 'PidginI2PConversationTab',
+        'welcome'          => 'PidginI2PChannelWelcome',
+      }
     }
   }
   return images[account][channel][image] + ".png"
@@ -38,21 +248,21 @@ end
 def default_chan (account)
   chans = {
     'irc.oftc.net' => '#tails',
+    'I2P'          => '#i2p',
   }
   return chans[account]
 end
 
 def pidgin_otr_keys
-  return @vm.file_content('$HOME/.purple/otr.private_key', $live_user)
+  return $vm.file_content('$HOME/.purple/otr.private_key', LIVE_USER)
 end
 
 Given /^Pidgin has the expected accounts configured with random nicknames$/ do
-  next if @skip_steps_while_restoring_background
   expected = [
             ["irc.oftc.net", "prpl-irc", "6697"],
             ["127.0.0.1",    "prpl-irc", "6668"],
           ]
-  configured_pidgin_accounts.each() do |account|
+  configured_pidgin_accounts.values.each() do |account|
     assert(account['nickname'] != "XXX_NICK_XXX", "Nickname was no randomised")
     assert_equal(account['nickname'], account['real_name'],
                  "Nickname and real name are not identical: " +
@@ -69,63 +279,118 @@ Given /^Pidgin has the expected accounts configured with random nicknames$/ do
 end
 
 When /^I start Pidgin through the GNOME menu$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.wait_and_click("GnomeApplicationsMenu.png", 10)
-  @screen.wait_and_click("GnomeApplicationsInternet.png", 10)
-  @screen.wait_and_click("GnomeApplicationsPidgin.png", 20)
+  step 'I start "Pidgin" via the GNOME "Internet" applications menu'
 end
 
 When /^I open Pidgin's account manager window$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.type("a", Sikuli::KeyModifier.CTRL) # shortcut for "manage accounts"
+  @screen.wait_and_click('PidginMenuAccounts.png', 20)
+  @screen.wait_and_click('PidginMenuManageAccounts.png', 20)
   step "I see Pidgin's account manager window"
 end
 
 When /^I see Pidgin's account manager window$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.wait("PidginAccountWindow.png", 20)
+  @screen.wait("PidginAccountWindow.png", 40)
 end
 
 When /^I close Pidgin's account manager window$/ do
-  next if @skip_steps_while_restoring_background
   @screen.wait_and_click("PidginAccountManagerCloseButton.png", 10)
 end
 
-When /^I activate the "([^"]+)" Pidgin account$/ do |account|
-  next if @skip_steps_while_restoring_background
+When /^I (de)?activate the "([^"]+)" Pidgin account$/ do |deactivate, account|
   @screen.click("PidginAccount_#{account}.png")
   @screen.type(Sikuli::Key.LEFT + Sikuli::Key.SPACE)
-  # wait for the Pidgin to be connecting, otherwise sometimes the step
-  # that closes the account management dialog happens before the account
-  # is actually enabled
-  @screen.wait("PidginConnecting.png", 5)
+  if deactivate
+    @screen.waitVanish('PidginAccountEnabledCheckbox.png', 5)
+  else
+    # wait for the Pidgin to be connecting, otherwise sometimes the step
+    # that closes the account management dialog happens before the account
+    # is actually enabled
+    @screen.waitAny(['PidginConnecting.png', 'PidginAvailableStatus.png'], 5)
+  end
 end
 
+def deactivate_and_activate_pidgin_account(account)
+  debug_log("Deactivating and reactivating Pidgin account #{account}")
+  step "I open Pidgin's account manager window"
+  step "I deactivate the \"#{account}\" Pidgin account"
+  step "I close Pidgin's account manager window"
+  step "I open Pidgin's account manager window"
+  step "I activate the \"#{account}\" Pidgin account"
+  step "I close Pidgin's account manager window"
+end
+
+
+
 Then /^Pidgin successfully connects to the "([^"]+)" account$/ do |account|
-  next if @skip_steps_while_restoring_background
-  expected_channel_entry = chan_image(account, default_chan(account), 'roaster')
-  @screen.wait(expected_channel_entry, 60)
+  expected_channel_entry = chan_image(account, default_chan(account), 'roster')
+  reconnect_button = 'PidginReconnect.png'
+  recovery_on_failure = Proc.new do
+    if @screen.exists('PidginReconnect.png')
+      @screen.click('PidginReconnect.png')
+    else
+      deactivate_and_activate_pidgin_account(account)
+    end
+  end
+  retrier_method = account == 'I2P' ? method(:retry_i2p) : method(:retry_tor)
+  retrier_method.call(recovery_on_failure) do
+    begin
+      $vm.focus_window('Buddy List')
+    rescue ExecutionFailedInVM
+      # Sometimes focusing the window with xdotool will fail with the
+      # conversation window right on top of it. We'll try to close the
+      # conversation window. At worst, the test will still fail...
+      close_pidgin_conversation_window(account)
+    end
+    on_screen, _ = @screen.waitAny([expected_channel_entry, reconnect_button], 60)
+    unless on_screen == expected_channel_entry
+      raise "Connecting to account #{account} failed."
+    end
+  end
+end
+
+Then /^the "([^"]*)" account only responds to PING and VERSION CTCP requests$/ do |irc_server|
+  ctcp_cmds = [
+    "CLIENTINFO", "DATE", "ERRMSG", "FINGER", "PING", "SOURCE", "TIME",
+    "USERINFO", "VERSION"
+  ]
+  expected_ctcp_replies = {
+    "PING" => /^\d+$/,
+    "VERSION" => /^Purple IRC$/
+  }
+  spam_target = configured_pidgin_accounts[irc_server]["nickname"]
+  ctcp_check = CtcpChecker.new(irc_server, 6667, spam_target, ctcp_cmds,
+                               expected_ctcp_replies)
+  ctcp_check.verify_ctcp_responses
 end
 
 Then /^I can join the "([^"]+)" channel on "([^"]+)"$/ do |channel, account|
-  next if @skip_steps_while_restoring_background
-  @screen.doubleClick(   chan_image(account, channel, 'roaster'))
-  @screen.wait_and_click(chan_image(account, channel, 'conversation_tab'), 10)
+  @screen.doubleClick(   chan_image(account, channel, 'roster'))
+  @screen.hide_cursor
+  focus_pidgin_irc_conversation_window(account)
+  try_for(60) do
+    begin
+      @screen.wait_and_click(chan_image(account, channel, 'conversation_tab'), 5)
+    rescue FindFailed => e
+      # If the channel tab can't be found it could be because there were
+      # multiple connection attempts and the channel tab we want is off the
+      # screen. We'll try closing tabs until the one we want can be found.
+      @screen.type("w", Sikuli::KeyModifier.CTRL)
+      raise e
+    end
+  end
+  @screen.hide_cursor
   @screen.wait(          chan_image(account, channel, 'welcome'), 10)
 end
 
 Then /^I take note of the configured Pidgin accounts$/ do
-  next if @skip_steps_while_restoring_background
   @persistent_pidgin_accounts = configured_pidgin_accounts
 end
 
 Then /^I take note of the OTR key for Pidgin's "([^"]+)" account$/ do |account_name|
-  next if @skip_steps_while_restoring_background
   @persistent_pidgin_otr_keys = pidgin_otr_keys
 end
 
 Then /^Pidgin has the expected persistent accounts configured$/ do
-  next if @skip_steps_while_restoring_background
   current_accounts = configured_pidgin_accounts
   assert(current_accounts <=> @persistent_pidgin_accounts,
          "Currently configured Pidgin accounts do not match the persistent ones:\n" +
@@ -135,7 +400,6 @@ Then /^Pidgin has the expected persistent accounts configured$/ do
 end
 
 Then /^Pidgin has the expected persistent OTR keys$/ do
-  next if @skip_steps_while_restoring_background
   assert_equal(pidgin_otr_keys, @persistent_pidgin_otr_keys)
 end
 
@@ -143,6 +407,7 @@ def pidgin_add_certificate_from (cert_file)
   # Here, we need a certificate that is not already in the NSS database
   step "I copy \"/usr/share/ca-certificates/spi-inc.org/spi-cacert-2008.crt\" to \"#{cert_file}\" as user \"amnesia\""
 
+  $vm.focus_window('Buddy List')
   @screen.wait_and_click('PidginToolsMenu.png', 10)
   @screen.wait_and_click('PidginCertificatesMenuItem.png', 10)
   @screen.wait('PidginCertificateManagerDialog.png', 10)
@@ -162,20 +427,19 @@ def pidgin_add_certificate_from (cert_file)
 end
 
 Then /^I can add a certificate from the "([^"]+)" directory to Pidgin$/ do |cert_dir|
-  next if @skip_steps_while_restoring_background
   pidgin_add_certificate_from("#{cert_dir}/test.crt")
-  @screen.wait('PidginCertificateAddHostnameDialog.png', 10)
+  wait_and_focus('PidginCertificateAddHostnameDialog.png', 10, 'Certificate Import')
   @screen.type("XXX test XXX" + Sikuli::Key.ENTER)
-  @screen.wait('PidginCertificateTestItem.png', 10)
+  wait_and_focus('PidginCertificateTestItem.png', 10, 'Certificate Manager')
 end
 
 Then /^I cannot add a certificate from the "([^"]+)" directory to Pidgin$/ do |cert_dir|
-  next if @skip_steps_while_restoring_background
   pidgin_add_certificate_from("#{cert_dir}/test.crt")
-  @screen.wait('PidginCertificateImportFailed.png', 10)
+  wait_and_focus('PidginCertificateImportFailed.png', 10, 'Import Error')
 end
 
 When /^I close Pidgin's certificate manager$/ do
+  wait_and_focus('PidginCertificateManagerDialog.png', 10, 'Certificate Manager')
   @screen.type(Sikuli::Key.ESC)
   # @screen.wait_and_click('PidginCertificateManagerClose.png', 10)
   @screen.waitVanish('PidginCertificateManagerDialog.png', 10)
@@ -186,3 +450,18 @@ When /^I close Pidgin's certificate import failure dialog$/ do
   # @screen.wait_and_click('PidginCertificateManagerClose.png', 10)
   @screen.waitVanish('PidginCertificateImportFailed.png', 10)
 end
+
+When /^I see the Tails roadmap URL$/ do
+  try_for(60) do
+    begin
+      @screen.find('PidginTailsRoadmapUrl.png')
+    rescue FindFailed => e
+      @screen.type(Sikuli::Key.PAGE_UP)
+      raise e
+    end
+  end
+end
+
+When /^I click on the Tails roadmap URL$/ do
+  @screen.click('PidginTailsRoadmapUrl.png')
+end
diff --git a/features/step_definitions/po.rb b/features/step_definitions/po.rb
new file mode 100644
index 0000000..c73bace
--- /dev/null
+++ b/features/step_definitions/po.rb
@@ -0,0 +1,8 @@
+Given /^I am in the Git branch being tested$/ do
+  Dir.chdir(GIT_DIR)
+end
+
+Then /^all the PO files should be correct$/ do
+  File.exists?('./submodules/jenkins-tools/slaves/check_po')
+  cmd_helper(['./submodules/jenkins-tools/slaves/check_po'])
+end
diff --git a/features/step_definitions/root_access_control.rb b/features/step_definitions/root_access_control.rb
index aaebb0d..ff1bdfc 100644
--- a/features/step_definitions/root_access_control.rb
+++ b/features/step_definitions/root_access_control.rb
@@ -1,21 +1,20 @@
 Then /^I should be able to run administration commands as the live user$/ do
-  next if @skip_steps_while_restoring_background
-  stdout = @vm.execute("echo #{@sudo_password} | sudo -S whoami", $live_user).stdout
-  actual_user = stdout.sub(/^\[sudo\] password for #{$live_user}: /, "").chomp
+  stdout = $vm.execute("echo #{@sudo_password} | sudo -S whoami",
+                       :user => LIVE_USER).stdout
+  actual_user = stdout.sub(/^\[sudo\] password for #{LIVE_USER}: /, "").chomp
   assert_equal("root", actual_user, "Could not use sudo")
 end
 
 Then /^I should not be able to run administration commands as the live user with the "([^"]*)" password$/ do |password|
-  next if @skip_steps_while_restoring_background
-  stderr = @vm.execute("echo #{password} | sudo -S whoami", $live_user).stderr
+  stderr = $vm.execute("echo #{password} | sudo -S whoami",
+                       :user => LIVE_USER).stderr
   sudo_failed = stderr.include?("The administration password is disabled") || stderr.include?("is not allowed to execute")
   assert(sudo_failed, "The administration password is not disabled:" + stderr)
 end
 
 When /^running a command as root with pkexec requires PolicyKit administrator privileges$/ do
-  next if @skip_steps_while_restoring_background
   action = 'org.freedesktop.policykit.exec'
-  action_details = @vm.execute("pkaction --verbose --action-id #{action}").stdout
+  action_details = $vm.execute("pkaction --verbose --action-id #{action}").stdout
   assert(action_details[/\s+implicit any:\s+auth_admin$/],
          "Expected 'auth_admin' for 'any':\n#{action_details}")
   assert(action_details[/\s+implicit inactive:\s+auth_admin$/],
@@ -25,21 +24,19 @@ When /^running a command as root with pkexec requires PolicyKit administrator pr
 end
 
 Then /^I should be able to run a command as root with pkexec$/ do
-  next if @skip_steps_while_restoring_background
   step "I run \"pkexec touch /root/pkexec-test\" in GNOME Terminal"
   step 'I enter the sudo password in the pkexec prompt'
   try_for(10, :msg => 'The /root/pkexec-test file was not created.') {
-    @vm.execute('ls /root/pkexec-test').success?
+    $vm.execute('ls /root/pkexec-test').success?
   }
 end
 
 Then /^I should not be able to run a command as root with pkexec and the standard passwords$/ do
-  next if @skip_steps_while_restoring_background
   step "I run \"pkexec touch /root/pkexec-test\" in GNOME Terminal"
-  ['', 'live'].each do |password|
+  ['', 'live', 'amnesia'].each do |password|
     step "I enter the \"#{password}\" password in the pkexec prompt"
     @screen.wait('PolicyKitAuthFailure.png', 20)
   end
-  step "I enter the \"amnesia\" password in the pkexec prompt"
+  @screen.type(Sikuli::Key.ESC)
   @screen.wait('PolicyKitAuthCompleteFailure.png', 20)
 end
diff --git a/features/step_definitions/snapshots.rb b/features/step_definitions/snapshots.rb
new file mode 100644
index 0000000..0e9ae3b
--- /dev/null
+++ b/features/step_definitions/snapshots.rb
@@ -0,0 +1,211 @@
+def checkpoints
+  {
+    'tails-greeter' => {
+      :description => "I have started Tails from DVD without network and stopped at Tails Greeter's login screen",
+      :parent_checkpoint => nil,
+      :steps => [
+        'the network is unplugged',
+        'I start the computer',
+        'the computer boots Tails'
+      ],
+    },
+
+    'no-network-logged-in' => {
+      :description => "I have started Tails from DVD without network and logged in",
+      :parent_checkpoint => "tails-greeter",
+      :steps => [
+        'I log in to a new session',
+        'Tails Greeter has dealt with the sudo password',
+        'the Tails desktop is ready',
+      ],
+    },
+
+    'with-no-network-and-i2p' => {
+      :temporary => true,
+      :description => 'I have started Tails from DVD with I2P enabled and logged in',
+      :steps => [
+        'I set Tails to boot with options "i2p"',
+        'the network is unplugged',
+        'I start the computer',
+        'the computer boots Tails',
+        'I log in to a new session',
+        'the Tails desktop is ready',
+      ],
+    },
+
+    'with-network-and-i2p' => {
+      :temporary => true,
+      :description => 'I have started Tails from DVD with I2P enabled and logged in and the network is connected',
+      :parent_checkpoint => "with-no-network-and-i2p",
+      :steps => [
+        'the network is plugged',
+        'Tor is ready',
+        'I2P is running',
+        'all notifications have disappeared',
+        'available upgrades have been checked',
+        "I2P's reseeding completed",
+      ],
+    },
+
+    'with-network-logged-in' => {
+      :description => "I have started Tails from DVD and logged in and the network is connected",
+      :parent_checkpoint => "no-network-logged-in",
+      :steps => [
+        'the network is plugged',
+        'Tor is ready',
+        'all notifications have disappeared',
+        'available upgrades have been checked',
+      ],
+    },
+
+    'no-network-bridge-mode' => {
+      :temporary => true,
+      :description => "I have started Tails from DVD without network and logged in with bridge mode enabled",
+      :parent_checkpoint => "tails-greeter",
+      :steps => [
+        'I enable more Tails Greeter options',
+        'I enable the specific Tor configuration option',
+        'I log in to a new session',
+        'Tails Greeter has dealt with the sudo password',
+        'the Tails desktop is ready',
+        'all notifications have disappeared',
+      ],
+    },
+
+    'no-network-logged-in-sudo-passwd' => {
+      :temporary => true,
+      :description => "I have started Tails from DVD without network and logged in with an administration password",
+      :parent_checkpoint => "tails-greeter",
+      :steps => [
+        'I enable more Tails Greeter options',
+        'I set an administration password',
+        'I log in to a new session',
+        'Tails Greeter has dealt with the sudo password',
+        'the Tails desktop is ready',
+      ],
+    },
+
+    'with-network-logged-in-sudo-passwd' => {
+      :temporary => true,
+      :description => "I have started Tails from DVD and logged in with an administration password and the network is connected",
+      :parent_checkpoint => "no-network-logged-in-sudo-passwd",
+      :steps => [
+        'the network is plugged',
+        'Tor is ready',
+        'all notifications have disappeared',
+        'available upgrades have been checked',
+      ],
+    },
+
+    'usb-install-tails-greeter' => {
+      :description => "I have started Tails without network from a USB drive without a persistent partition and stopped at Tails Greeter's login screen",
+      :parent_checkpoint => 'no-network-logged-in',
+      :steps => [
+        'I create a 4 GiB disk named "__internal"',
+        'I plug USB drive "__internal"',
+        'I "Clone & Install" Tails to USB drive "__internal"',
+        'the running Tails is installed on USB drive "__internal"',
+        'there is no persistence partition on USB drive "__internal"',
+        'I shutdown Tails and wait for the computer to power off',
+        'I start Tails from USB drive "__internal" with network unplugged',
+        'the boot device has safe access rights',
+        'Tails is running from USB drive "__internal"',
+        'there is no persistence partition on USB drive "__internal"',
+        'process "udev-watchdog" is running',
+        'udev-watchdog is monitoring the correct device',
+      ],
+    },
+
+    'usb-install-logged-in' => {
+      :description => "I have started Tails without network from a USB drive without a persistent partition and logged in",
+      :parent_checkpoint => 'usb-install-tails-greeter',
+      :steps => [
+        'I log in to a new session',
+        'the Tails desktop is ready',
+      ],
+    },
+
+    'usb-install-with-persistence-tails-greeter' => {
+      :description => "I have started Tails without network from a USB drive with a persistent partition and stopped at Tails Greeter's login screen",
+      :parent_checkpoint => 'usb-install-logged-in',
+      :steps => [
+        'I create a persistent partition',
+        'a Tails persistence partition exists on USB drive "__internal"',
+        'I shutdown Tails and wait for the computer to power off',
+        'I start Tails from USB drive "__internal" with network unplugged',
+        'the boot device has safe access rights',
+        'Tails is running from USB drive "__internal"',
+        'process "udev-watchdog" is running',
+        'udev-watchdog is monitoring the correct device',
+      ],
+    },
+
+    'usb-install-with-persistence-logged-in' => {
+      :description => "I have started Tails without network from a USB drive with a persistent partition enabled and logged in",
+      :parent_checkpoint => 'usb-install-with-persistence-tails-greeter',
+      :steps => [
+        'I enable persistence',
+        'I log in to a new session',
+        'the Tails desktop is ready',
+        'all persistence presets are enabled',
+        'all persistent filesystems have safe access rights',
+        'all persistence configuration files have safe access rights',
+        'all persistent directories have safe access rights',
+      ],
+    },
+  }
+end
+
+def reach_checkpoint(name)
+  scenario_indent = " "*4
+  step_indent = " "*6
+
+  step "a computer"
+  if VM.snapshot_exists?(name)
+    $vm.restore_snapshot(name)
+    post_snapshot_restore_hook
+  else
+    checkpoint = checkpoints[name]
+    checkpoint_description = checkpoint[:description]
+    parent_checkpoint = checkpoint[:parent_checkpoint]
+    steps = checkpoint[:steps]
+    if parent_checkpoint
+      if VM.snapshot_exists?(parent_checkpoint)
+        $vm.restore_snapshot(parent_checkpoint)
+      else
+        reach_checkpoint(parent_checkpoint)
+      end
+      post_snapshot_restore_hook
+    end
+    debug_log(scenario_indent + "Checkpoint: #{checkpoint_description}",
+              :color => :white)
+    step_action = "Given"
+    if parent_checkpoint
+      parent_description = checkpoints[parent_checkpoint][:description]
+      debug_log(step_indent + "#{step_action} #{parent_description}",
+                :color => :green)
+      step_action = "And"
+    end
+    steps.each do |s|
+      begin
+        step(s)
+      rescue Exception => e
+        debug_log(scenario_indent +
+                  "Step failed while creating checkpoint: #{s}",
+                  :color => :red)
+        raise e
+      end
+      debug_log(step_indent + "#{step_action} #{s}", :color => :green)
+      step_action = "And"
+    end
+    $vm.save_snapshot(name)
+  end
+end
+
+# For each checkpoint we generate a step to reach it.
+checkpoints.each do |name, desc|
+  step_regex = Regexp.new("^#{Regexp.escape(desc[:description])}$")
+  Given step_regex do
+    reach_checkpoint(name)
+  end
+end
diff --git a/features/step_definitions/ssh.rb b/features/step_definitions/ssh.rb
new file mode 100644
index 0000000..038b297
--- /dev/null
+++ b/features/step_definitions/ssh.rb
@@ -0,0 +1,122 @@
+require 'socket'
+
+def assert_not_ipaddr(s)
+  err_msg = "'#{s}' looks like a LAN IP address."
+  assert_raise(IPAddr::InvalidAddressError, err_msg) do
+    IPAddr.new(s)
+  end
+end
+
+def read_and_validate_ssh_config srv_type
+  conf  = $config[srv_type]
+  begin
+    required_settings = ["private_key", "public_key", "username", "hostname"]
+    required_settings.each do |key|
+      assert(conf.has_key?(key))
+      assert_not_nil(conf[key])
+      assert(!conf[key].empty?)
+    end
+  rescue NoMethodError
+    raise(
+      <<EOF
+Your #{srv_type} config is incorrect or missing from your local configuration file (#{LOCAL_CONFIG_FILE}). See wiki/src/contribute/release_process/test/usage.mdwn for the format.
+EOF
+    )
+  end
+
+  case srv_type
+  when 'SSH'
+    @ssh_host        = conf["hostname"]
+    @ssh_port        = conf["port"].to_i if conf["port"]
+    @ssh_username    = conf["username"]
+    assert_not_ipaddr(@ssh_host)
+  when 'SFTP'
+    @sftp_host       = conf["hostname"]
+    @sftp_port       = conf["port"].to_i if conf["port"]
+    @sftp_username   = conf["username"]
+    assert_not_ipaddr(@sftp_host)
+  end
+end
+
+Given /^I have the SSH key pair for an? (Git|SSH|SFTP) (?:repository|server)( on the LAN)?$/ do |server_type, lan|
+  $vm.execute_successfully("install -m 0700 -d '/home/#{LIVE_USER}/.ssh/'",
+                           :user => LIVE_USER)
+  unless server_type == 'Git' || lan
+    read_and_validate_ssh_config server_type
+    secret_key = $config[server_type]["private_key"]
+    public_key = $config[server_type]["public_key"]
+  else
+    secret_key = $config["Unsafe_SSH_private_key"]
+    public_key = $config["Unsafe_SSH_public_key"]
+  end
+
+  $vm.execute_successfully("echo '#{secret_key}' > '/home/#{LIVE_USER}/.ssh/id_rsa'",
+                           :user => LIVE_USER)
+  $vm.execute_successfully("echo '#{public_key}' > '/home/#{LIVE_USER}/.ssh/id_rsa.pub'",
+                           :user => LIVE_USER)
+  $vm.execute_successfully("chmod 0600 '/home/#{LIVE_USER}/.ssh/'id*",
+                           :user => LIVE_USER)
+end
+
+Given /^I (?:am prompted to )?verify the SSH fingerprint for the (?:Git|SSH) (?:repository|server)$/ do
+  @screen.wait("SSHFingerprint.png", 60)
+  @screen.type('yes' + Sikuli::Key.ENTER)
+end
+
+def get_free_tcp_port
+  server = TCPServer.new('127.0.0.1', 0)
+  return server.addr[1]
+ensure
+  server.close
+end
+
+Given /^an SSH server is running on the LAN$/ do
+  @sshd_server_port = get_free_tcp_port
+  @sshd_server_host = $vmnet.bridge_ip_addr
+  sshd = SSHServer.new(@sshd_server_host, @sshd_server_port)
+  sshd.start
+  add_after_scenario_hook { sshd.stop }
+end
+
+When /^I connect to an SSH server on the (Internet|LAN)$/ do |location|
+
+  case location
+  when 'Internet'
+    read_and_validate_ssh_config "SSH"
+  when 'LAN'
+    @ssh_port = @sshd_server_port
+    @ssh_username = 'user'
+    @ssh_host = @sshd_server_host
+  end
+
+  ssh_port_suffix = "-p #{@ssh_port}" if @ssh_port
+
+  cmd = "ssh #{@ssh_username}@#{@ssh_host} #{ssh_port_suffix}"
+
+  step 'process "ssh" is not running'
+  step "I run \"#{cmd}\" in GNOME Terminal"
+  step 'process "ssh" is running within 10 seconds'
+end
+
+Then /^I have sucessfully logged into the SSH server$/ do
+  @screen.wait('SSHLoggedInPrompt.png', 60)
+end
+
+Then /^I connect to an SFTP server on the Internet$/ do
+  read_and_validate_ssh_config "SFTP"
+  @sftp_port ||= 22
+  @sftp_port = @sftp_port.to_s
+  step 'I start "Files" via the GNOME "Accessories" applications menu'
+  @screen.wait_and_click("GnomeFilesConnectToServer.png", 10)
+  @screen.wait("GnomeConnectToServerWindow.png", 10)
+  @screen.type("sftp://" + @sftp_username + "@" + @sftp_host + ":" + @sftp_port)
+  @screen.wait_and_click("GnomeConnectToServerConnectButton.png", 10)
+end
+
+Then /^I verify the SSH fingerprint for the SFTP server$/ do
+  @screen.wait_and_click("GnomeSSHVerificationConfirm.png", 60)
+end
+
+Then /^I successfully connect to the SFTP server$/ do
+  @screen.wait("GnomeSSHSuccess.png", 60)
+end
diff --git a/features/step_definitions/time_syncing.rb b/features/step_definitions/time_syncing.rb
index 161a416..319fb52 100644
--- a/features/step_definitions/time_syncing.rb
+++ b/features/step_definitions/time_syncing.rb
@@ -1,16 +1,44 @@
+# In some steps below we allow some slack when verifying that the date
+# was set appropriately because it may take time to send the `date`
+# command over the remote shell and get the answer back, parsing and
+# post-processing of the result, etc.
+def max_time_drift
+  10
+end
+
 When /^I set the system time to "([^"]+)"$/ do |time|
-  next if @skip_steps_while_restoring_background
-  @vm.execute("date -s '#{time}'")
+  $vm.execute_successfully("date -s '#{time}'")
+  new_time = DateTime.parse($vm.execute_successfully("date").stdout).to_time
+  expected_time_lower_bound = DateTime.parse(time).to_time
+  expected_time_upper_bound = expected_time_lower_bound + max_time_drift
+  assert(expected_time_lower_bound <= new_time &&
+         new_time <= expected_time_upper_bound,
+         "The guest's time was supposed to be set to " \
+         "'#{expected_time_lower_bound}' but is '#{new_time}'")
 end
 
-When /^I bump the system time with "([^"]+)"$/ do |timediff|
-  next if @skip_steps_while_restoring_background
-  @vm.execute("date -s 'now #{timediff}'")
+When /^I bump the (hardware clock's|system) time with "([^"]+)"$/ do |clock_type, timediff|
+  case clock_type
+  when "hardware clock's"
+    old_time = DateTime.parse($vm.execute_successfully("hwclock -r").stdout).to_time
+    $vm.execute_successfully("hwclock --set --date 'now #{timediff}'")
+    new_time = DateTime.parse($vm.execute_successfully("hwclock -r").stdout).to_time
+  when 'system'
+    old_time = DateTime.parse($vm.execute_successfully("date").stdout).to_time
+    $vm.execute_successfully("date -s 'now #{timediff}'")
+    new_time = DateTime.parse($vm.execute_successfully("date").stdout).to_time
+  end
+  expected_time_lower_bound = DateTime.parse(
+      cmd_helper(["date", "-d", "#{old_time} #{timediff}"])).to_time
+  expected_time_upper_bound = expected_time_lower_bound + max_time_drift
+  assert(expected_time_lower_bound <= new_time &&
+         new_time <= expected_time_upper_bound,
+         "The #{clock_type} time was supposed to be bumped to " \
+         "'#{expected_time_lower_bound}' but is '#{new_time}'")
 end
 
 Then /^Tails clock is less than (\d+) minutes incorrect$/ do |max_diff_mins|
-  next if @skip_steps_while_restoring_background
-  guest_time_str = @vm.execute("date --rfc-2822").stdout.chomp
+  guest_time_str = $vm.execute("date --rfc-2822").stdout.chomp
   guest_time = Time.rfc2822(guest_time_str)
   host_time = Time.now
   diff = (host_time - guest_time).abs
@@ -18,3 +46,41 @@ Then /^Tails clock is less than (\d+) minutes incorrect$/ do |max_diff_mins|
          "The guest's clock is off by #{diff} seconds (#{guest_time})")
   puts "Time was #{diff} seconds off"
 end
+
+Then /^the system clock is just past Tails' build date$/ do
+  system_time_str = $vm.execute_successfully('date').to_s
+  system_time = DateTime.parse(system_time_str).to_time
+  build_time_cmd = 'sed -n -e "1s/^.* - \([0-9]\+\)$/\1/p;q" ' +
+                   '/etc/amnesia/version'
+  build_time_str = $vm.execute_successfully(build_time_cmd).to_s
+  build_time = DateTime.parse(build_time_str).to_time
+  diff = system_time - build_time  # => in seconds
+  # Half an hour should be enough to boot Tails on any reasonable
+  # hardware and VM setup.
+  max_diff = 30*60
+  assert(diff > 0,
+         "The system time (#{system_time}) is before the Tails " +
+         "build date (#{build_time})")
+  assert(diff <= max_diff,
+         "The system time (#{system_time}) is more than #{max_diff} seconds " +
+         "past the build date (#{build_time})")
+end
+
+Then /^Tails' hardware clock is close to the host system's time$/ do
+  host_time = Time.now
+  hwclock_time_str = $vm.execute('hwclock -r').stdout.chomp
+  hwclock_time = DateTime.parse(hwclock_time_str).to_time
+  diff = (hwclock_time - host_time).abs
+  assert(diff <= max_time_drift)
+end
+
+Then /^the hardware clock is still off by "([^"]+)"$/ do |timediff|
+  hwclock = DateTime.parse($vm.execute_successfully("hwclock -r").stdout.chomp).to_time
+  expected_time_lower_bound = DateTime.parse(
+      cmd_helper(["date", "-d", "now #{timediff}"])).to_time - max_time_drift
+  expected_time_upper_bound = expected_time_lower_bound + max_time_drift
+  assert(expected_time_lower_bound <= hwclock &&
+         hwclock <= expected_time_upper_bound,
+         "The host's hwclock should be approximately " \
+         "'#{expected_time_lower_bound}' but is actually '#{hwclock}'")
+end
diff --git a/features/step_definitions/tor.rb b/features/step_definitions/tor.rb
new file mode 100644
index 0000000..ac12fd4
--- /dev/null
+++ b/features/step_definitions/tor.rb
@@ -0,0 +1,402 @@
+def iptables_chains_parse(iptables, table = "filter", &block)
+  assert(block_given?)
+  cmd = "#{iptables}-save -c -t #{table} | iptables-xml"
+  xml_str = $vm.execute_successfully(cmd).stdout
+  rexml = REXML::Document.new(xml_str)
+  rexml.get_elements('iptables-rules/table/chain').each do |element|
+    yield(
+      element.attribute('name').to_s,
+      element.attribute('policy').to_s,
+      element.get_elements('rule')
+    )
+  end
+end
+
+def ip4tables_chains(table = "filter", &block)
+  iptables_chains_parse('iptables', table, &block)
+end
+
+def ip6tables_chains(table = "filter", &block)
+  iptables_chains_parse('ip6tables', table, &block)
+end
+
+def iptables_rules_parse(iptables, chain, table)
+  iptables_chains_parse(iptables, table) do |name, _, rules|
+    return rules if name == chain
+  end
+  return nil
+end
+
+def iptables_rules(chain, table = "filter")
+  iptables_rules_parse("iptables", chain, table)
+end
+
+def ip6tables_rules(chain, table = "filter")
+  iptables_rules_parse("ip6tables", chain, table)
+end
+
+def ip4tables_packet_counter_sum(filters = {})
+  pkts = 0
+  ip4tables_chains do |name, _, rules|
+    next if filters[:tables] && not(filters[:tables].include?(name))
+    rules.each do |rule|
+      next if filters[:uid] && not(rule.elements["conditions/owner/uid-owner[text()=#{filters[:uid]}]"])
+      pkts += rule.attribute('packet-count').to_s.to_i
+    end
+  end
+  return pkts
+end
+
+def try_xml_element_text(element, xpath, default = nil)
+  node = element.elements[xpath]
+  (node.nil? or not(node.has_text?)) ? default : node.text
+end
+
+Then /^the firewall's policy is to (.+) all IPv4 traffic$/ do |expected_policy|
+  expected_policy.upcase!
+  ip4tables_chains do |name, policy, _|
+    if ["INPUT", "FORWARD", "OUTPUT"].include?(name)
+      assert_equal(expected_policy, policy,
+                   "Chain #{name} has unexpected policy #{policy}")
+    end
+  end
+end
+
+Then /^the firewall is configured to only allow the (.+) users? to connect directly to the Internet over IPv4$/ do |users_str|
+  users = users_str.split(/, | and /)
+  expected_uids = Set.new
+  users.each do |user|
+    expected_uids << $vm.execute_successfully("id -u #{user}").stdout.to_i
+  end
+  allowed_output = iptables_rules("OUTPUT").find_all do |rule|
+    out_iface = rule.elements['conditions/match/o']
+    is_maybe_accepted = rule.get_elements('actions/*').find do |action|
+      not(["DROP", "REJECT", "LOG"].include?(action.name))
+    end
+    is_maybe_accepted &&
+    (
+      # nil => match all interfaces according to iptables-xml
+      out_iface.nil? ||
+      ((out_iface.text == 'lo') == (out_iface.attribute('invert').to_s == '1'))
+    )
+  end
+  uids = Set.new
+  allowed_output.each do |rule|
+    rule.elements.each('actions/*') do |action|
+      destination = try_xml_element_text(rule, "conditions/match/d")
+      if action.name == "ACCEPT"
+        # nil == 0.0.0.0/0 according to iptables-xml
+        assert(destination == '0.0.0.0/0' || destination.nil?,
+               "The following rule has an unexpected destination:\n" +
+               rule.to_s)
+        state_cond = try_xml_element_text(rule, "conditions/state/state")
+        next if state_cond == "RELATED,ESTABLISHED"
+        assert_not_nil(rule.elements['conditions/owner/uid-owner'])
+        rule.elements.each('conditions/owner/uid-owner') do |owner|
+          uid = owner.text.to_i
+          uids << uid
+          assert(expected_uids.include?(uid),
+                 "The following rule allows uid #{uid} to access the " +
+                 "network, but we only expect uids #{expected_uids.to_a} " +
+                 "(#{users_str}) to have such access:\n#{rule.to_s}")
+        end
+      elsif action.name == "call" && action.elements[1].name == "lan"
+        lan_subnets = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
+        assert(lan_subnets.include?(destination),
+               "The following lan-targeted rule's destination is " +
+               "#{destination} which may not be a private subnet:\n" +
+               rule.to_s)
+      else
+        raise "Unexpected iptables OUTPUT chain rule:\n#{rule.to_s}"
+      end
+    end
+  end
+  uids_not_found = expected_uids - uids
+  assert(uids_not_found.empty?,
+         "Couldn't find rules allowing uids #{uids_not_found.to_a.to_s} " \
+         "access to the network")
+end
+
+Then /^the firewall's NAT rules only redirect traffic for Tor's TransPort and DNSPort$/ do
+  loopback_address = "127.0.0.1/32"
+  tor_onion_addr_space = "127.192.0.0/10"
+  tor_trans_port = "9040"
+  dns_port = "53"
+  tor_dns_port = "5353"
+  ip4tables_chains('nat') do |name, _, rules|
+    if name == "OUTPUT"
+      good_rules = rules.find_all do |rule|
+        redirect = rule.get_elements('actions/*').all? do |action|
+          action.name == "REDIRECT"
+        end
+        destination = try_xml_element_text(rule, "conditions/match/d")
+        redir_port = try_xml_element_text(rule, "actions/REDIRECT/to-ports")
+        redirected_to_trans_port = redir_port == tor_trans_port
+        udp_destination_port = try_xml_element_text(rule, "conditions/udp/dport")
+        dns_redirected_to_tor_dns_port = (udp_destination_port == dns_port) &&
+                                         (redir_port == tor_dns_port)
+        redirect &&
+        (
+         (destination == tor_onion_addr_space && redirected_to_trans_port) ||
+         (destination == loopback_address && dns_redirected_to_tor_dns_port)
+        )
+      end
+      bad_rules = rules - good_rules
+      assert(bad_rules.empty?,
+             "The NAT table's OUTPUT chain contains some unexpected " +
+             "rules:\n#{bad_rules}")
+    else
+      assert(rules.empty?,
+             "The NAT table contains unexpected rules for the #{name} " +
+             "chain:\n#{rules}")
+    end
+  end
+end
+
+Then /^the firewall is configured to block all external IPv6 traffic$/ do
+  ip6_loopback = '::1/128'
+  expected_policy = "DROP"
+  ip6tables_chains do |name, policy, rules|
+    assert_equal(expected_policy, policy,
+                 "The IPv6 #{name} chain has policy #{policy} but we " \
+                 "expected #{expected_policy}")
+    good_rules = rules.find_all do |rule|
+      ["DROP", "REJECT", "LOG"].any? do |target|
+        rule.elements["actions/#{target}"]
+      end \
+      ||
+      ["s", "d"].all? do |x|
+        try_xml_element_text(rule, "conditions/match/#{x}") == ip6_loopback
+      end
+    end
+    bad_rules = rules - good_rules
+    assert(bad_rules.empty?,
+           "The IPv6 table's #{name} chain contains some unexpected rules:\n" +
+           (bad_rules.map { |r| r.to_s }).join("\n"))
+  end
+end
+
+def firewall_has_dropped_packet_to?(proto, host, port)
+  regex = "^Dropped outbound packet: .* "
+  regex += "DST=#{Regexp.escape(host)} .* "
+  regex += "PROTO=#{Regexp.escape(proto)} "
+  regex += ".* DPT=#{port} " if port
+  $vm.execute("journalctl --dmesg --output=cat | grep -qP '#{regex}'").success?
+end
+
+When /^I open an untorified (TCP|UDP|ICMP) connections to (\S*)(?: on port (\d+))? that is expected to fail$/ do |proto, host, port|
+  assert(!firewall_has_dropped_packet_to?(proto, host, port),
+         "A #{proto} packet to #{host}" +
+         (port.nil? ? "" : ":#{port}") +
+         " has already been dropped by the firewall")
+  @conn_proto = proto
+  @conn_host = host
+  @conn_port = port
+  case proto
+  when "TCP"
+    assert_not_nil(port)
+    cmd = "echo | netcat #{host} #{port}"
+    user = LIVE_USER
+  when "UDP"
+    assert_not_nil(port)
+    cmd = "echo | netcat -u #{host} #{port}"
+    user = LIVE_USER
+  when "ICMP"
+    cmd = "ping -c 5 #{host}"
+    user = 'root'
+  end
+  @conn_res = $vm.execute(cmd, :user => user)
+end
+
+Then /^the untorified connection fails$/ do
+  case @conn_proto
+  when "TCP"
+    expected_in_stderr = "Connection refused"
+    conn_failed = !@conn_res.success? &&
+      @conn_res.stderr.chomp.end_with?(expected_in_stderr)
+  when "UDP", "ICMP"
+    conn_failed = !@conn_res.success?
+  end
+  assert(conn_failed,
+         "The untorified #{@conn_proto} connection didn't fail as expected:\n" +
+         @conn_res.to_s)
+end
+
+Then /^the untorified connection is logged as dropped by the firewall$/ do
+  assert(firewall_has_dropped_packet_to?(@conn_proto, @conn_host, @conn_port),
+         "No #{@conn_proto} packet to #{@conn_host}" +
+         (@conn_port.nil? ? "" : ":#{@conn_port}") +
+         " was dropped by the firewall")
+end
+
+When /^the system DNS is(?: still)? using the local DNS resolver$/ do
+  resolvconf = $vm.file_content("/etc/resolv.conf")
+  bad_lines = resolvconf.split("\n").find_all do |line|
+    !line.start_with?("#") && !/^nameserver\s+127\.0\.0\.1$/.match(line)
+  end
+  assert_empty(bad_lines,
+               "The following bad lines were found in /etc/resolv.conf:\n" +
+               bad_lines.join("\n"))
+end
+
+def stream_isolation_info(application)
+  case application
+  when "htpdate"
+    {
+      :grep_monitor_expr => '/curl\>',
+      :socksport => 9062
+    }
+  when "tails-security-check", "tails-upgrade-frontend-wrapper"
+    # We only grep connections with ESTABLISHED state since `perl`
+    # is also used by monkeysphere's validation agent, which LISTENs
+    {
+      :grep_monitor_expr => '\<ESTABLISHED\>.\+/perl\>',
+      :socksport => 9062
+    }
+  when "Tor Browser"
+    {
+      :grep_monitor_expr => '/firefox\>',
+      :socksport => 9150
+    }
+  when "Gobby"
+    {
+      :grep_monitor_expr => '/gobby\>',
+      :socksport => 9050
+    }
+  when "SSH"
+    {
+      :grep_monitor_expr => '/\(connect-proxy\|ssh\)\>',
+      :socksport => 9050
+    }
+  when "whois"
+    {
+      :grep_monitor_expr => '/whois\>',
+      :socksport => 9050
+    }
+  else
+    raise "Unknown application '#{application}' for the stream isolation tests"
+  end
+end
+
+When /^I monitor the network connections of (.*)$/ do |application|
+  @process_monitor_log = "/tmp/netstat.log"
+  info = stream_isolation_info(application)
+  $vm.spawn("while true; do " +
+            "  netstat -taupen | grep \"#{info[:grep_monitor_expr]}\"; " +
+            "  sleep 0.1; " +
+            "done > #{@process_monitor_log}")
+end
+
+Then /^I see that (.+) is properly stream isolated$/ do |application|
+  expected_port = stream_isolation_info(application)[:socksport]
+  assert_not_nil(@process_monitor_log)
+  log_lines = $vm.file_content(@process_monitor_log).split("\n")
+  assert(log_lines.size > 0,
+         "Couldn't see any connection made by #{application} so " \
+         "something is wrong")
+  log_lines.each do |line|
+    addr_port = line.split(/\s+/)[4]
+    assert_equal("127.0.0.1:#{expected_port}", addr_port,
+                 "#{application} should use SocksPort #{expected_port} but " \
+                 "was seen connecting to #{addr_port}")
+  end
+end
+
+And /^I re-run tails-security-check$/ do
+  $vm.execute_successfully("tails-security-check", :user => LIVE_USER)
+end
+
+And /^I re-run htpdate$/ do
+  $vm.execute_successfully("service htpdate stop && " \
+                           "rm -f /var/run/htpdate/* && " \
+                           "systemctl --no-block start htpdate.service")
+  step "the time has synced"
+end
+
+And /^I re-run tails-upgrade-frontend-wrapper$/ do
+  $vm.execute_successfully("tails-upgrade-frontend-wrapper", :user => LIVE_USER)
+end
+
+When /^I connect Gobby to "([^"]+)"$/ do |host|
+  @screen.wait("GobbyWindow.png", 30)
+  @screen.wait("GobbyWelcomePrompt.png", 10)
+  @screen.click("GnomeCloseButton.png")
+  @screen.wait("GobbyWindow.png", 10)
+  # This indicates that Gobby has finished initializing itself
+  # (generating DH parameters, etc.) -- before, the UI is not responsive
+  # and our CTRL-t is lost.
+  @screen.wait("GobbyFailedToShareDocuments.png", 30)
+  @screen.type("t", Sikuli::KeyModifier.CTRL)
+  @screen.wait("GobbyConnectPrompt.png", 10)
+  @screen.type(host + Sikuli::Key.ENTER)
+  @screen.wait("GobbyConnectionComplete.png", 60)
+end
+
+When /^the Tor Launcher autostarts$/ do
+  @screen.wait('TorLauncherWindow.png', 60)
+end
+
+When /^I configure some (\w+) pluggable transports in Tor Launcher$/ do |bridge_type|
+  bridge_type.downcase!
+  bridge_type.capitalize!
+  begin
+    @bridges = $config["Tor"]["Transports"][bridge_type]
+    assert_not_nil(@bridges)
+    assert(!@bridges.empty?)
+  rescue NoMethodError, Test::Unit::AssertionFailedError
+    raise(
+<<EOF
+It seems no '#{bridge_type}' pluggable transports are defined in your local configuration file (#{LOCAL_CONFIG_FILE}). See wiki/src/contribute/release_process/test/usage.mdwn for the format.
+EOF
+)
+  end
+  @bridge_hosts = []
+  for bridge in @bridges do
+    @bridge_hosts << bridge["ipv4_address"]
+  end
+
+  @screen.wait_and_click('TorLauncherConfigureButton.png', 10)
+  @screen.wait('TorLauncherBridgePrompt.png', 10)
+  @screen.wait_and_click('TorLauncherYesRadioOption.png', 10)
+  @screen.wait_and_click('TorLauncherNextButton.png', 10)
+  @screen.wait_and_click('TorLauncherBridgeList.png', 10)
+  for bridge in @bridges do
+    bridge_line = bridge_type.downcase   + " " +
+                  bridge["ipv4_address"] + ":" +
+                  bridge["ipv4_port"].to_s
+    bridge_line += " " + bridge["fingerprint"].to_s if bridge["fingerprint"]
+    bridge_line += " " + bridge["extra"].to_s if bridge["extra"]
+    @screen.type(bridge_line + Sikuli::Key.ENTER)
+  end
+  @screen.wait_and_click('TorLauncherNextButton.png', 10)
+  @screen.hide_cursor
+  @screen.wait_and_click('TorLauncherFinishButton.png', 10)
+  @screen.wait('TorLauncherConnectingWindow.png', 10)
+  @screen.waitVanish('TorLauncherConnectingWindow.png', 120)
+end
+
+When /^all Internet traffic has only flowed through the configured pluggable transports$/ do
+  assert_not_nil(@bridge_hosts, "No bridges has been configured via the " +
+                 "'I configure some ... bridges in Tor Launcher' step")
+  leaks = FirewallLeakCheck.new(@sniffer.pcap_file,
+                                :accepted_hosts => @bridge_hosts)
+  leaks.assert_no_leaks
+end
+
+Then /^the Tor binary is configured to use the expected Tor authorities$/ do
+  tor_auths = Set.new
+  tor_binary_orport_strings = $vm.execute_successfully(
+    "strings /usr/bin/tor | grep -E 'orport=[0-9]+'").stdout.chomp.split("\n")
+  tor_binary_orport_strings.each do |potential_auth_string|
+    auth_regex = /^\S+ orport=\d+( bridge)?( no-v2)?( v3ident=[A-Z0-9]{40})? ([0-9\.]+):\d+( [A-Z0-9]{4}){10}$/
+    m = auth_regex.match(potential_auth_string)
+    if m
+      auth_ipv4_addr = m[4]
+      tor_auths << auth_ipv4_addr
+    end
+  end
+  expected_tor_auths = Set.new(TOR_AUTHORITIES)
+  assert_equal(expected_tor_auths, tor_auths,
+               "The Tor binary does not have the expected Tor authorities " +
+               "configured")
+end
diff --git a/features/step_definitions/torified_browsing.rb b/features/step_definitions/torified_browsing.rb
index 770fda5..c8f3ff1 100644
--- a/features/step_definitions/torified_browsing.rb
+++ b/features/step_definitions/torified_browsing.rb
@@ -1,12 +1,5 @@
-When /^I open a new tab in the Tor Browser$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.click("TorBrowserNewTabButton.png")
-end
-
-When /^I open the address "([^"]*)" in the Tor Browser$/ do |address|
-  next if @skip_steps_while_restoring_background
-  step "I open a new tab in the Tor Browser"
-  @screen.click("TorBrowserAddressBar.png")
-  sleep 0.5
-  @screen.type(address + Sikuli::Key.ENTER)
+When /^no traffic has flowed to the LAN$/ do
+  leaks = FirewallLeakCheck.new(@sniffer.pcap_file, :ignore_lan => false)
+  assert(not(leaks.ipv4_tcp_leaks.include?(@lan_host)),
+         "Traffic was sent to LAN host #{@lan_host}")
 end
diff --git a/features/step_definitions/torified_gnupg.rb b/features/step_definitions/torified_gnupg.rb
index 5a1462c..4b4cc04 100644
--- a/features/step_definitions/torified_gnupg.rb
+++ b/features/step_definitions/torified_gnupg.rb
@@ -1,54 +1,208 @@
-When /^the "([^"]*)" OpenPGP key is not in the live user's public keyring$/ do |keyid|
-  next if @skip_steps_while_restoring_background
-  assert(!@vm.execute("gpg --batch --list-keys '#{keyid}'", $live_user).success?,
+class OpenPGPKeyserverCommunicationError < StandardError
+end
+
+def count_gpg_signatures(key)
+  output = $vm.execute_successfully("gpg --batch --list-sigs #{key}",
+                                    :user => LIVE_USER).stdout
+  output.scan(/^sig/).count
+end
+
+def check_for_seahorse_error
+  if @screen.exists('GnomeCloseButton.png')
+    raise OpenPGPKeyserverCommunicationError.new(
+      "Found GnomeCloseButton.png' on the screen"
+    )
+  end
+end
+
+def start_or_restart_seahorse
+  assert_not_nil(@withgpgapplet)
+  if @withgpgapplet
+    seahorse_menu_click_helper('GpgAppletIconNormal.png', 'GpgAppletManageKeys.png')
+  else
+    step 'I start "Seahorse" via the GNOME "Utilities" applications menu'
+  end
+  step 'Seahorse has opened'
+end
+
+Then /^the key "([^"]+)" has (only|more than) (\d+) signatures$/ do |key, qualifier, num|
+  count = count_gpg_signatures(key)
+  case qualifier
+  when 'only'
+    assert_equal(count, num.to_i, "Expected #{num} signatures but instead found #{count}")
+  when 'more than'
+    assert(count > num.to_i, "Expected more than #{num} signatures but found #{count}")
+  else
+    raise "Unknown operator #{qualifier} passed"
+  end
+end
+
+When /^the "([^"]+)" OpenPGP key is not in the live user's public keyring$/ do |keyid|
+  assert(!$vm.execute("gpg --batch --list-keys '#{keyid}'",
+                      :user => LIVE_USER).success?,
          "The '#{keyid}' key is in the live user's public keyring.")
 end
 
-When /^I fetch the "([^"]*)" OpenPGP key using the GnuPG CLI$/ do |keyid|
-  next if @skip_steps_while_restoring_background
-  @gnupg_recv_key_res = @vm.execute(
-    "gpg --batch --recv-key '#{keyid}'",
-    $live_user)
+When /^I fetch the "([^"]+)" OpenPGP key using the GnuPG CLI( without any signatures)?$/ do |keyid, without|
+  # Make keyid an instance variable so we can reference it in the Seahorse
+  # keysyncing step.
+  @fetched_openpgp_keyid = keyid
+  if without
+    importopts = '--keyserver-options import-clean'
+  else
+    importopts = ''
+  end
+  retry_tor do
+    @gnupg_recv_key_res = $vm.execute_successfully(
+      "timeout 120 gpg --batch #{importopts} --recv-key '#{@fetched_openpgp_keyid}'",
+      :user => LIVE_USER)
+    if @gnupg_recv_key_res.failure?
+      raise "Fetching keys with the GnuPG CLI failed with:\n" +
+            "#{@gnupg_recv_key_res.stdout}\n" +
+            "#{@gnupg_recv_key_res.stderr}"
+    end
+  end
 end
 
 When /^the GnuPG fetch is successful$/ do
-  next if @skip_steps_while_restoring_background
   assert(@gnupg_recv_key_res.success?,
          "gpg keyserver fetch failed:\n#{@gnupg_recv_key_res.stderr}")
 end
 
+When /^the Seahorse operation is successful$/ do
+  !@screen.exists('GnomeCloseButton.png')
+  $vm.has_process?('seahorse')
+end
+
 When /^GnuPG uses the configured keyserver$/ do
-  next if @skip_steps_while_restoring_background
-  assert(@gnupg_recv_key_res.stderr[$configured_keyserver_hostname],
-         "GnuPG's stderr did not mention keyserver #{$configured_keyserver_hostname}")
+  assert(@gnupg_recv_key_res.stderr[CONFIGURED_KEYSERVER_HOSTNAME],
+         "GnuPG's stderr did not mention keyserver #{CONFIGURED_KEYSERVER_HOSTNAME}")
 end
 
-When /^the "([^"]*)" key is in the live user's public keyring after at most (\d+) seconds$/ do |keyid, delay|
-  next if @skip_steps_while_restoring_background
-  try_for(delay.to_f, :msg => "The '#{keyid}' key is not in the live user's public keyring") {
-    @vm.execute("gpg --batch --list-keys '#{keyid}'", $live_user).success?
+When /^the "([^"]+)" key is in the live user's public keyring(?: after at most (\d) seconds)?$/ do |keyid, delay|
+  delay = 10 unless delay
+  try_for(delay.to_i, :msg => "The '#{keyid}' key is not in the live user's public keyring") {
+    $vm.execute("gpg --batch --list-keys '#{keyid}'",
+                :user => LIVE_USER).success?
   }
 end
 
-When /^I start Seahorse$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.wait_and_click("GnomeApplicationsMenu.png", 10)
-  @screen.wait_and_click("GnomeApplicationsSystem.png", 10)
-  @screen.wait_and_click("GnomeApplicationsPreferences.png", 10)
-  @screen.wait_and_click("GnomeApplicationsSeahorse.png", 10)
-end
-
-When /^I fetch the "([^"]*)" OpenPGP key using Seahorse$/ do |keyid|
-  next if @skip_steps_while_restoring_background
-  step "I start Seahorse"
-  @screen.wait("SeahorseWindow.png", 10)
-  @screen.type("r", Sikuli::KeyModifier.ALT) # Menu: "Remote" ->
-  @screen.type("f")                  # "Find Remote Keys...".
-  @screen.wait("SeahorseFindKeysWindow.png", 10)
-  # Seahorse doesn't seem to support searching for fingerprints
-  @screen.type(keyid + Sikuli::Key.ENTER)
-  @screen.wait("SeahorseFoundKeyResult.png", 5*60)
-  @screen.type(Sikuli::Key.DOWN)   # Select first item in result menu
-  @screen.type("f", Sikuli::KeyModifier.ALT) # Menu: "File" ->
-  @screen.type("i")                  # "Import"
+When /^I start Seahorse( via the Tails OpenPGP Applet)?$/ do |withgpgapplet|
+  @withgpgapplet = !!withgpgapplet
+  start_or_restart_seahorse
+end
+
+Then /^Seahorse has opened$/ do
+  @screen.wait('SeahorseWindow.png', 20)
+end
+
+Then /^I enable key synchronization in Seahorse$/ do
+  step 'process "seahorse" is running'
+  @screen.wait_and_click("SeahorseWindow.png", 10)
+  seahorse_menu_click_helper('GnomeEditMenu.png', 'SeahorseEditPreferences.png', 'seahorse')
+  @screen.wait('SeahorsePreferences.png', 20)
+  @screen.type("p", Sikuli::KeyModifier.ALT) # Option: "Publish keys to...".
+  @screen.type(Sikuli::Key.DOWN) # select HKP server
+  @screen.type("c", Sikuli::KeyModifier.ALT) # Button: "Close"
+end
+
+Then /^I synchronize keys in Seahorse$/ do
+  recovery_proc = Proc.new do
+    # The versions of Seahorse in Wheezy and Jessie will abort with a
+    # segmentation fault whenever there's any sort of network error while
+    # syncing keys. This will usually happens after clicking away the error
+    # message. This does not appear to be a problem in Stretch.
+    #
+    # We'll kill the Seahorse process to avoid waiting for the inevitable
+    # segfault. We'll also make sure the process is still running (=  hasn't
+    # yet segfaulted) before terminating it.
+    if @screen.exists('GnomeCloseButton.png') || !$vm.has_process?('seahorse')
+      step 'I kill the process "seahorse"' if $vm.has_process?('seahorse')
+      debug_log('Restarting Seahorse.')
+      start_or_restart_seahorse
+    end
+  end
+
+  def change_of_status?
+    # Due to a lack of visual feedback in Seahorse we'll break out of the
+    # try_for loop below by returning "true" when there's something we can act
+    # upon.
+    if count_gpg_signatures(@fetched_openpgp_keyid) > 2 || \
+      @screen.exists('GnomeCloseButton.png')  || \
+      !$vm.has_process?('seahorse')
+        true
+    end
+  end
+
+  retry_tor(recovery_proc) do
+    @screen.wait_and_click("SeahorseWindow.png", 10)
+    seahorse_menu_click_helper('SeahorseRemoteMenu.png',
+                               'SeahorseRemoteMenuSync.png',
+                               'seahorse')
+    @screen.wait('SeahorseSyncKeys.png', 20)
+    @screen.type("s", Sikuli::KeyModifier.ALT) # Button: Sync
+    # There's no visual feedback of Seahorse in Tails/Jessie, except on error.
+    try_for(120) {
+      change_of_status?
+    }
+    check_for_seahorse_error
+    raise OpenPGPKeyserverCommunicationError.new(
+      'Seahorse crashed with a segfault.') unless $vm.has_process?('seahorse')
+   end
+end
+
+When /^I fetch the "([^"]+)" OpenPGP key using Seahorse( via the Tails OpenPGP Applet)?$/ do |keyid, withgpgapplet|
+  step "I start Seahorse#{withgpgapplet}"
+
+  def change_of_status?(keyid)
+    # Due to a lack of visual feedback in Seahorse we'll break out of the
+    # try_for loop below by returning "true" when there's something we can act
+    # upon.
+    if $vm.execute_successfully(
+      "gpg --batch --list-keys '#{keyid}'", :user => LIVE_USER) ||
+      @screen.exists('GnomeCloseButton.png')
+      true
+    end
+  end
+
+  recovery_proc = Proc.new do
+    @screen.click('GnomeCloseButton.png') if @screen.exists('GnomeCloseButton.png')
+    @screen.type("w", Sikuli::KeyModifier.CTRL)
+  end
+  retry_tor(recovery_proc) do
+    @screen.wait_and_click("SeahorseWindow.png", 10)
+    seahorse_menu_click_helper('SeahorseRemoteMenu.png',
+                               'SeahorseRemoteMenuFind.png',
+                               'seahorse')
+    @screen.wait('SeahorseFindKeysWindow.png', 10)
+    # Seahorse doesn't seem to support searching for fingerprints
+    @screen.type(keyid + Sikuli::Key.ENTER)
+    begin
+      @screen.waitAny(['SeahorseFoundKeyResult.png',
+                       'GnomeCloseButton.png'], 120)
+    rescue FindAnyFailed
+      # We may end up here if Seahorse appears to be "frozen".
+      # Sometimes--but not always--if we click another window
+      # the main Seahorse window will unfreeze, allowing us
+      # to continue normally.
+      @screen.click("SeahorseSearch.png")
+    end
+    check_for_seahorse_error
+    @screen.click("SeahorseKeyResultWindow.png")
+    @screen.click("SeahorseFoundKeyResult.png")
+    @screen.click("SeahorseImport.png")
+    try_for(120) do
+      change_of_status?(keyid)
+    end
+    check_for_seahorse_error
+  end
+end
+
+Then /^Seahorse is configured to use the correct keyserver$/ do
+  @gnome_keyservers = YAML.load($vm.execute_successfully('gsettings get org.gnome.crypto.pgp keyservers',
+                                                         :user => LIVE_USER).stdout)
+  assert_equal(1, @gnome_keyservers.count, 'Seahorse should only have one keyserver configured.')
+  # Seahorse doesn't support hkps so that part of the domain is stripped out.
+  # We also insert hkp:// to the beginning of the domain.
+  assert_equal(CONFIGURED_KEYSERVER_HOSTNAME.sub('hkps.', 'hkp://'), @gnome_keyservers[0])
 end
diff --git a/features/step_definitions/torified_misc.rb b/features/step_definitions/torified_misc.rb
new file mode 100644
index 0000000..7112776
--- /dev/null
+++ b/features/step_definitions/torified_misc.rb
@@ -0,0 +1,41 @@
+When /^I query the whois directory service for "([^"]+)"$/ do |domain|
+  retry_tor do
+    @vm_execute_res = $vm.execute("whois '#{domain}'", :user => LIVE_USER)
+    if @vm_execute_res.failure? || @vm_execute_res.stdout['LIMIT EXCEEDED']
+      raise "Looking up whois info for #{domain} failed with:\n" +
+            "#{@vm_execute_res.stdout}\n" +
+            "#{@vm_execute_res.stderr}"
+    end
+  end
+end
+
+When /^I wget "([^"]+)" to stdout(?:| with the '([^']+)' options)$/ do |url, options|
+  arguments = "-O - '#{url}'"
+  arguments = "#{options} #{arguments}" if options
+  retry_tor do
+    @vm_execute_res = $vm.execute("wget #{arguments}", :user => LIVE_USER)
+    if @vm_execute_res.failure?
+      raise "wget:ing #{url} with options #{options} failed with:\n" +
+            "#{@vm_execute_res.stdout}\n" +
+            "#{@vm_execute_res.stderr}"
+    end
+  end
+end
+
+Then /^the (wget|whois) command is successful$/ do |command|
+  assert(
+    @vm_execute_res.success?,
+    "#{command} failed:\n" +
+    "#{@vm_execute_res.stdout}\n" +
+    "#{@vm_execute_res.stderr}"
+  )
+end
+
+Then /^the (wget|whois) standard output contains "([^"]+)"$/ do |command, text|
+  assert(
+    @vm_execute_res.stdout[text],
+    "The #{command} standard output does not contain #{text}:\n" +
+    "#{@vm_execute_res.stdout}\n" +
+    "#{@vm_execute_res.stderr}"
+  )
+end
diff --git a/features/step_definitions/totem.rb b/features/step_definitions/totem.rb
index d125f4e..72698dd 100644
--- a/features/step_definitions/totem.rb
+++ b/features/step_definitions/totem.rb
@@ -1,50 +1,43 @@
-def shared_video_dir_on_guest
-  "/tmp/shared_video_dir"
-end
-
 Given /^I create sample videos$/ do
-  next if @skip_steps_while_restoring_background
-  fatal_system("ffmpeg -loop 1 -t 30 -f image2 " +
+  @shared_video_dir_on_host = "#{$config["TMPDIR"]}/shared_video_dir"
+  @shared_video_dir_on_guest = "/tmp/shared_video_dir"
+  FileUtils.mkdir_p(@shared_video_dir_on_host)
+  add_after_scenario_hook { FileUtils.rm_r(@shared_video_dir_on_host) }
+  fatal_system("avconv -loop 1 -t 30 -f image2 " +
                "-i 'features/images/TailsBootSplash.png' " +
                "-an -vcodec libx264 -y " +
-               "'#{$misc_files_dir}/video.mp4' >/dev/null 2>&1")
+               '-filter:v "crop=in_w-mod(in_w\,2):in_h-mod(in_h\,2)" ' +
+               "'#{@shared_video_dir_on_host}/video.mp4' >/dev/null 2>&1")
 end
 
 Given /^I setup a filesystem share containing sample videos$/ do
-  next if @skip_steps_while_restoring_background
-  @vm.add_share($misc_files_dir, shared_video_dir_on_guest)
+  $vm.add_share(@shared_video_dir_on_host, @shared_video_dir_on_guest)
 end
 
 Given /^I copy the sample videos to "([^"]+)" as user "([^"]+)"$/ do |destination, user|
-  next if @skip_steps_while_restoring_background
-  for video_on_host in Dir.glob("#{$misc_files_dir}/*.mp4") do
+  for video_on_host in Dir.glob("#{@shared_video_dir_on_host}/*.mp4") do
     video_name = File.basename(video_on_host)
-    src_on_guest = "#{shared_video_dir_on_guest}/#{video_name}"
+    src_on_guest = "#{@shared_video_dir_on_guest}/#{video_name}"
     dst_on_guest = "#{destination}/#{video_name}"
     step "I copy \"#{src_on_guest}\" to \"#{dst_on_guest}\" as user \"amnesia\""
   end
 end
 
-When /^I start Totem through the GNOME menu$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.wait_and_click("GnomeApplicationsMenu.png", 10)
-  @screen.wait_and_click("GnomeApplicationsSoundVideo.png", 10)
-  @screen.wait_and_click("GnomeApplicationsTotem.png", 20)
-  @screen.wait_and_click("TotemMainWindow.png", 20)
-end
-
-When /^I load the "([^"]+)" URL in Totem$/ do |url|
-  next if @skip_steps_while_restoring_background
-  @screen.type("l", Sikuli::KeyModifier.CTRL)
-  @screen.wait("TotemOpenUrlDialog.png", 10)
-  @screen.type(url + Sikuli::Key.ENTER)
-end
-
 When /^I(?:| try to) open "([^"]+)" with Totem$/ do |filename|
-  next if @skip_steps_while_restoring_background
   step "I run \"totem #{filename}\" in GNOME Terminal"
 end
 
 When /^I close Totem$/ do
   step 'I kill the process "totem"'
 end
+
+Then /^I can watch a WebM video over HTTPs$/ do
+  test_url = 'https://webm.html5.org/test.webm'
+  recovery_on_failure = Proc.new do
+    step 'I close Totem'
+  end
+  retry_tor(recovery_on_failure) do
+    step "I open \"#{test_url}\" with Totem"
+    @screen.wait("SampleRemoteWebMVideoFrame.png", 120)
+  end
+end
diff --git a/features/step_definitions/unsafe_browser.rb b/features/step_definitions/unsafe_browser.rb
index 86f1c16..b8c0498 100644
--- a/features/step_definitions/unsafe_browser.rb
+++ b/features/step_definitions/unsafe_browser.rb
@@ -1,147 +1,143 @@
 When /^I see and accept the Unsafe Browser start verification$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.wait("UnsafeBrowserStartVerification.png", 30)
-  @screen.type("l", Sikuli::KeyModifier.ALT)
+  @screen.wait('GnomeQuestionDialogIcon.png', 30)
+  @screen.type(Sikuli::Key.ESC)
 end
 
-Then /^I see the Unsafe Browser start notification and wait for it to close$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.wait("UnsafeBrowserStartNotification.png", 30)
-  @screen.waitVanish("UnsafeBrowserStartNotification.png", 10)
+def supported_torbrowser_languages
+  localization_descriptions = "#{Dir.pwd}/config/chroot_local-includes/usr/share/tails/browser-localization/descriptions"
+  File.read(localization_descriptions).split("\n").map do |line|
+    # The line will be of the form "xx:YY:..." or "xx-YY:YY:..."
+    first, second = line.sub('-', '_').split(':')
+    candidates = ["#{first}_#{second}.utf8", "#{first}.utf8",
+                  "#{first}_#{second}", first]
+    when_not_found = Proc.new { raise "Could not find a locale for '#{line}'" }
+    candidates.find(when_not_found) do |candidate|
+      $vm.directory_exist?("/usr/lib/locale/#{candidate}")
+    end
+  end
 end
 
-Then /^the Unsafe Browser has started$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.wait("UnsafeBrowserHomepage.png", 360)
+Then /^I start the Unsafe Browser in the "([^"]+)" locale$/ do |loc|
+  step "I run \"LANG=#{loc} LC_ALL=#{loc} sudo unsafe-browser\" in GNOME Terminal"
+  step "I see and accept the Unsafe Browser start verification"
 end
 
-Then /^the Unsafe Browser has a red theme$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.wait("UnsafeBrowserRedTheme.png", 10)
+Then /^the Unsafe Browser works in all supported languages$/ do
+  failed = Array.new
+  supported_torbrowser_languages.each do |lang|
+    step "I start the Unsafe Browser in the \"#{lang}\" locale"
+    begin
+      step "the Unsafe Browser has started"
+    rescue RuntimeError
+      failed << lang
+      next
+    end
+    step "I close the Unsafe Browser"
+    step "the Unsafe Browser chroot is torn down"
+  end
+  assert(failed.empty?, "Unsafe Browser failed to launch in the following locale(s): #{failed.join(', ')}")
 end
 
-Then /^the Unsafe Browser shows a warning as its start page$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.wait("UnsafeBrowserStartPage.png", 10)
+Then /^the Unsafe Browser has no add-ons installed$/ do
+  step "I open the address \"about:addons\" in the Unsafe Browser"
+  step "I see \"UnsafeBrowserNoAddons.png\" after at most 30 seconds"
 end
 
-When /^I start the Unsafe Browser$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.wait_and_click("GnomeApplicationsMenu.png", 10)
-  @screen.wait_and_click("GnomeApplicationsInternet.png", 10)
-  @screen.wait_and_click("GnomeApplicationsUnsafeBrowser.png", 20)
-end
+Then /^the Unsafe Browser has only Firefox's default bookmarks configured$/ do
+  info = xul_application_info("Unsafe Browser")
+  # "Show all bookmarks"
+  @screen.type("o", Sikuli::KeyModifier.SHIFT + Sikuli::KeyModifier.CTRL)
+  @screen.wait_and_click("UnsafeBrowserExportBookmarksButton.png", 20)
+  @screen.wait_and_click("UnsafeBrowserExportBookmarksMenuEntry.png", 20)
+  @screen.wait("UnsafeBrowserExportBookmarksSavePrompt.png", 20)
+  path = "/home/#{info[:user]}/bookmarks"
+  @screen.type(path + Sikuli::Key.ENTER)
+  chroot_path = "#{info[:chroot]}/#{path}.json"
+  try_for(10) { $vm.file_exist?(chroot_path) }
+  dump = JSON.load($vm.file_content(chroot_path))
+
+  def check_bookmarks_helper(a)
+    mozilla_uris_counter = 0
+    places_uris_counter = 0
+    a.each do |h|
+      h.each_pair do |k, v|
+        if k == "children"
+          m, p = check_bookmarks_helper(v)
+          mozilla_uris_counter += m
+          places_uris_counter += p
+        elsif k == "uri"
+          uri = v
+          if uri.match("^https://www\.mozilla\.org/")
+            mozilla_uris_counter += 1
+          elsif uri.match("^place:(sort|folder|type)=")
+            places_uris_counter += 1
+          else
+            raise "Unexpected Unsafe Browser bookmark for '#{uri}'"
+          end
+        end
+      end
+    end
+    return [mozilla_uris_counter, places_uris_counter]
+  end
 
-When /^I successfully start the Unsafe Browser$/ do
-  next if @skip_steps_while_restoring_background
-  step "I start the Unsafe Browser"
-  step "I see and accept the Unsafe Browser start verification"
-  step "I see the Unsafe Browser start notification and wait for it to close"
-  step "the Unsafe Browser has started"
+  mozilla_uris_counter, places_uris_counter =
+    check_bookmarks_helper(dump["children"])
+  assert_equal(5, mozilla_uris_counter,
+               "Unexpected number (#{mozilla_uris_counter}) of mozilla " \
+               "bookmarks")
+  assert_equal(3, places_uris_counter,
+               "Unexpected number (#{places_uris_counter}) of places " \
+               "bookmarks")
+  @screen.type(Sikuli::Key.F4, Sikuli::KeyModifier.ALT)
 end
 
-Then /^I see a warning about another instance already running$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.wait('UnsafeBrowserWarnAlreadyRunning.png', 10)
+Then /^the Unsafe Browser has a red theme$/ do
+  @screen.wait("UnsafeBrowserRedTheme.png", 10)
 end
 
-When /^I close the Unsafe Browser$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.type("q", Sikuli::KeyModifier.CTRL)
+Then /^the Unsafe Browser shows a warning as its start page$/ do
+  @screen.wait("UnsafeBrowserStartPage.png", 10)
 end
 
-Then /^I see the Unsafe Browser stop notification$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.wait('UnsafeBrowserStopNotification.png', 20)
-  @screen.waitVanish('UnsafeBrowserStopNotification.png', 10)
+Then /^I see a warning about another instance already running$/ do
+  @screen.wait('UnsafeBrowserWarnAlreadyRunning.png', 10)
 end
 
 Then /^I can start the Unsafe Browser again$/ do
-  next if @skip_steps_while_restoring_background
   step "I start the Unsafe Browser"
 end
 
-When /^I open a new tab in the Unsafe Browser$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.wait_and_click("UnsafeBrowserWindow.png", 10)
-  @screen.type("t", Sikuli::KeyModifier.CTRL)
-end
-
-When /^I open the address "([^"]*)" in the Unsafe Browser$/ do |address|
-  next if @skip_steps_while_restoring_background
-  step "I open a new tab in the Unsafe Browser"
-  @screen.type("l", Sikuli::KeyModifier.CTRL)
-  sleep 0.5
-  @screen.type(address + Sikuli::Key.ENTER)
-end
-
-# Workaround until the TBB shows the menu bar by default
-# https://lists.torproject.org/pipermail/tor-qa/2014-October/000478.html
-def show_unsafe_browser_menu_bar
-  try_for(15, :msg => "Failed to show the menu bar") do
-    @screen.type("h", Sikuli::KeyModifier.ALT)
-    @screen.find('UnsafeBrowserEditMenu.png')
-  end
-end
-
 Then /^I cannot configure the Unsafe Browser to use any local proxies$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.wait_and_click("UnsafeBrowserWindow.png", 10)
-  # First we open the proxy settings page to prepare it with the
-  # correct open tabs for the loop below.
-  show_unsafe_browser_menu_bar
-  @screen.hover('UnsafeBrowserEditMenu.png')
-  @screen.wait_and_click('UnsafeBrowserEditPreferences.png', 10)  
-  @screen.wait('UnsafeBrowserPreferencesWindow.png', 10)
-  @screen.wait_and_click('UnsafeBrowserAdvancedSettings.png', 10)
-  @screen.wait_and_click('UnsafeBrowserNetworkTab.png', 10)
-  sleep 0.5
-  @screen.type(Sikuli::Key.ESC)
-#  @screen.waitVanish('UnsafeBrowserPreferences.png', 10)
-  sleep 0.5
-
-  http_proxy  = 'x' # Alt+x is the shortcut to select http proxy
   socks_proxy = 'c' # Alt+c for socks proxy
   no_proxy    = 'y' # Alt+y for no proxy
-
-  # Note: the loop below depends on that http_proxy is done after any
-  # other proxy types since it will set "Use this proxy server for all
-  # protocols", which will make the other proxy types unselectable.
-  proxies = [[socks_proxy, 9050],
-             [socks_proxy, 9061],
-             [socks_proxy, 9062],
-             [socks_proxy, 9150],
-             [http_proxy,  8118],
-             [no_proxy,       0]]
-
-  proxies.each do |proxy|
-    proxy_type = proxy[0]
-    proxy_port = proxy[1]
-
+  proxies = [[no_proxy, nil, nil]]
+  socksport_lines =
+    $vm.execute_successfully('grep -w "^SocksPort" /etc/tor/torrc').stdout
+  assert(socksport_lines.size >= 4, "We got fewer than four Tor SocksPorts")
+  socksports = socksport_lines.scan(/^SocksPort\s([^:]+):(\d+)/)
+  proxies += socksports.map { |host, port| [socks_proxy, host, port] }
+
+  proxies.each do |proxy_type, proxy_host, proxy_port|
     @screen.hide_cursor
 
     # Open proxy settings and select manual proxy configuration
-    show_unsafe_browser_menu_bar
-    @screen.hover('UnsafeBrowserEditMenu.png')
-    @screen.wait_and_click('UnsafeBrowserEditPreferences.png', 10)  
-    @screen.wait('UnsafeBrowserPreferencesWindow.png', 10)
-    @screen.type("e", Sikuli::KeyModifier.ALT)
-    @screen.wait('UnsafeBrowserProxySettings.png', 10)
+    @screen.click('UnsafeBrowserMenuButton.png')
+    @screen.wait_and_click('UnsafeBrowserPreferencesButton.png', 10)
+    @screen.wait_and_click('UnsafeBrowserAdvancedSettingsButton.png', 10)
+    hit, _ = @screen.waitAny(['UnsafeBrowserNetworkTabAlreadySelected.png',
+                              'UnsafeBrowserNetworkTab.png'], 10)
+    @screen.click(hit) if hit == 'UnsafeBrowserNetworkTab.png'
+    @screen.wait_and_click('UnsafeBrowserNetworkTabSettingsButton.png', 10)
+    @screen.wait_and_click('UnsafeBrowserProxySettingsWindow.png', 10)
     @screen.type("m", Sikuli::KeyModifier.ALT)
 
     # Configure the proxy
     @screen.type(proxy_type, Sikuli::KeyModifier.ALT)  # Select correct proxy type
-    @screen.type("127.0.0.1" + Sikuli::Key.TAB + "#{proxy_port}") if proxy_type != no_proxy
-    # For http proxy we set "Use this proxy server for all protocols"
-    @screen.type("s", Sikuli::KeyModifier.ALT) if proxy_type == http_proxy
+    @screen.type(proxy_host + Sikuli::Key.TAB + proxy_port) if proxy_type != no_proxy
 
     # Close settings
-    @screen.type(Sikuli::Key.ENTER)
-#    @screen.waitVanish('UnsafeBrowserProxySettings.png', 10)
-    sleep 0.5
-    @screen.type(Sikuli::Key.ESC)
-#    @screen.waitVanish('UnsafeBrowserPreferences.png', 10)
-    sleep 0.5
+    @screen.click('UnsafeBrowserProxySettingsOkButton.png')
+    @screen.waitVanish('UnsafeBrowserProxySettingsWindow.png', 10)
 
     # Test that the proxy settings work as they should
     step "I open the address \"https://check.torproject.org\" in the Unsafe Browser"
@@ -152,3 +148,42 @@ Then /^I cannot configure the Unsafe Browser to use any local proxies$/ do
     end
   end
 end
+
+Then /^the Unsafe Browser has no proxy configured$/ do
+  @screen.click('UnsafeBrowserMenuButton.png')
+  @screen.wait_and_click('UnsafeBrowserPreferencesButton.png', 10)
+  @screen.wait_and_click('UnsafeBrowserAdvancedSettingsButton.png', 10)
+  @screen.wait_and_click('UnsafeBrowserNetworkTab.png', 10)
+  @screen.wait_and_click('UnsafeBrowserNetworkTabSettingsButton.png', 10)
+  @screen.wait('UnsafeBrowserProxySettingsWindow.png', 10)
+  @screen.wait('UnsafeBrowserNoProxySelected.png', 10)
+  @screen.type(Sikuli::Key.F4, Sikuli::KeyModifier.ALT)
+  @screen.type("w", Sikuli::KeyModifier.CTRL)
+end
+
+Then /^the Unsafe Browser complains that no DNS server is configured$/ do
+  @screen.wait("UnsafeBrowserDNSError.png", 30)
+end
+
+Then /^I configure the Unsafe Browser to check for updates more frequently$/ do
+  prefs = '/usr/share/tails/chroot-browsers/unsafe-browser/prefs.js'
+  $vm.file_append(prefs, 'pref("app.update.idletime", 1);')
+  $vm.file_append(prefs, 'pref("app.update.promptWaitTime", 1);')
+  $vm.file_append(prefs, 'pref("app.update.interval", 5);')
+end
+
+But /^checking for updates is disabled in the Unsafe Browser's configuration$/ do
+  prefs = '/usr/share/tails/chroot-browsers/common/prefs.js'
+  assert($vm.file_content(prefs).include?('pref("app.update.enabled", false)'))
+end
+
+Then /^the clearnet user has (|not )sent packets out to the Internet$/ do |sent|
+  uid = $vm.execute_successfully("id -u clearnet").stdout.chomp.to_i
+  pkts = ip4tables_packet_counter_sum(:tables => ['OUTPUT'], :uid => uid)
+  case sent
+  when ''
+    assert(pkts > 0, "Packets have not gone out to the internet.")
+  when 'not'
+    assert_equal(pkts, 0, "Packets have gone out to the internet.")
+  end
+end
diff --git a/features/step_definitions/untrusted_partitions.rb b/features/step_definitions/untrusted_partitions.rb
index de2e0a7..43453b2 100644
--- a/features/step_definitions/untrusted_partitions.rb
+++ b/features/step_definitions/untrusted_partitions.rb
@@ -1,35 +1,61 @@
-Given /^I create a (\d+) ([[:alpha:]]+) disk named "([^"]+)"$/ do |size, unit, name|
-  next if @skip_steps_while_restoring_background
-  @vm.storage.create_new_disk(name, {:size => size, :unit => unit,
-                                     :type => "raw"})
+Given /^I create an? ([[:alnum:]]+) swap partition on disk "([^"]+)"$/ do |parttype, name|
+  $vm.storage.disk_mkswap(name, parttype)
 end
 
-Given /^I create a ([[:alpha:]]+) label on disk "([^"]+)"$/ do |type, name|
-  next if @skip_steps_while_restoring_background
-  @vm.storage.disk_mklabel(name, type)
+Then /^an? "([^"]+)" partition was detected by Tails on drive "([^"]+)"$/ do |type, name|
+  part_info = $vm.execute_successfully(
+      "blkid '#{$vm.disk_dev(name)}'").stdout.strip
+  assert(part_info.split.grep(/^TYPE=\"#{Regexp.escape(type)}\"$/),
+         "No #{type} partition was detected by Tails on disk '#{name}'")
 end
 
-Given /^I create a ([[:alnum:]]+) filesystem on disk "([^"]+)"$/ do |type, name|
-  next if @skip_steps_while_restoring_background
-  @vm.storage.disk_mkpartfs(name, type)
+Then /^Tails has no disk swap enabled$/ do
+  # Skip first line which contain column headers
+  swap_info = $vm.execute_successfully("tail -n+2 /proc/swaps").stdout
+  assert(swap_info.empty?,
+         "Disk swapping is enabled according to /proc/swaps:\n" + swap_info)
+  mem_info = $vm.execute_successfully("grep '^Swap' /proc/meminfo").stdout
+  assert(mem_info.match(/^SwapTotal:\s+0 kB$/),
+             "Disk swapping is enabled according to /proc/meminfo:\n" +
+             mem_info)
 end
 
-Given /^I cat an ISO hybrid of the Tails image to disk "([^"]+)"$/ do |name|
-  next if @skip_steps_while_restoring_background
-  disk_path = @vm.storage.disk_path(name)
-  tails_iso_hybrid = "#{$tmp_dir}/#{File.basename($tails_iso)}"
-  begin
-    cmd_helper("cp '#{$tails_iso}' '#{tails_iso_hybrid}'")
-    cmd_helper("isohybrid '#{tails_iso_hybrid}' --entry 4 --type 0x1c")
-    cmd_helper("dd if='#{tails_iso_hybrid}' of='#{disk_path}' conv=notrunc")
-  ensure
-    cmd_helper("rm -f '#{tails_iso_hybrid}'")
+Given /^I create an? ([[:alnum:]]+) partition( labeled "([^"]+)")? with an? ([[:alnum:]]+) filesystem( encrypted with password "([^"]+)")? on disk "([^"]+)"$/ do |parttype, has_label, label, fstype, is_encrypted, luks_password, name|
+  opts = {}
+  opts.merge!(:label => label) if has_label
+  opts.merge!(:luks_password => luks_password) if is_encrypted
+  $vm.storage.disk_mkpartfs(name, parttype, fstype, opts)
+end
+
+Given /^I cat an ISO of the Tails image to disk "([^"]+)"$/ do |name|
+  src_disk = {
+    :path => TAILS_ISO,
+    :opts => {
+      :format => "raw",
+      :readonly => true
+    }
+  }
+  dest_disk = {
+    :path => $vm.storage.disk_path(name),
+    :opts => {
+      :format => $vm.storage.disk_format(name)
+    }
+  }
+  $vm.storage.guestfs_disk_helper(src_disk, dest_disk) do |g, src_disk_handle, dest_disk_handle|
+    g.copy_device_to_device(src_disk_handle, dest_disk_handle, {})
   end
 end
 
 Then /^drive "([^"]+)" is not mounted$/ do |name|
-  next if @skip_steps_while_restoring_background
-  dev = @vm.disk_dev(name)
-  assert(!@vm.execute("grep -qs '^#{dev}' /proc/mounts").success?,
+  dev = $vm.disk_dev(name)
+  assert(!$vm.execute("grep -qs '^#{dev}' /proc/mounts").success?,
          "an untrusted partition from drive '#{name}' was automounted")
 end
+
+Then /^Tails Greeter has( not)? detected a persistence partition$/ do |no_persistence|
+  expecting_persistence = no_persistence.nil?
+  @screen.find('TailsGreeter.png')
+  found_persistence = ! @screen.exists('TailsGreeterPersistence.png').nil?
+  assert_equal(expecting_persistence, found_persistence,
+               "Persistence is unexpectedly#{no_persistence} enabled")
+end
diff --git a/features/step_definitions/usb.rb b/features/step_definitions/usb.rb
index f9f17ea..76f94d2 100644
--- a/features/step_definitions/usb.rb
+++ b/features/step_definitions/usb.rb
@@ -1,103 +1,131 @@
-def persistent_mounts
-  {
-    "cups-configuration" => "/etc/cups",
-    "nm-system-connections" => "/etc/NetworkManager/system-connections",
-    "claws-mail" => "/home/#{$live_user}/.claws-mail",
-    "gnome-keyrings" => "/home/#{$live_user}/.gnome2/keyrings",
-    "gnupg" => "/home/#{$live_user}/.gnupg",
-    "bookmarks" => "/home/#{$live_user}/.mozilla/firefox/bookmarks",
-    "pidgin" => "/home/#{$live_user}/.purple",
-    "openssh-client" => "/home/#{$live_user}/.ssh",
-    "Persistent" => "/home/#{$live_user}/Persistent",
-    "apt/cache" => "/var/cache/apt/archives",
-    "apt/lists" => "/var/lib/apt/lists",
+# Returns a hash that for each preset the running Tails is aware of
+# maps the source to the destination.
+def get_persistence_presets(skip_links = false)
+  # Perl script that prints all persistence presets (one per line) on
+  # the form: <mount_point>:<comma-separated-list-of-options>
+  script = <<-EOF
+  use strict;
+  use warnings FATAL => "all";
+  use Tails::Persistence::Configuration::Presets;
+  foreach my $preset (Tails::Persistence::Configuration::Presets->new()->all) {
+    say $preset->destination, ":", join(",", @{$preset->options});
   }
+EOF
+  # VMCommand:s cannot handle newlines, and they're irrelevant in the
+  # above perl script any way
+  script.delete!("\n")
+  presets = $vm.execute_successfully("perl -E '#{script}'").stdout.chomp.split("\n")
+  assert presets.size >= 10, "Got #{presets.size} persistence presets, " +
+                             "which is too few"
+  persistence_mapping = Hash.new
+  for line in presets
+    destination, options_str = line.split(":")
+    options = options_str.split(",")
+    is_link = options.include? "link"
+    next if is_link and skip_links
+    source_str = options.find { |option| /^source=/.match option }
+    # If no source is given as an option, live-boot's persistence
+    # feature defaults to the destination minus the initial "/".
+    if source_str.nil?
+      source = destination.partition("/").last
+    else
+      source = source_str.split("=")[1]
+    end
+    persistence_mapping[source] = destination
+  end
+  return persistence_mapping
 end
 
-def persistent_volumes_mountpoints
-  @vm.execute("ls -1 -d /live/persistence/*_unlocked/").stdout.chomp.split
+def persistent_dirs
+  get_persistence_presets
 end
 
-Given /^I create a new (\d+) ([[:alpha:]]+) USB drive named "([^"]+)"$/ do |size, unit, name|
-  next if @skip_steps_while_restoring_background
-  @vm.storage.create_new_disk(name, {:size => size, :unit => unit})
+def persistent_mounts
+  get_persistence_presets(true)
+end
+
+def persistent_volumes_mountpoints
+  $vm.execute("ls -1 -d /live/persistence/*_unlocked/").stdout.chomp.split
 end
 
 Given /^I clone USB drive "([^"]+)" to a new USB drive "([^"]+)"$/ do |from, to|
-  next if @skip_steps_while_restoring_background
-  @vm.storage.clone_to_new_disk(from, to)
+  $vm.storage.clone_to_new_disk(from, to)
 end
 
 Given /^I unplug USB drive "([^"]+)"$/ do |name|
-  next if @skip_steps_while_restoring_background
-  @vm.unplug_drive(name)
+  $vm.unplug_drive(name)
 end
 
 Given /^the computer is set to boot from the old Tails DVD$/ do
-  next if @skip_steps_while_restoring_background
-  @vm.set_cdrom_boot($old_tails_iso)
+  $vm.set_cdrom_boot(OLD_TAILS_ISO)
 end
 
 Given /^the computer is set to boot in UEFI mode$/ do
-  next if @skip_steps_while_restoring_background
-  @vm.set_os_loader('UEFI')
+  $vm.set_os_loader('UEFI')
   @os_loader = 'UEFI'
 end
 
-class ISOHybridUpgradeNotSupported < StandardError
+class UpgradeNotSupported < StandardError
 end
 
 def usb_install_helper(name)
-  @screen.wait('USBCreateLiveUSB.png', 10)
-
-  # Here we'd like to select USB drive using #{name}, but Sikuli's
-  # OCR seems to be too unreliable.
-#  @screen.wait('USBTargetDevice.png', 10)
-#  match = @screen.find('USBTargetDevice.png')
-#  region_x = match.x
-#  region_y = match.y + match.h
-#  region_w = match.w*3
-#  region_h = match.h*2
-#  ocr = Sikuli::Region.new(region_x, region_y, region_w, region_h).text
-#  STDERR.puts ocr
-#  # Unfortunately this results in almost garbage, like "|]dev/sdm"
-#  # when it should be /dev/sda1
-
-  @screen.wait_and_click('USBCreateLiveUSB.png', 10)
-  if @screen.exists("USBSuggestsInstall.png")
-    raise ISOHybridUpgradeNotSupported
+  @screen.wait('USBTailsLogo.png', 10)
+  if @screen.exists("USBCannotUpgrade.png")
+    raise UpgradeNotSupported
   end
+  @screen.wait_and_click('USBCreateLiveUSB.png', 10)
   @screen.wait('USBCreateLiveUSBConfirmWindow.png', 10)
   @screen.wait_and_click('USBCreateLiveUSBConfirmYes.png', 10)
-  @screen.wait('USBInstallationComplete.png', 60*60)
+  @screen.wait('USBInstallationComplete.png', 30*60)
 end
 
 When /^I start Tails Installer$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.wait_and_click("GnomeApplicationsMenu.png", 10)
-  @screen.wait_and_click("GnomeApplicationsTails.png", 10)
-  @screen.wait_and_click("GnomeApplicationsTailsInstaller.png", 20)
+  step 'I start "TailsInstaller" via the GNOME "Tails" applications menu'
+  @screen.wait('USBCloneAndInstall.png', 30)
+end
+
+When /^I start Tails Installer in "([^"]+)" mode$/ do |mode|
+  step 'I start Tails Installer'
+  case mode
+  when 'Clone & Install'
+    @screen.wait_and_click('USBCloneAndInstall.png', 10)
+  when 'Clone & Upgrade'
+    @screen.wait_and_click('USBCloneAndUpgrade.png', 10)
+  when 'Upgrade from ISO'
+    @screen.wait_and_click('USBUpgradeFromISO.png', 10)
+  else
+    raise "Unsupported mode '#{mode}'"
+  end
+end
+
+Then /^Tails Installer detects that a device is too small$/ do
+  @screen.wait('TailsInstallerTooSmallDevice.png', 10)
 end
 
 When /^I "Clone & Install" Tails to USB drive "([^"]+)"$/ do |name|
-  next if @skip_steps_while_restoring_background
-  step "I start Tails Installer"
-  @screen.wait_and_click('USBCloneAndInstall.png', 30)
+  step 'I start Tails Installer in "Clone & Install" mode'
   usb_install_helper(name)
 end
 
 When /^I "Clone & Upgrade" Tails to USB drive "([^"]+)"$/ do |name|
-  next if @skip_steps_while_restoring_background
-  step "I start Tails Installer"
-  @screen.wait_and_click('USBCloneAndUpgrade.png', 30)
+  step 'I start Tails Installer in "Clone & Upgrade" mode'
   usb_install_helper(name)
 end
 
 When /^I try a "Clone & Upgrade" Tails to USB drive "([^"]+)"$/ do |name|
-  next if @skip_steps_while_restoring_background
   begin
     step "I \"Clone & Upgrade\" Tails to USB drive \"#{name}\""
-  rescue ISOHybridUpgradeNotSupported
+  rescue UpgradeNotSupported
+    # this is what we expect
+  else
+    raise "The USB installer should not succeed"
+  end
+end
+
+When /^I try to "Upgrade from ISO" USB drive "([^"]+)"$/ do |name|
+  begin
+    step "I do a \"Upgrade from ISO\" on USB drive \"#{name}\""
+  rescue UpgradeNotSupported
     # this is what we expect
   else
     raise "The USB installer should not succeed"
@@ -105,140 +133,151 @@ When /^I try a "Clone & Upgrade" Tails to USB drive "([^"]+)"$/ do |name|
 end
 
 When /^I am suggested to do a "Clone & Install"$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.find("USBSuggestsInstall.png")
+  @screen.find("USBCannotUpgrade.png")
 end
 
-def shared_iso_dir_on_guest
-  "/tmp/shared_iso_dir"
+When /^I am told that the destination device cannot be upgraded$/ do
+  @screen.find("USBCannotUpgrade.png")
 end
 
 Given /^I setup a filesystem share containing the Tails ISO$/ do
-  next if @skip_steps_while_restoring_background
-  @vm.add_share(File.dirname($tails_iso), shared_iso_dir_on_guest)
+  shared_iso_dir_on_host = "#{$config["TMPDIR"]}/shared_iso_dir"
+  @shared_iso_dir_on_guest = "/tmp/shared_iso_dir"
+  FileUtils.mkdir_p(shared_iso_dir_on_host)
+  FileUtils.cp(TAILS_ISO, shared_iso_dir_on_host)
+  add_after_scenario_hook { FileUtils.rm_r(shared_iso_dir_on_host) }
+  $vm.add_share(shared_iso_dir_on_host, @shared_iso_dir_on_guest)
 end
 
 When /^I do a "Upgrade from ISO" on USB drive "([^"]+)"$/ do |name|
-  next if @skip_steps_while_restoring_background
-  step "I start Tails Installer"
-  @screen.wait_and_click('USBUpgradeFromISO.png', 10)
+  step 'I start Tails Installer in "Upgrade from ISO" mode'
   @screen.wait('USBUseLiveSystemISO.png', 10)
   match = @screen.find('USBUseLiveSystemISO.png')
   @screen.click(match.getCenter.offset(0, match.h*2))
   @screen.wait('USBSelectISO.png', 10)
-  @screen.wait_and_click('GnomeFileDiagTypeFilename.png', 10)
-  iso = "#{shared_iso_dir_on_guest}/#{File.basename($tails_iso)}"
-  @screen.type(iso + Sikuli::Key.ENTER)
+  @screen.wait_and_click('GnomeFileDiagHome.png', 10)
+  @screen.type("l", Sikuli::KeyModifier.CTRL)
+  @screen.wait('GnomeFileDiagTypeFilename.png', 10)
+  iso = "#{@shared_iso_dir_on_guest}/#{File.basename(TAILS_ISO)}"
+  @screen.type(iso)
+  @screen.wait_and_click('GnomeFileDiagOpenButton.png', 10)
   usb_install_helper(name)
 end
 
 Given /^I enable all persistence presets$/ do
-  next if @skip_steps_while_restoring_background
   @screen.wait('PersistenceWizardPresets.png', 20)
-  # Mark first non-default persistence preset
-  @screen.type(Sikuli::Key.TAB*2)
-  # Check all non-default persistence presets
-  12.times do
-    @screen.type(Sikuli::Key.SPACE + Sikuli::Key.TAB)
+  # Select the "Persistent" folder preset, which is checked by default.
+  @screen.type(Sikuli::Key.TAB)
+  # Check all non-default persistence presets, i.e. all *after* the
+  # "Persistent" folder, which are unchecked by default.
+  (persistent_dirs.size - 1).times do
+    @screen.type(Sikuli::Key.TAB + Sikuli::Key.SPACE)
   end
   @screen.wait_and_click('PersistenceWizardSave.png', 10)
-  @screen.wait('PersistenceWizardDone.png', 20)
+  @screen.wait('PersistenceWizardDone.png', 30)
   @screen.type(Sikuli::Key.F4, Sikuli::KeyModifier.ALT)
 end
 
-Given /^I create a persistent partition with password "([^"]+)"$/ do |pwd|
-  next if @skip_steps_while_restoring_background
-  @screen.wait_and_click("GnomeApplicationsMenu.png", 10)
-  @screen.wait_and_click("GnomeApplicationsTails.png", 10)
-  @screen.wait_and_click("GnomeApplicationsConfigurePersistentVolume.png", 20)
-  @screen.wait('PersistenceWizardWindow.png', 40)
+Given /^I create a persistent partition$/ do
+  step 'I start "ConfigurePersistentVolume" via the GNOME "Tails" applications menu'
   @screen.wait('PersistenceWizardStart.png', 20)
-  @screen.type(pwd + "\t" + pwd + Sikuli::Key.ENTER)
+  @screen.type(@persistence_password + "\t" + @persistence_password + Sikuli::Key.ENTER)
   @screen.wait('PersistenceWizardPresets.png', 300)
   step "I enable all persistence presets"
 end
 
-def check_part_integrity(name, dev, usage, type, scheme, label)
-  info = @vm.execute("udisks --show-info #{dev}").stdout
-  info_split = info.split("\n  partition:\n")
+def check_disk_integrity(name, dev, scheme)
+  info = $vm.execute("udisksctl info --block-device '#{dev}'").stdout
+  info_split = info.split("\n  org\.freedesktop\.UDisks2\.PartitionTable:\n")
+  dev_info = info_split[0]
+  part_table_info = info_split[1]
+  assert(part_table_info.match("^    Type: +#{scheme}$"),
+         "Unexpected partition scheme on USB drive '#{name}', '#{dev}'")
+end
+
+def check_part_integrity(name, dev, usage, fs_type, part_label, part_type = nil)
+  info = $vm.execute("udisksctl info --block-device '#{dev}'").stdout
+  info_split = info.split("\n  org\.freedesktop\.UDisks2\.Partition:\n")
   dev_info = info_split[0]
   part_info = info_split[1]
-  assert(dev_info.match("^  usage: +#{usage}$"),
+  assert(dev_info.match("^    IdUsage: +#{usage}$"),
          "Unexpected device field 'usage' on USB drive '#{name}', '#{dev}'")
-  assert(dev_info.match("^  type: +#{type}$"),
-         "Unexpected device field 'type' on USB drive '#{name}', '#{dev}'")
-  assert(part_info.match("^    scheme: +#{scheme}$"),
-         "Unexpected partition scheme on USB drive '#{name}', '#{dev}'")
-  assert(part_info.match("^    label: +#{label}$"),
+  assert(dev_info.match("^    IdType: +#{fs_type}$"),
+         "Unexpected device field 'IdType' on USB drive '#{name}', '#{dev}'")
+  assert(part_info.match("^    Name: +#{part_label}$"),
          "Unexpected partition label on USB drive '#{name}', '#{dev}'")
+  if part_type
+    assert(part_info.match("^    Type: +#{part_type}$"),
+           "Unexpected partition type on USB drive '#{name}', '#{dev}'")
+  end
 end
 
 def tails_is_installed_helper(name, tails_root, loader)
-  dev = @vm.disk_dev(name) + "1"
-  check_part_integrity(name, dev, "filesystem", "vfat", "gpt", "Tails")
+  disk_dev = $vm.disk_dev(name)
+  part_dev = disk_dev + "1"
+  check_disk_integrity(name, disk_dev, "gpt")
+  check_part_integrity(name, part_dev, "filesystem", "vfat", "Tails",
+                       # EFI System Partition
+                       'c12a7328-f81f-11d2-ba4b-00a0c93ec93b')
 
   target_root = "/mnt/new"
-  @vm.execute("mkdir -p #{target_root}")
-  @vm.execute("mount #{dev} #{target_root}")
+  $vm.execute("mkdir -p #{target_root}")
+  $vm.execute("mount #{part_dev} #{target_root}")
 
-  c = @vm.execute("diff -qr '#{tails_root}/live' '#{target_root}/live'")
+  c = $vm.execute("diff -qr '#{tails_root}/live' '#{target_root}/live'")
   assert(c.success?,
-         "USB drive '#{name}' has differences in /live:\n#{c.stdout}")
+         "USB drive '#{name}' has differences in /live:\n#{c.stdout}\n#{c.stderr}")
 
-  syslinux_files = @vm.execute("ls -1 #{target_root}/syslinux").stdout.chomp.split
+  syslinux_files = $vm.execute("ls -1 #{target_root}/syslinux").stdout.chomp.split
   # We deal with these files separately
-  ignores = ["syslinux.cfg", "exithelp.cfg", "ldlinux.sys"]
+  ignores = ["syslinux.cfg", "exithelp.cfg", "ldlinux.c32", "ldlinux.sys"]
   for f in syslinux_files - ignores do
-    c = @vm.execute("diff -q '#{tails_root}/#{loader}/#{f}' " +
+    c = $vm.execute("diff -q '#{tails_root}/#{loader}/#{f}' " +
                     "'#{target_root}/syslinux/#{f}'")
     assert(c.success?, "USB drive '#{name}' has differences in " +
            "'/syslinux/#{f}'")
   end
 
   # The main .cfg is named differently vs isolinux
-  c = @vm.execute("diff -q '#{tails_root}/#{loader}/#{loader}.cfg' " +
+  c = $vm.execute("diff -q '#{tails_root}/#{loader}/#{loader}.cfg' " +
                   "'#{target_root}/syslinux/syslinux.cfg'")
   assert(c.success?, "USB drive '#{name}' has differences in " +
          "'/syslinux/syslinux.cfg'")
 
-  @vm.execute("umount #{target_root}")
-  @vm.execute("sync")
+  $vm.execute("umount #{target_root}")
+  $vm.execute("sync")
 end
 
 Then /^the running Tails is installed on USB drive "([^"]+)"$/ do |target_name|
-  next if @skip_steps_while_restoring_background
   loader = boot_device_type == "usb" ? "syslinux" : "isolinux"
   tails_is_installed_helper(target_name, "/lib/live/mount/medium", loader)
 end
 
 Then /^the ISO's Tails is installed on USB drive "([^"]+)"$/ do |target_name|
-  next if @skip_steps_while_restoring_background
-  iso = "#{shared_iso_dir_on_guest}/#{File.basename($tails_iso)}"
+  iso = "#{@shared_iso_dir_on_guest}/#{File.basename(TAILS_ISO)}"
   iso_root = "/mnt/iso"
-  @vm.execute("mkdir -p #{iso_root}")
-  @vm.execute("mount -o loop #{iso} #{iso_root}")
+  $vm.execute("mkdir -p #{iso_root}")
+  $vm.execute("mount -o loop #{iso} #{iso_root}")
   tails_is_installed_helper(target_name, iso_root, "isolinux")
-  @vm.execute("umount #{iso_root}")
+  $vm.execute("umount #{iso_root}")
 end
 
 Then /^there is no persistence partition on USB drive "([^"]+)"$/ do |name|
-  next if @skip_steps_while_restoring_background
-  data_part_dev = @vm.disk_dev(name) + "2"
-  assert(!@vm.execute("test -b #{data_part_dev}").success?,
+  data_part_dev = $vm.disk_dev(name) + "2"
+  assert(!$vm.execute("test -b #{data_part_dev}").success?,
          "USB drive #{name} has a partition '#{data_part_dev}'")
 end
 
-Then /^a Tails persistence partition with password "([^"]+)" exists on USB drive "([^"]+)"$/ do |pwd, name|
-  next if @skip_steps_while_restoring_background
-  dev = @vm.disk_dev(name) + "2"
-  check_part_integrity(name, dev, "crypto", "crypto_LUKS", "gpt", "TailsData")
+Then /^a Tails persistence partition exists on USB drive "([^"]+)"$/ do |name|
+  dev = $vm.disk_dev(name) + "2"
+  check_part_integrity(name, dev, "crypto", "crypto_LUKS", "TailsData")
 
   # The LUKS container may already be opened, e.g. by udisks after
   # we've run tails-persistence-setup.
-  c = @vm.execute("ls -1 /dev/mapper/")
+  c = $vm.execute("ls -1 /dev/mapper/")
   if c.success?
     for candidate in c.stdout.split("\n")
-      luks_info = @vm.execute("cryptsetup status #{candidate}")
+      luks_info = $vm.execute("cryptsetup status #{candidate}")
       if luks_info.success? and luks_info.stdout.match("^\s+device:\s+#{dev}$")
         luks_dev = "/dev/mapper/#{candidate}"
         break
@@ -246,122 +285,133 @@ Then /^a Tails persistence partition with password "([^"]+)" exists on USB drive
     end
   end
   if luks_dev.nil?
-    c = @vm.execute("echo #{pwd} | cryptsetup luksOpen #{dev} #{name}")
+    c = $vm.execute("echo #{@persistence_password} | " +
+                    "cryptsetup luksOpen #{dev} #{name}")
     assert(c.success?, "Couldn't open LUKS device '#{dev}' on  drive '#{name}'")
     luks_dev = "/dev/mapper/#{name}"
   end
 
   # Adapting check_part_integrity() seems like a bad idea so here goes
-  info = @vm.execute("udisks --show-info #{luks_dev}").stdout
-  assert info.match("^  cleartext luks device:$")
-  assert info.match("^  usage: +filesystem$")
-  assert info.match("^  type: +ext[34]$")
-  assert info.match("^  label: +TailsData$")
+  info = $vm.execute("udisksctl info --block-device '#{luks_dev}'").stdout
+  assert info.match("^    CryptoBackingDevice: +'/[a-zA-Z0-9_/]+'$")
+  assert info.match("^    IdUsage: +filesystem$")
+  assert info.match("^    IdType: +ext[34]$")
+  assert info.match("^    IdLabel: +TailsData$")
 
   mount_dir = "/mnt/#{name}"
-  @vm.execute("mkdir -p #{mount_dir}")
-  c = @vm.execute("mount #{luks_dev} #{mount_dir}")
+  $vm.execute("mkdir -p #{mount_dir}")
+  c = $vm.execute("mount #{luks_dev} #{mount_dir}")
   assert(c.success?,
          "Couldn't mount opened LUKS device '#{dev}' on drive '#{name}'")
 
-  @vm.execute("umount #{mount_dir}")
-  @vm.execute("sync")
-  @vm.execute("cryptsetup luksClose #{name}")
+  $vm.execute("umount #{mount_dir}")
+  $vm.execute("sync")
+  $vm.execute("cryptsetup luksClose #{name}")
 end
 
-Given /^I enable persistence with password "([^"]+)"$/ do |pwd|
-  next if @skip_steps_while_restoring_background
+Given /^I enable persistence$/ do
   @screen.wait('TailsGreeterPersistence.png', 10)
   @screen.type(Sikuli::Key.SPACE)
   @screen.wait('TailsGreeterPersistencePassphrase.png', 10)
   match = @screen.find('TailsGreeterPersistencePassphrase.png')
   @screen.click(match.getCenter.offset(match.w*2, match.h/2))
-  @screen.type(pwd)
+  @screen.type(@persistence_password)
 end
 
 def tails_persistence_enabled?
   persistence_state_file = "/var/lib/live/config/tails.persistence"
-  return @vm.execute("test -e '#{persistence_state_file}'").success? &&
-         @vm.execute('. #{persistence_state_file} && ' +
+  return $vm.execute("test -e '#{persistence_state_file}'").success? &&
+         $vm.execute(". '#{persistence_state_file}' && " +
                      'test "$TAILS_PERSISTENCE_ENABLED" = true').success?
 end
 
-Given /^persistence is enabled$/ do
-  next if @skip_steps_while_restoring_background
+Given /^all persistence presets(| from the old Tails version) are enabled$/ do |old_tails|
   try_for(120, :msg => "Persistence is disabled") do
     tails_persistence_enabled?
   end
   # Check that all persistent directories are mounted
-  mount = @vm.execute("mount").stdout.chomp
-  for _, dir in persistent_mounts do
+  if old_tails.empty?
+    expected_mounts = persistent_mounts
+  else
+    assert_not_nil($remembered_persistence_mounts)
+    expected_mounts = $remembered_persistence_mounts
+  end
+  mount = $vm.execute("mount").stdout.chomp
+  for _, dir in expected_mounts do
     assert(mount.include?("on #{dir} "),
            "Persistent directory '#{dir}' is not mounted")
   end
 end
 
 Given /^persistence is disabled$/ do
-  next if @skip_steps_while_restoring_background
   assert(!tails_persistence_enabled?, "Persistence is enabled")
 end
 
-Given /^I enable read-only persistence with password "([^"]+)"$/ do |pwd|
-  step "I enable persistence with password \"#{pwd}\""
-  next if @skip_steps_while_restoring_background
+Given /^I enable read-only persistence$/ do
+  step "I enable persistence"
   @screen.wait_and_click('TailsGreeterPersistenceReadOnly.png', 10)
 end
 
 def boot_device
   # Approach borrowed from
   # config/chroot_local_includes/lib/live/config/998-permissions
-  boot_dev_id = @vm.execute("udevadm info --device-id-of-file=/lib/live/mount/medium").stdout.chomp
-  boot_dev = @vm.execute("readlink -f /dev/block/'#{boot_dev_id}'").stdout.chomp
+  boot_dev_id = $vm.execute("udevadm info --device-id-of-file=/lib/live/mount/medium").stdout.chomp
+  boot_dev = $vm.execute("readlink -f /dev/block/'#{boot_dev_id}'").stdout.chomp
   return boot_dev
 end
 
-def boot_device_type
+def device_info(dev)
   # Approach borrowed from
   # config/chroot_local_includes/lib/live/config/998-permissions
-  boot_dev_info = @vm.execute("udevadm info --query=property --name='#{boot_device}'").stdout.chomp
-  boot_dev_type = (boot_dev_info.split("\n").select { |x| x.start_with? "ID_BUS=" })[0].split("=")[1]
-  return boot_dev_type
+  info = $vm.execute("udevadm info --query=property --name='#{dev}'").stdout.chomp
+  info.split("\n").map { |e| e.split('=') } .to_h
 end
 
-Then /^Tails is running from USB drive "([^"]+)"$/ do |name|
-  next if @skip_steps_while_restoring_background
-  assert_equal("usb", boot_device_type)
+def boot_device_type
+  device_info(boot_device)['ID_BUS']
+end
+
+Then /^Tails is running from (.*) drive "([^"]+)"$/ do |bus, name|
+  bus = bus.downcase
+  case bus
+  when "ide"
+    expected_bus = "ata"
+  else
+    expected_bus = bus
+  end
+  assert_equal(expected_bus, boot_device_type)
   actual_dev = boot_device
   # The boot partition differs between a "normal" install using the
   # USB installer and isohybrid installations
-  expected_dev_normal = @vm.disk_dev(name) + "1"
-  expected_dev_isohybrid = @vm.disk_dev(name) + "4"
+  expected_dev_normal = $vm.disk_dev(name) + "1"
+  expected_dev_isohybrid = $vm.disk_dev(name) + "4"
   assert(actual_dev == expected_dev_normal ||
          actual_dev == expected_dev_isohybrid,
-         "We are running from device #{actual_dev}, but for USB drive " +
+         "We are running from device #{actual_dev}, but for #{bus} drive " +
          "'#{name}' we expected to run from either device " +
          "#{expected_dev_normal} (when installed via the USB installer) " +
-         "or #{expected_dev_normal} (when installed from an isohybrid)")
+         "or #{expected_dev_isohybrid} (when installed from an isohybrid)")
 end
 
 Then /^the boot device has safe access rights$/ do
-  next if @skip_steps_while_restoring_background
 
   super_boot_dev = boot_device.sub(/[[:digit:]]+$/, "")
-  devs = @vm.execute("ls -1 #{super_boot_dev}*").stdout.chomp.split
+  devs = $vm.execute("ls -1 #{super_boot_dev}*").stdout.chomp.split
   assert(devs.size > 0, "Could not determine boot device")
-  all_users = @vm.execute("cut -d':' -f1 /etc/passwd").stdout.chomp.split
+  all_users = $vm.execute("cut -d':' -f1 /etc/passwd").stdout.chomp.split
   all_users_with_groups = all_users.collect do |user|
-    groups = @vm.execute("groups #{user}").stdout.chomp.sub(/^#{user} : /, "").split(" ")
+    groups = $vm.execute("groups #{user}").stdout.chomp.sub(/^#{user} : /, "").split(" ")
     [user, groups]
   end
   for dev in devs do
-    dev_owner = @vm.execute("stat -c %U #{dev}").stdout.chomp
-    dev_group = @vm.execute("stat -c %G #{dev}").stdout.chomp
-    dev_perms = @vm.execute("stat -c %a #{dev}").stdout.chomp
+    dev_owner = $vm.execute("stat -c %U #{dev}").stdout.chomp
+    dev_group = $vm.execute("stat -c %G #{dev}").stdout.chomp
+    dev_perms = $vm.execute("stat -c %a #{dev}").stdout.chomp
     assert_equal("root", dev_owner)
     assert(dev_group == "disk" || dev_group == "root",
            "Boot device '#{dev}' owned by group '#{dev_group}', expected " +
            "'disk' or 'root'.")
-    assert_equal("1660", dev_perms)
+    assert_equal("660", dev_perms)
     for user, groups in all_users_with_groups do
       next if user == "root"
       assert(!(groups.include?(dev_group)),
@@ -370,34 +420,34 @@ Then /^the boot device has safe access rights$/ do
     end
   end
 
-  info = @vm.execute("udisks --show-info #{super_boot_dev}").stdout
-  assert(info.match("^  system internal: +1$"),
+  info = $vm.execute("udisksctl info --block-device '#{super_boot_dev}'").stdout
+  assert(info.match("^    HintSystem: +true$"),
          "Boot device '#{super_boot_dev}' is not system internal for udisks")
 end
 
-Then /^persistent filesystems have safe access rights$/ do
+Then /^all persistent filesystems have safe access rights$/ do
   persistent_volumes_mountpoints.each do |mountpoint|
-    fs_owner = @vm.execute("stat -c %U #{mountpoint}").stdout.chomp
-    fs_group = @vm.execute("stat -c %G #{mountpoint}").stdout.chomp
-    fs_perms = @vm.execute("stat -c %a #{mountpoint}").stdout.chomp
+    fs_owner = $vm.execute("stat -c %U #{mountpoint}").stdout.chomp
+    fs_group = $vm.execute("stat -c %G #{mountpoint}").stdout.chomp
+    fs_perms = $vm.execute("stat -c %a #{mountpoint}").stdout.chomp
     assert_equal("root", fs_owner)
     assert_equal("root", fs_group)
     assert_equal('775', fs_perms)
   end
 end
 
-Then /^persistence configuration files have safe access rights$/ do
+Then /^all persistence configuration files have safe access rights$/ do
   persistent_volumes_mountpoints.each do |mountpoint|
-    assert(@vm.execute("test -e #{mountpoint}/persistence.conf").success?,
+    assert($vm.execute("test -e #{mountpoint}/persistence.conf").success?,
            "#{mountpoint}/persistence.conf does not exist, while it should")
-    assert(@vm.execute("test ! -e #{mountpoint}/live-persistence.conf").success?,
+    assert($vm.execute("test ! -e #{mountpoint}/live-persistence.conf").success?,
            "#{mountpoint}/live-persistence.conf does exist, while it should not")
-    @vm.execute(
+    $vm.execute(
       "ls -1 #{mountpoint}/persistence.conf #{mountpoint}/live-*.conf"
     ).stdout.chomp.split.each do |f|
-      file_owner = @vm.execute("stat -c %U '#{f}'").stdout.chomp
-      file_group = @vm.execute("stat -c %G '#{f}'").stdout.chomp
-      file_perms = @vm.execute("stat -c %a '#{f}'").stdout.chomp
+      file_owner = $vm.execute("stat -c %U '#{f}'").stdout.chomp
+      file_group = $vm.execute("stat -c %G '#{f}'").stdout.chomp
+      file_perms = $vm.execute("stat -c %a '#{f}'").stdout.chomp
       assert_equal("tails-persistence-setup", file_owner)
       assert_equal("tails-persistence-setup", file_group)
       assert_equal("600", file_perms)
@@ -405,88 +455,142 @@ Then /^persistence configuration files have safe access rights$/ do
   end
 end
 
-Then /^persistent directories have safe access rights$/ do
-  next if @skip_steps_while_restoring_background
-  expected_perms = "700"
+Then /^all persistent directories(| from the old Tails version) have safe access rights$/ do |old_tails|
+  if old_tails.empty?
+    expected_dirs = persistent_dirs
+  else
+    assert_not_nil($remembered_persistence_dirs)
+    expected_dirs = $remembered_persistence_dirs
+  end
   persistent_volumes_mountpoints.each do |mountpoint|
-    # We also want to check that dotfiles' source has safe permissions
-    all_persistent_dirs = persistent_mounts.clone
-    all_persistent_dirs["dotfiles"] = "/home/#{$live_user}/"
-    persistent_mounts.each do |src, dest|
-      next unless dest.start_with?("/home/#{$live_user}/")
-      f = "#{mountpoint}/#{src}"
-      next unless @vm.execute("test -d #{f}").success?
-      file_perms = @vm.execute("stat -c %a '#{f}'").stdout.chomp
-      assert_equal(expected_perms, file_perms)
+    expected_dirs.each do |src, dest|
+      full_src = "#{mountpoint}/#{src}"
+      assert_vmcommand_success $vm.execute("test -d #{full_src}")
+      dir_perms = $vm.execute_successfully("stat -c %a '#{full_src}'").stdout.chomp
+      dir_owner = $vm.execute_successfully("stat -c %U '#{full_src}'").stdout.chomp
+      if dest.start_with?("/home/#{LIVE_USER}")
+        expected_perms = "700"
+        expected_owner = LIVE_USER
+      else
+        expected_perms = "755"
+        expected_owner = "root"
+      end
+      assert_equal(expected_perms, dir_perms,
+                   "Persistent source #{full_src} has permission " \
+                   "#{dir_perms}, expected #{expected_perms}")
+      assert_equal(expected_owner, dir_owner,
+                   "Persistent source #{full_src} has owner " \
+                   "#{dir_owner}, expected #{expected_owner}")
     end
   end
 end
 
 When /^I write some files expected to persist$/ do
-  next if @skip_steps_while_restoring_background
   persistent_mounts.each do |_, dir|
-    owner = @vm.execute("stat -c %U #{dir}").stdout.chomp
-    assert(@vm.execute("touch #{dir}/XXX_persist", user=owner).success?,
+    owner = $vm.execute("stat -c %U #{dir}").stdout.chomp
+    assert($vm.execute("touch #{dir}/XXX_persist", :user => owner).success?,
            "Could not create file in persistent directory #{dir}")
   end
 end
 
 When /^I remove some files expected to persist$/ do
-  next if @skip_steps_while_restoring_background
   persistent_mounts.each do |_, dir|
-    owner = @vm.execute("stat -c %U #{dir}").stdout.chomp
-    assert(@vm.execute("rm #{dir}/XXX_persist", user=owner).success?,
+    owner = $vm.execute("stat -c %U #{dir}").stdout.chomp
+    assert($vm.execute("rm #{dir}/XXX_persist", :user => owner).success?,
            "Could not remove file in persistent directory #{dir}")
   end
 end
 
 When /^I write some files not expected to persist$/ do
-  next if @skip_steps_while_restoring_background
   persistent_mounts.each do |_, dir|
-    owner = @vm.execute("stat -c %U #{dir}").stdout.chomp
-    assert(@vm.execute("touch #{dir}/XXX_gone", user=owner).success?,
+    owner = $vm.execute("stat -c %U #{dir}").stdout.chomp
+    assert($vm.execute("touch #{dir}/XXX_gone", :user => owner).success?,
            "Could not create file in persistent directory #{dir}")
   end
 end
 
-Then /^the expected persistent files are present in the filesystem$/ do
-  next if @skip_steps_while_restoring_background
-  persistent_mounts.each do |_, dir|
-    assert(@vm.execute("test -e #{dir}/XXX_persist").success?,
+When /^I take note of which persistence presets are available$/ do
+  $remembered_persistence_mounts = persistent_mounts
+  $remembered_persistence_dirs = persistent_dirs
+end
+
+Then /^the expected persistent files(| created with the old Tails version) are present in the filesystem$/ do |old_tails|
+  if old_tails.empty?
+    expected_mounts = persistent_mounts
+  else
+    assert_not_nil($remembered_persistence_mounts)
+    expected_mounts = $remembered_persistence_mounts
+  end
+  expected_mounts.each do |_, dir|
+    assert($vm.execute("test -e #{dir}/XXX_persist").success?,
            "Could not find expected file in persistent directory #{dir}")
-    assert(!@vm.execute("test -e #{dir}/XXX_gone").success?,
+    assert(!$vm.execute("test -e #{dir}/XXX_gone").success?,
            "Found file that should not have persisted in persistent directory #{dir}")
   end
 end
 
-Then /^only the expected files should persist on USB drive "([^"]+)"$/ do |name|
-  next if @skip_steps_while_restoring_background
-  step "a computer"
-  step "the computer is set to boot from USB drive \"#{name}\""
-  step "the network is unplugged"
-  step "I start the computer"
-  step "the computer boots Tails"
-  step "I enable read-only persistence with password \"asdf\""
-  step "I log in to a new session"
-  step "persistence is enabled"
-  step "GNOME has started"
-  step "all notifications have disappeared"
-  step "the expected persistent files are present in the filesystem"
-  step "I shutdown Tails and wait for the computer to power off"
+Then /^only the expected files are present on the persistence partition on USB drive "([^"]+)"$/ do |name|
+  assert(!$vm.is_running?)
+  disk = {
+    :path => $vm.storage.disk_path(name),
+    :opts => {
+      :format => $vm.storage.disk_format(name),
+      :readonly => true
+    }
+  }
+  $vm.storage.guestfs_disk_helper(disk) do |g, disk_handle|
+    partitions = g.part_list(disk_handle).map do |part_desc|
+      disk_handle + part_desc["part_num"].to_s
+    end
+    partition = partitions.find do |part|
+      g.blkid(part)["PART_ENTRY_NAME"] == "TailsData"
+    end
+    assert_not_nil(partition, "Could not find the 'TailsData' partition " \
+                              "on disk '#{disk_handle}'")
+    luks_mapping = File.basename(partition) + "_unlocked"
+    g.luks_open(partition, @persistence_password, luks_mapping)
+    luks_dev = "/dev/mapper/#{luks_mapping}"
+    mount_point = "/"
+    g.mount(luks_dev, mount_point)
+    assert_not_nil($remembered_persistence_mounts)
+    $remembered_persistence_mounts.each do |dir, _|
+      # Guestfs::exists may have a bug; if the file exists, 1 is
+      # returned, but if it doesn't exist false is returned. It seems
+      # the translation of C types into Ruby types is glitchy.
+      assert(g.exists("/#{dir}/XXX_persist") == 1,
+             "Could not find expected file in persistent directory #{dir}")
+      assert(g.exists("/#{dir}/XXX_gone") != 1,
+             "Found file that should not have persisted in persistent directory #{dir}")
+    end
+    g.umount(mount_point)
+    g.luks_close(luks_dev)
+  end
 end
 
 When /^I delete the persistent partition$/ do
-  next if @skip_steps_while_restoring_background
-  @screen.wait_and_click("GnomeApplicationsMenu.png", 10)
-  @screen.wait_and_click("GnomeApplicationsTails.png", 10)
-  @screen.wait_and_click("GnomeApplicationsDeletePersistentVolume.png", 20)
-  @screen.wait("PersistenceWizardWindow.png", 40)
+  step 'I start "DeletePersistentVolume" via the GNOME "Tails" applications menu'
   @screen.wait("PersistenceWizardDeletionStart.png", 20)
   @screen.type(" ")
   @screen.wait("PersistenceWizardDone.png", 120)
 end
 
 Then /^Tails has started in UEFI mode$/ do
-  assert(@vm.execute("test -d /sys/firmware/efi").success?,
+  assert($vm.execute("test -d /sys/firmware/efi").success?,
          "/sys/firmware/efi does not exist")
  end
+
+Given /^I create a ([[:alpha:]]+) label on disk "([^"]+)"$/ do |type, name|
+  $vm.storage.disk_mklabel(name, type)
+end
+
+Then /^a suitable USB device is (?:still )?not found$/ do
+  @screen.wait("TailsInstallerNoQEMUHardDisk.png", 30)
+end
+
+Then /^the "(?:[^"]+)" USB drive is selected$/ do
+  @screen.wait("TailsInstallerQEMUHardDisk.png", 30)
+end
+
+Then /^no USB drive is selected$/ do
+  @screen.wait("TailsInstallerNoQEMUHardDisk.png", 30)
+end
diff --git a/features/support/config.rb b/features/support/config.rb
index 66542cd..9db1592 100644
--- a/features/support/config.rb
+++ b/features/support/config.rb
@@ -1,5 +1,14 @@
 require 'fileutils'
-require "features/support/helpers/misc_helpers.rb"
+require 'yaml'
+require "#{Dir.pwd}/features/support/helpers/misc_helpers.rb"
+
+# These files deal with options like some of the settings passed
+# to the `run_test_suite` script, and "secrets" like credentials
+# (passwords, SSH keys) to be used in tests.
+CONFIG_DIR = "#{Dir.pwd}/features/config"
+DEFAULTS_CONFIG_FILE = "#{CONFIG_DIR}/defaults.yml"
+LOCAL_CONFIG_FILE = "#{CONFIG_DIR}/local.yml"
+LOCAL_CONFIG_DIRS_FILES_GLOB = "#{CONFIG_DIR}/*.d/*.yml"
 
 # Dynamic
 $tails_iso = ENV['ISO'] || get_newest_iso
@@ -15,20 +24,85 @@ $time_at_start = Time.now
 $live_user = "user"
 $sikuli_retry_findfailed = !ENV['SIKULI_RETRY_FINDFAILED'].nil?
 
-# Static
-$configured_keyserver_hostname = 'hkps.pool.sks-keyservers.net'
-$services_expected_on_all_ifaces =
+assert File.exists?(DEFAULTS_CONFIG_FILE)
+$config = YAML.load(File.read(DEFAULTS_CONFIG_FILE))
+config_files = Dir.glob(LOCAL_CONFIG_DIRS_FILES_GLOB).sort
+config_files.insert(0, LOCAL_CONFIG_FILE) if File.exists?(LOCAL_CONFIG_FILE)
+config_files.each do |config_file|
+  yaml_struct = YAML.load(File.read(config_file)) || Hash.new
+  if not(yaml_struct.instance_of?(Hash))
+    raise "Local configuration file '#{config_file}' is malformed"
+  end
+  $config.merge!(yaml_struct)
+end
+# Options passed to the `run_test_suite` script will always take
+# precedence. The way we import these keys is only safe for values
+# with types boolean or string. If we need more, we'll have to invoke
+# YAML's type autodetection on ENV some how.
+$config.merge!(ENV)
+
+# Export TMPDIR back to the environment for subprocesses that we start
+# (e.g. guestfs). Note that this export will only make a difference if
+# TMPDIR wasn't already set and --tmpdir wasn't passed, i.e. only when
+# we use the default.
+ENV['TMPDIR'] = $config['TMPDIR']
+
+# Dynamic constants initialized through the environment or similar,
+# e.g. options we do not want to be configurable through the YAML
+# configuration files.
+DEBUG_LOG_PSEUDO_FIFO = "#{$config["TMPDIR"]}/debug_log_pseudo_fifo"
+DISPLAY = ENV['DISPLAY']
+GIT_DIR = ENV['PWD']
+KEEP_SNAPSHOTS = !ENV['KEEP_SNAPSHOTS'].nil?
+LIVE_USER = cmd_helper(". config/chroot_local-includes/etc/live/config.d/username.conf; echo ${LIVE_USERNAME}").chomp
+TAILS_ISO = ENV['TAILS_ISO']
+OLD_TAILS_ISO = ENV['OLD_TAILS_ISO'] || TAILS_ISO
+TIME_AT_START = Time.now
+loop do
+  ARTIFACTS_DIR = $config['TMPDIR'] + "/run-" +
+                  sanitize_filename(TIME_AT_START.to_s) + "-" +
+                  [
+                    "git",
+                    sanitize_filename(describe_git_head,
+                                      :replacement => '-'),
+                    current_short_commit
+                  ].reject(&:empty?).join("_") + "-" +
+                  random_alnum_string(6)
+  if not(File.exist?(ARTIFACTS_DIR))
+    FileUtils.mkdir_p(ARTIFACTS_DIR)
+    break
+  end
+end
+
+# Constants that are statically initialized.
+CONFIGURED_KEYSERVER_HOSTNAME = 'hkps.pool.sks-keyservers.net'
+LIBVIRT_DOMAIN_NAME = "TailsToaster"
+LIBVIRT_DOMAIN_UUID = "203552d5-819c-41f3-800e-2c8ef2545404"
+LIBVIRT_NETWORK_NAME = "TailsToasterNet"
+LIBVIRT_NETWORK_UUID = "f2305af3-2a64-4f16-afe6-b9dbf02a597e"
+MISC_FILES_DIR = "#{Dir.pwd}/features/misc_files"
+SERVICES_EXPECTED_ON_ALL_IFACES =
   [
    ["cupsd",    "0.0.0.0", "631"],
    ["dhclient", "0.0.0.0", "*"]
   ]
-$tor_authorities =
+# OpenDNS
+SOME_DNS_SERVER = "208.67.222.222"
+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"
+   "86.59.21.38",
+   "128.31.0.39",
+   "194.109.206.212",
+   "82.94.251.203",
+   "199.254.238.52",
+   "131.188.40.189",
+   "193.23.244.244",
+   "208.83.223.34",
+   "171.25.193.9",
+   "154.35.175.225",
   ]
-# OpenDNS
-$some_dns_server = "208.67.222.222"
+VM_XML_PATH = "#{Dir.pwd}/features/domains"
+
+TAILS_SIGNING_KEY = cmd_helper(". #{Dir.pwd}/config/amnesia; echo ${AMNESIA_DEV_KEYID}").tr(' ', '').chomp
+TAILS_DEBIAN_REPO_KEY = "221F9A3C6FA3E09E182E060BC7988EA7A358D82E"
diff --git a/features/support/env.rb b/features/support/env.rb
index 3fa5c37..2e17ae7 100644
--- a/features/support/env.rb
+++ b/features/support/env.rb
@@ -3,6 +3,12 @@ require "features/support/extra_hooks.rb"
 require 'time'
 require 'rspec'
 
+# Force UTF-8. Ruby will default to the system locale, and if it is
+# non-UTF-8, String-methods will fail when operating on non-ASCII
+# strings.
+Encoding.default_external = Encoding::UTF_8
+Encoding.default_internal = Encoding::UTF_8
+
 def fatal_system(str)
   unless system(str)
     raise StandardError.new("Command exited with #{$?}")
@@ -14,6 +20,9 @@ def git_exists?
 end
 
 def create_git
+  Dir.mkdir 'config'
+  FileUtils.touch('config/base_branch')
+  Dir.mkdir('config/APT_overlays.d')
   Dir.mkdir 'debian'
   File.open('debian/changelog', 'w') do |changelog|
     changelog.write(<<END_OF_CHANGELOG)
@@ -33,7 +42,35 @@ END_OF_CHANGELOG
   fatal_system "git branch -M stable"
   fatal_system "git branch testing stable"
   fatal_system "git branch devel stable"
-  fatal_system "git branch experimental devel"
+  fatal_system "git branch feature/jessie devel"
+end
+
+def current_branch
+  cmd = 'git rev-parse --symbolic-full-name --abbrev-ref HEAD'.split
+  branch = cmd_helper(cmd).strip
+  assert_not_equal("HEAD", branch, "We are in 'detached HEAD' state")
+  return branch
+end
+
+# In order: if git HEAD is tagged, return its name; if a branch is
+# checked out, return its name; otherwise we are in 'detached HEAD'
+# state, and we return the empty string.
+def describe_git_head
+  cmd_helper("git describe --tags --exact-match #{current_commit}".split).strip
+rescue Test::Unit::AssertionFailedError
+  begin
+    current_branch
+  rescue Test::Unit::AssertionFailedError
+    ""
+  end
+end
+
+def current_commit
+  cmd_helper('git rev-parse HEAD'.split).strip
+end
+
+def current_short_commit
+  current_commit[0, 7]
 end
 
 RSpec::Matchers.define :have_suite do |suite|
diff --git a/features/support/extra_hooks.rb b/features/support/extra_hooks.rb
index a8addb3..16196a5 100644
--- a/features/support/extra_hooks.rb
+++ b/features/support/extra_hooks.rb
@@ -1,8 +1,28 @@
-require 'cucumber/formatter/pretty'
+# Make the code below work with cucumber >= 2.0. Once we stop
+# supporting <2.0 we should probably do this differently, but this way
+# we can easily support both at the same time.
+begin
+  if not(Cucumber::Core::Ast::Feature.instance_methods.include?(:accept_hook?))
+    require 'gherkin/tag_expression'
+    class Cucumber::Core::Ast::Feature
+      # Code inspired by Cucumber::Core::Test::Case.match_tags?() in
+      # cucumber-ruby-core 1.1.3, lib/cucumber/core/test/case.rb:~59.
+      def accept_hook?(hook)
+        tag_expr = Gherkin::TagExpression.new(hook.tag_expressions.flatten)
+        tags = @tags.map do |t|
+          Gherkin::Formatter::Model::Tag.new(t.name, t.line)
+        end
+        tag_expr.evaluate(tags)
+      end
+    end
+  end
+rescue NameError => e
+  raise e if e.to_s != "uninitialized constant Cucumber::Core"
+end
 
-# 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.
+# Sort of inspired by Cucumber::RbSupport::RbHook (from cucumber
+# < 2.0) 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
 
@@ -26,20 +46,120 @@ def AfterFeature(*tag_expressions, &block)
   $after_feature_hooks << SimpleHook.new(tag_expressions, block)
 end
 
-module ExtraHooks
-  class Pretty < Cucumber::Formatter::Pretty
+require 'cucumber/formatter/console'
+if not($at_exit_print_artifacts_dir_patching_done)
+  module Cucumber::Formatter::Console
+    if method_defined?(:print_stats)
+      alias old_print_stats print_stats
+    end
+    def print_stats(*args)
+      if Dir.exists?(ARTIFACTS_DIR) and Dir.entries(ARTIFACTS_DIR).size > 2
+        @io.puts "Artifacts directory: #{ARTIFACTS_DIR}"
+        @io.puts
+      end
+      if self.class.method_defined?(:old_print_stats)
+        old_print_stats(*args)
+      end
+    end
+  end
+  $at_exit_print_artifacts_dir_patching_done = true
+end
+
+def info_log(message = "", options = {})
+  options[:color] = :clear
+  # This trick allows us to use a module's (~private) method on a
+  # one-off basis.
+  cucumber_console = Class.new.extend(Cucumber::Formatter::Console)
+  puts cucumber_console.format_string(message, options[:color])
+end
+
+def debug_log(message, options = {})
+  $debug_log_fns.each { |fn| fn.call(message, options) } if $debug_log_fns
+end
+
+require 'cucumber/formatter/pretty'
+# Backport part of commit af940a8 from the cucumber-ruby repo. This
+# fixes the "out hook output" for the Pretty formatter so stuff
+# written via `puts` after a Scenario has run its last step will be
+# written, instead of delayed to the next Feature/Scenario (if any) or
+# dropped completely (if not).
+# XXX: This can be removed once we stop supporting Debian Jessie
+# around when Debian Stretch is released.
+if Gem::Version.new(Cucumber::VERSION) < Gem::Version.new('2.0.0.beta.4')
+  module Cucumber
+    module Formatter
+      class Pretty
+        def after_feature_element(feature_element)
+          print_messages
+          @io.puts
+          @io.flush
+        end
+      end
+    end
+  end
+end
+
+module ExtraFormatters
+  # This is a null formatter in the sense that it doesn't ever output
+  # anything. We only use it do hook into the correct events so we can
+  # add our extra hooks.
+  class ExtraHooks
+    def initialize(*args)
+      # We do not care about any of the arguments.
+    end
+
     def before_feature(feature)
-      for hook in $before_feature_hooks do
-        hook.invoke(feature) if feature.accept_hook?(hook)
+      if $before_feature_hooks
+        $before_feature_hooks.each do |hook|
+          hook.invoke(feature) if feature.accept_hook?(hook)
+        end
       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)
+      if $after_feature_hooks
+        $after_feature_hooks.reverse.each do |hook|
+          hook.invoke(feature) if feature.accept_hook?(hook)
+        end
       end
-      super if defined?(super)
     end
   end
+
+  # The pretty formatter with debug logging mixed into its output.
+  class PrettyDebug < Cucumber::Formatter::Pretty
+    def initialize(*args)
+      super(*args)
+      $debug_log_fns ||= []
+      $debug_log_fns << self.method(:debug_log)
+    end
+
+    def debug_log(message, options)
+      options[:color] ||= :blue
+      @io.puts(format_string(message, options[:color]))
+      @io.flush
+    end
+  end
+
+end
+
+module Cucumber
+  module Cli
+    class Options
+      BUILTIN_FORMATS['pretty_debug'] =
+        [
+          'ExtraFormatters::PrettyDebug',
+          'Prints the feature with debugging information - in colours.'
+        ]
+      BUILTIN_FORMATS['debug'] = BUILTIN_FORMATS['pretty_debug']
+    end
+  end
+end
+
+AfterConfiguration do |config|
+  # Cucumber may read this file multiple times, and hence run this
+  # AfterConfiguration hook multiple times. We only want our
+  # ExtraHooks formatter to be loaded once, otherwise the hooks would
+  # be run miltiple times.
+  extra_hooks = ['ExtraFormatters::ExtraHooks', '/dev/null']
+  config.formats << extra_hooks if not(config.formats.include?(extra_hooks))
 end
diff --git a/features/support/helpers/chatbot_helper.rb b/features/support/helpers/chatbot_helper.rb
new file mode 100644
index 0000000..23ce3e1
--- /dev/null
+++ b/features/support/helpers/chatbot_helper.rb
@@ -0,0 +1,59 @@
+require 'tempfile'
+
+class ChatBot
+
+  def initialize(account, password, otr_key, opts = Hash.new)
+    @account = account
+    @password = password
+    @otr_key = otr_key
+    @opts = opts
+    @pid = nil
+    @otr_key_file = nil
+  end
+
+  def start
+    @otr_key_file = Tempfile.new("otr_key.", $config["TMPDIR"])
+    @otr_key_file << @otr_key
+    @otr_key_file.close
+
+    cmd_helper(['/usr/bin/convertkey', @otr_key_file.path])
+    cmd_helper(["mv", "#{@otr_key_file.path}3", @otr_key_file.path])
+
+    cmd = [
+           "#{GIT_DIR}/features/scripts/otr-bot.py",
+           @account,
+           @password,
+           @otr_key_file.path
+          ]
+    cmd += ["--connect-server", @opts["connect_server"]] if @opts["connect_server"]
+    cmd += ["--auto-join"] + @opts["auto_join"] if @opts["auto_join"]
+    cmd += ["--log-file", DEBUG_LOG_PSEUDO_FIFO]
+
+    job = IO.popen(cmd)
+    @pid = job.pid
+  end
+
+  def stop
+    @otr_key_file.delete
+    begin
+      Process.kill("TERM", @pid)
+    rescue
+      # noop
+    end
+  end
+
+  def active?
+    begin
+      ret = Process.kill(0, @pid)
+    rescue Errno::ESRCH => e
+      if e.message == "No such process"
+        return false
+      else
+        raise e
+      end
+    end
+    assert_equal(1, ret, "This shouldn't happen")
+    return true
+  end
+
+end
diff --git a/features/support/helpers/ctcp_helper.rb b/features/support/helpers/ctcp_helper.rb
new file mode 100644
index 0000000..ee5180a
--- /dev/null
+++ b/features/support/helpers/ctcp_helper.rb
@@ -0,0 +1,126 @@
+require 'net/irc'
+require 'timeout'
+
+class CtcpChecker < Net::IRC::Client
+
+  CTCP_SPAM_DELAY = 5
+
+  # `spam_target`: the nickname of the IRC user to CTCP spam.
+  # `ctcp_cmds`: the Array of CTCP commands to send.
+  # `expected_ctcp_replies`: Hash where the keys are the exact set of replies
+  # we expect, and their values a regex the reply data must match.
+  def initialize(host, port, spam_target, ctcp_cmds, expected_ctcp_replies)
+    @spam_target = spam_target
+    @ctcp_cmds =  ctcp_cmds
+    @expected_ctcp_replies = expected_ctcp_replies
+    nickname = self.class.random_irc_nickname
+    opts = {
+      :nick => nickname,
+      :user => nickname,
+      :real => nickname,
+    }
+    opts[:logger] = Logger.new(DEBUG_LOG_PSEUDO_FIFO)
+    super(host, port, opts)
+  end
+
+  # Makes sure that only the expected CTCP replies are received.
+  def verify_ctcp_responses
+    @sent_ctcp_cmds = Set.new
+    @received_ctcp_replies = Set.new
+
+    # Give 60 seconds for connecting to the server and other overhead
+    # beyond the expected time to spam all CTCP commands.
+    expected_ctcp_spam_time = @ctcp_cmds.length * CTCP_SPAM_DELAY
+    timeout = expected_ctcp_spam_time + 60
+
+    begin
+      Timeout::timeout(timeout) do
+        start
+      end
+    rescue Timeout::Error
+      # Do nothing as we'll check for errors below.
+    ensure
+      finish
+    end
+
+    ctcp_cmds_not_sent = @ctcp_cmds - @sent_ctcp_cmds.to_a
+    expected_ctcp_replies_not_received =
+      @expected_ctcp_replies.keys - @received_ctcp_replies.to_a
+
+    if !ctcp_cmds_not_sent.empty? || !expected_ctcp_replies_not_received.empty?
+      raise "Failed to spam all CTCP commands and receive the expected " +
+            "replies within #{timeout} seconds.\n" +
+            (ctcp_cmds_not_sent.empty? ? "" :
+            "CTCP commands not sent: #{ctcp_cmds_not_sent}\n") +
+            (expected_ctcp_replies_not_received.empty? ? "" :
+            "Expected CTCP replies not received: " +
+            expected_ctcp_replies_not_received.to_s)
+    end
+
+  end
+
+  # Generate a random IRC nickname, in this case an alpha-numeric
+  # string with length 10 to 15. To make it legal, the first character
+  # is forced to be alpha.
+  def self.random_irc_nickname
+    random_alpha_string(1) + random_alnum_string(9, 14)
+  end
+
+  def spam(spam_target)
+    post(NOTICE, spam_target, "Hi! I'm gonna test your CTCP capabilities now.")
+    @ctcp_cmds.each do |cmd|
+      sleep CTCP_SPAM_DELAY
+      full_cmd = cmd
+      case cmd
+      when "PING"
+        full_cmd += " #{Time.now.to_i}"
+      when "ACTION"
+        full_cmd += " barfs on the floor."
+      when "ERRMSG"
+        full_cmd += " Pidgin should not respond to this."
+      end
+      post(PRIVMSG, spam_target, ctcp_encode(full_cmd))
+      @sent_ctcp_cmds << cmd
+    end
+  end
+
+  def on_rpl_welcome(m)
+    super
+    Thread.new { spam(@spam_target) }
+  end
+
+  def on_message(m)
+    if m.command == ERR_NICKNAMEINUSE
+      finish
+      new_nick = self.class.random_irc_nickname
+      @opts.marshal_load({
+                           :nick => new_nick,
+                           :user => new_nick,
+                           :real => new_nick,
+                         })
+      start
+      return
+    end
+
+    if m.ctcp? and /^:#{Regexp.escape(@spam_target)}!/.match(m)
+      m.ctcps.each do |ctcp_reply|
+        reply_type, _, reply_data = ctcp_reply.partition(" ")
+        if @expected_ctcp_replies.has_key?(reply_type)
+          if @expected_ctcp_replies[reply_type].match(reply_data)
+            @received_ctcp_replies << reply_type
+          else
+            raise "Received expected CTCP reply '#{reply_type}' but with " +
+                  "unexpected data '#{reply_data}' "
+          end
+        else
+          raise "Received unexpected CTCP reply '#{reply_type}' with " +
+                "data '#{reply_data}'"
+        end
+      end
+    end
+    if Set.new(@ctcp_cmds) == @sent_ctcp_cmds && \
+       Set.new(@expected_ctcp_replies.keys) == @received_ctcp_replies
+      finish
+    end
+  end
+end
diff --git a/features/support/helpers/display_helper.rb b/features/support/helpers/display_helper.rb
index 354935f..b4dce73 100644
--- a/features/support/helpers/display_helper.rb
+++ b/features/support/helpers/display_helper.rb
@@ -6,8 +6,22 @@ class Display
     @x_display = x_display
   end
 
+  def active?
+    p = IO.popen(["xprop", "-display", @x_display,
+                  "-name", "#{@domain} (1) - Virt Viewer",
+                  :err => ["/dev/null", "w"]])
+    Process.wait(p.pid)
+    $?.success?
+  end
+
   def start
-    start_virtviewer(@domain)
+    @virtviewer = IO.popen(["virt-viewer", "--direct",
+                                           "--kiosk",
+                                           "--reconnect",
+                                           "--connect", "qemu:///system",
+                                           "--display", @x_display,
+                                           @domain,
+                                           :err => ["/dev/null", "w"]])
     # 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
@@ -17,35 +31,18 @@ class Display
   end
 
   def stop
-    stop_virtviewer
+    return if @virtviewer.nil?
+    Process.kill("TERM", @virtviewer.pid)
+    @virtviewer.close
+  rescue IOError
+    # IO.pid throws this if the process wasn't started yet. Possibly
+    # there's a race when doing a start() and then quickly running
+    # stop().
   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(' '))
+    stop
+    start
   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
index b0d3a9c..42f6532 100644
--- a/features/support/helpers/exec_helper.rb
+++ b/features/support/helpers/exec_helper.rb
@@ -10,13 +10,11 @@ class VMCommand
     @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 })
+  def VMCommand.wait_until_remote_shell_is_up(vm, timeout = 90)
+    try_for(timeout, :msg => "Remote shell seems to be down") do
+      Timeout::timeout(3) do
+        VMCommand.execute(vm, "echo 'hello?'")
       end
-    rescue Timeout::Error
-      raise "Remote shell seems to be down"
     end
   end
 
@@ -27,21 +25,21 @@ class VMCommand
   # 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.
+  # background (or running scripts that does the same) like our
+  # onioncircuits 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
+    debug_log("#{type}ing as #{options[:user]}: #{cmd}")
     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
+    debug_log("#{type} returned: #{s}") if not(options[:spawn])
     begin
       return JSON.load(s)
     rescue JSON::ParserError
@@ -58,4 +56,16 @@ class VMCommand
     return @returncode == 0
   end
 
+  def failure?
+    return not(success?)
+  end
+
+  def to_s
+    "Return status: #{@returncode}\n" +
+    "STDOUT:\n" +
+    @stdout +
+    "STDERR:\n" +
+    @stderr
+  end
+
 end
diff --git a/features/support/helpers/firewall_helper.rb b/features/support/helpers/firewall_helper.rb
index 400965a..fce363c 100644
--- a/features/support/helpers/firewall_helper.rb
+++ b/features/support/helpers/firewall_helper.rb
@@ -11,21 +11,12 @@ class IPAddr
   ]
 
   PrivateIPv6Ranges = [
-    IPAddr.new("fc00::/7"),   # private
+    IPAddr.new("fc00::/7")
   ]
 
   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
+    private_ranges = self.ipv4? ? PrivateIPv4Ranges : PrivateIPv6Ranges
+    private_ranges.any? { |range| range.include?(self) }
   end
 
   def public?
@@ -34,16 +25,25 @@ class IPAddr
 end
 
 class FirewallLeakCheck
-  attr_reader :ipv4_tcp_leaks, :ipv4_nontcp_leaks, :ipv6_leaks, :nonip_leaks
+  attr_reader :ipv4_tcp_leaks, :ipv4_nontcp_leaks, :ipv6_leaks, :nonip_leaks, :mac_leaks
 
-  def initialize(pcap_file, tor_relays)
-    packets = PacketFu::PcapFile.new.file_to_array(:filename => pcap_file)
-    @tor_relays = tor_relays
+  def initialize(pcap_file, options = {})
+    options[:accepted_hosts] ||= []
+    options[:ignore_lan] ||= true
+    @pcap_file = pcap_file
+    packets = PacketFu::PcapFile.new.file_to_array(:filename => @pcap_file)
+    mac_leaks = Set.new
     ipv4_tcp_packets = []
     ipv4_nontcp_packets = []
     ipv6_packets = []
     nonip_packets = []
     packets.each do |p|
+      if PacketFu::EthPacket.can_parse?(p)
+        packet = PacketFu::EthPacket.parse(p)
+        mac_leaks << packet.eth_saddr
+        mac_leaks << packet.eth_daddr
+      end
+
       if PacketFu::TCPPacket.can_parse?(p)
         ipv4_tcp_packets << PacketFu::TCPPacket.parse(p)
       elsif PacketFu::IPPacket.can_parse?(p)
@@ -57,17 +57,25 @@ class FirewallLeakCheck
         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
+    ipv4_tcp_hosts = filter_hosts_from_ippackets(ipv4_tcp_packets,
+                                                 options[:ignore_lan])
+    accepted = Set.new(options[:accepted_hosts])
+    @mac_leaks = mac_leaks
+    @ipv4_tcp_leaks = ipv4_tcp_hosts.select { |host| !accepted.member?(host) }
+    @ipv4_nontcp_leaks = filter_hosts_from_ippackets(ipv4_nontcp_packets,
+                                                     options[:ignore_lan])
+    @ipv6_leaks = filter_hosts_from_ippackets(ipv6_packets,
+                                              options[:ignore_lan])
     @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)
+  def save_pcap_file
+    save_failure_artifact("Network capture", @pcap_file)
+  end
+
+  # Returns a list of all unique destination IP addresses found in
+  # `packets`. Exclude LAN hosts if ignore_lan is set.
+  def filter_hosts_from_ippackets(packets, ignore_lan)
     hosts = []
     packets.each do |p|
       candidate = nil
@@ -80,21 +88,34 @@ class FirewallLeakCheck
         raise "Expected an IP{v4,v6} packet, but got something else:\n" +
               p.peek_format
       end
-      if candidate != nil and IPAddr.new(candidate).public?
+      if candidate != nil and (not(ignore_lan) or 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?
+  def assert_no_leaks
+    err = ""
+    if !@ipv4_tcp_leaks.empty?
+      err += "The following IPv4 TCP non-Tor Internet hosts were " +
+        "contacted:\n" + ipv4_tcp_leaks.join("\n")
+    end
+    if !@ipv4_nontcp_leaks.empty?
+      err += "The following IPv4 non-TCP Internet hosts were contacted:\n" +
+        ipv4_nontcp_leaks.join("\n")
+    end
+    if !@ipv6_leaks.empty?
+      err += "The following IPv6 Internet hosts were contacted:\n" +
+        ipv6_leaks.join("\n")
+    end
+    if !@nonip_leaks.empty?
+      err += "Some non-IP packets were sent\n"
+    end
+    if !err.empty?
+      save_pcap_file
+      raise err
+    end
   end
 
 end
diff --git a/features/support/helpers/misc_helpers.rb b/features/support/helpers/misc_helpers.rb
index caf64b8..7e09411 100644
--- a/features/support/helpers/misc_helpers.rb
+++ b/features/support/helpers/misc_helpers.rb
@@ -2,6 +2,15 @@ require 'date'
 require 'timeout'
 require 'test/unit'
 
+# Test::Unit adds an at_exit hook which, among other things, consumes
+# the command-line arguments that were intended for cucumber. If
+# e.g. `--format` was passed it will throw an error since it's not a
+# valid option for Test::Unit, and it throwing an error at this time
+# (at_exit) will make Cucumber think it failed and consequently exit
+# with an error. Fooling Test::Unit that this hook has already run
+# works around this craziness.
+Test::Unit.run = true
+
 # Make all the assert_* methods easily accessible in any context.
 include Test::Unit::Assertions
 
@@ -12,41 +21,131 @@ def assert_vmcommand_success(p, msg = nil)
                                 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 = {})
+# It's forbidden to throw this exception (or subclasses) in anything
+# but try_for() below. Just don't use it anywhere else!
+class UniqueTryForTimeoutError < Exception
+end
+
+# Call block (ignoring any exceptions it may throw) repeatedly with
+# one second breaks until it returns true, or until `timeout` seconds have
+# passed when we throw a Timeout::Error exception.
+def try_for(timeout, 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]
+  last_exception = nil
+  # Create a unique exception used only for this particular try_for
+  # call's Timeout to allow nested try_for:s. If we used the same one,
+  # the innermost try_for would catch all outer ones', creating a
+  # really strange situation.
+  unique_timeout_exception = Class.new(UniqueTryForTimeoutError)
+  Timeout::timeout(timeout, unique_timeout_exception) do
+    loop do
+      begin
+        return if yield
+      rescue NameError, UniqueTryForTimeoutError => e
+        # NameError most likely means typos, and hiding that is rarely
+        # (never?) a good idea, so we rethrow them. See below why we
+        # also rethrow *all* the unique exceptions.
+        raise e
+      rescue Exception => e
+        # All other exceptions are ignored while trying the
+        # block. Well we save the last exception so we can print it in
+        # case of a timeout.
+        last_exception = e
       end
+      sleep options[:delay]
     end
-  rescue Timeout::Error => e
-    if options[:msg]
-      raise RuntimeError, options[:msg], caller
-    else
-      raise e
+  end
+  # At this point the block above either succeeded and we'll return,
+  # or we are throwing an exception. If the latter, we either have a
+  # NameError that we'll not catch (and will any try_for below us in
+  # the stack), or we have a unique exception. That can mean one of
+  # two things:
+  # 1. it's the one unique to this try_for, and in that case we'll
+  #    catch it, rethrowing it as something that will be ignored by
+  #    inside the blocks of all try_for:s below us in the stack.
+  # 2. it's an exception unique to another try_for. Assuming that we
+  #    do not throw the unique exceptions in any other place or way
+  #    than we do it in this function, this means that there is a
+  #    try_for below us in the stack to which this exception must be
+  #    unique to.
+  # Let 1 be the base step, and 2 the inductive step, and we sort of
+  # an inductive proof for the correctness of try_for when it's
+  # nested. It shows that for an infinite stack of try_for:s, any of
+  # the unique exceptions will be caught only by the try_for instance
+  # it is unique to, and all try_for:s in between will ignore it so it
+  # ends up there immediately.
+rescue unique_timeout_exception => e
+  msg = options[:msg] || 'try_for() timeout expired'
+  if last_exception
+    msg += "\nLast ignored exception was: " +
+           "#{last_exception.class}: #{last_exception}"
+  end
+  raise Timeout::Error.new(msg)
+end
+
+class TorFailure < StandardError
+end
+
+class MaxRetriesFailure < StandardError
+end
+
+# This will retry the block up to MAX_NEW_TOR_CIRCUIT_RETRIES
+# times. The block must raise an exception for a run to be considered
+# as a failure. After a failure recovery_proc will be called (if
+# given) and the intention with it is to bring us back to the state
+# expected by the block, so it can be retried.
+def retry_tor(recovery_proc = nil, &block)
+  tor_recovery_proc = Proc.new do
+    force_new_tor_circuit
+    recovery_proc.call if recovery_proc
+  end
+
+  retry_action($config['MAX_NEW_TOR_CIRCUIT_RETRIES'],
+               :recovery_proc => tor_recovery_proc,
+               :operation_name => 'Tor operation', &block)
+end
+
+def retry_i2p(recovery_proc = nil, &block)
+  retry_action(15, :recovery_proc => recovery_proc,
+               :operation_name => 'I2P operation', &block)
+end
+
+def retry_action(max_retries, options = {}, &block)
+  assert(max_retries.is_a?(Integer), "max_retries must be an integer")
+  options[:recovery_proc] ||= nil
+  options[:operation_name] ||= 'Operation'
+
+  retries = 1
+  loop do
+    begin
+      block.call
+      return
+    rescue Exception => e
+      if retries <= max_retries
+        debug_log("#{options[:operation_name]} failed (Try #{retries} of " +
+                  "#{max_retries}) with:\n" +
+                  "#{e.class}: #{e.message}")
+        options[:recovery_proc].call if options[:recovery_proc]
+        retries += 1
+      else
+        raise MaxRetriesFailure.new("#{options[:operation_name]} failed (despite retrying " +
+                                    "#{max_retries} times) with\n" +
+                                    "#{e.class}: #{e.message}")
+      end
     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? }
+  try_for(270) { $vm.execute('/usr/local/sbin/tor-has-bootstrapped').success? }
+rescue Timeout::Error => e
+  c = $vm.execute("journalctl SYSLOG_IDENTIFIER=restart-tor")
+  if c.success?
+    debug_log("From the journal:\n" + c.stdout.sub(/^/, "  "))
+  else
+    debug_log("Nothing was in the journal about 'restart-tor'")
+  end
+  raise e
 end
 
 def convert_bytes_mod(unit)
@@ -79,7 +178,12 @@ def convert_from_bytes(size, unit)
 end
 
 def cmd_helper(cmd)
-  IO.popen(cmd + " 2>&1") do |p|
+  if cmd.instance_of?(Array)
+    cmd << {:err => [:child, :out]}
+  elsif cmd.instance_of?(String)
+    cmd += " 2>&1"
+  end
+  IO.popen(cmd) do |p|
     out = p.readlines.join("\n")
     p.close
     ret = $?
@@ -88,34 +192,62 @@ def cmd_helper(cmd)
   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+$/]
+# This command will grab all router IP addresses from the Tor
+# consensus in the VM + the hardcoded TOR_AUTHORITIES.
+def get_all_tor_nodes
+  cmd = 'awk "/^r/ { print \$6 }" /var/lib/tor/cached-microdesc-consensus'
+  $vm.execute(cmd).stdout.chomp.split("\n") + TOR_AUTHORITIES
+end
+
+def get_free_space(machine, path)
+  case machine
+  when 'host'
+    assert(File.exists?(path), "Path '#{path}' not found on #{machine}.")
+    free = cmd_helper(["df", path])
+  when 'guest'
+    assert($vm.file_exist?(path), "Path '#{path}' not found on #{machine}.")
+    free = $vm.execute_successfully("df '#{path}'")
+  else
+    raise 'Unsupported machine type #{machine} passed.'
+  end
+  output = free.split("\n").last
+  return output.match(/[^\s]\s+[0-9]+\s+[0-9]+\s+([0-9]+)\s+.*/)[1].chomp.to_i
+end
+
+def random_string_from_set(set, min_len, max_len)
+  len = (min_len..max_len).to_a.sample
+  len ||= min_len
+  (0..len-1).map { |n| set.sample }.join
 end
 
-def sort_isos_by_creation_date
-  Dir.glob("#{Dir.pwd}/*.iso").sort_by {|f| tails_iso_creation_date(f)}
+def random_alpha_string(min_len, max_len = 0)
+  alpha_set = ('A'..'Z').to_a + ('a'..'z').to_a
+  random_string_from_set(alpha_set, min_len, max_len)
 end
 
-def get_newest_iso
-  return sort_isos_by_creation_date.last
+def random_alnum_string(min_len, max_len = 0)
+  alnum_set = ('A'..'Z').to_a + ('a'..'z').to_a + (0..9).to_a.map { |n| n.to_s }
+  random_string_from_set(alnum_set, min_len, max_len)
 end
 
-def get_oldest_iso
-  return sort_isos_by_creation_date.first
+# Sanitize the filename from unix-hostile filename characters
+def sanitize_filename(filename, options = {})
+  options[:replacement] ||= '_'
+  bad_unix_filename_chars = Regexp.new("[^A-Za-z0-9_\\-.,+:]")
+  filename.gsub(bad_unix_filename_chars, options[:replacement])
 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")
+def info_log_artifact_location(type, path)
+  if $config['ARTIFACTS_BASE_URI']
+    # Remove any trailing slashes, we'll add one ourselves
+    base_url = $config['ARTIFACTS_BASE_URI'].gsub(/\/*$/, "")
+    path = "#{base_url}/#{File.basename(path)}"
+  end
+  info_log("#{type.capitalize}: #{path}")
 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}"
+def pause(message = "Paused")
+  STDERR.puts
+  STDERR.puts "#{message} (Press ENTER to continue!)"
+  STDIN.gets
 end
diff --git a/features/support/helpers/sikuli_helper.rb b/features/support/helpers/sikuli_helper.rb
index 503e08b..938f485 100644
--- a/features/support/helpers/sikuli_helper.rb
+++ b/features/support/helpers/sikuli_helper.rb
@@ -5,6 +5,9 @@ require 'sikuli-script.jar'
 Rjb::load
 
 package_members = [
+                   "java.io.FileOutputStream",
+                   "java.io.PrintStream",
+                   "java.lang.System",
                    "org.sikuli.script.Finder",
                    "org.sikuli.script.Key",
                    "org.sikuli.script.KeyModifier",
@@ -18,6 +21,8 @@ package_members = [
 
 translations = Hash[
                     "org.sikuli.script", "Sikuli",
+                    "java.lang", "Java::Lang",
+                    "java.io", "Java::Io",
                    ]
 
 for p in package_members
@@ -36,12 +41,16 @@ for p in package_members
   mod.const_set(class_name, imported_class)
 end
 
+# Bind Java's stdout to debug_log() via our magical pseudo fifo
+# logger.
+def bind_java_to_pseudo_fifo_logger
+  file_output_stream = Java::Io::FileOutputStream.new(DEBUG_LOG_PSEUDO_FIFO)
+  print_stream = Java::Io::PrintStream.new(file_output_stream)
+  Java::Lang::System.setOut(print_stream)
+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
+  pause("FindFailed for: '#{pic}'")
 end
 
 # Since rjb imports Java classes without creating a corresponding
@@ -61,10 +70,16 @@ end
 sikuli_script_proxy = Sikuli::Screen
 $_original_sikuli_screen_new ||= Sikuli::Screen.method :new
 
+# For waitAny()/findAny() we are forced to throw this exception since
+# Rjb::throw doesn't block until the Java exception has been received
+# by Ruby, so strange things can happen.
+class FindAnyFailed < StandardError
+end
+
 def sikuli_script_proxy.new(*args)
   s = $_original_sikuli_screen_new.call(*args)
 
-  if $sikuli_retry_findfailed
+  if $config["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,
@@ -104,6 +119,18 @@ def sikuli_script_proxy.new(*args)
     self.click(Sikuli::Location.new(x, y))
   end
 
+  def s.doubleClick_point(x, y)
+    self.doubleClick(Sikuli::Location.new(x, y))
+  end
+
+  def s.click_mid_right_edge(pic)
+    r = self.find(pic)
+    top_right = r.getTopRight()
+    x = top_right.getX
+    y = top_right.getY + r.getH/2
+    self.click_point(x, y)
+  end
+
   def s.wait_and_click(pic, time)
     self.click(self.wait(pic, time))
   end
@@ -112,6 +139,48 @@ def sikuli_script_proxy.new(*args)
     self.doubleClick(self.wait(pic, time))
   end
 
+  def s.wait_and_right_click(pic, time)
+    self.rightClick(self.wait(pic, time))
+  end
+
+  def s.wait_and_hover(pic, time)
+    self.hover(self.wait(pic, time))
+  end
+
+  def s.existsAny(images)
+    images.each do |image|
+      region = self.exists(image)
+      return [image, region] if region
+    end
+    return nil
+  end
+
+  def s.findAny(images)
+    images.each do |image|
+      begin
+        return [image, self.find(image)]
+      rescue FindFailed
+        # Ignore. We deal we'll throw an appropriate exception after
+        # having looped through all images and found none of them.
+      end
+    end
+    # If we've reached this point, none of the images could be found.
+    raise FindAnyFailed.new("can not find any of the images #{images} on the " +
+                            "screen")
+  end
+
+  def s.waitAny(images, time)
+    Timeout::timeout(time) do
+      loop do
+        result = self.existsAny(images)
+        return result if result
+      end
+    end
+  rescue Timeout::Error
+    raise FindAnyFailed.new("can not find any of the images #{images} on the " +
+                            "screen")
+  end
+
   def s.hover_point(x, y)
     self.hover(Sikuli::Location.new(x, y))
   end
@@ -132,13 +201,13 @@ end
 # 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_settings.OcrDataPath = $config["TMPDIR"]
 # 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
+sikuli_settings.ActionLogs = true
+sikuli_settings.DebugLogs = true
+sikuli_settings.InfoLogs = true
+sikuli_settings.ProfileLogs = true
diff --git a/features/support/helpers/net_helper.rb b/features/support/helpers/sniffing_helper.rb
similarity index 65%
rename from features/support/helpers/net_helper.rb
rename to features/support/helpers/sniffing_helper.rb
index 2911919..213411e 100644
--- a/features/support/helpers/net_helper.rb
+++ b/features/support/helpers/sniffing_helper.rb
@@ -14,15 +14,16 @@ class Sniffer
 
   attr_reader :name, :pcap_file, :pid
 
-  def initialize(name, bridge_name)
+  def initialize(name, vmnet)
     @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"
+    @vmnet = vmnet
+    pcap_name = sanitize_filename("#{name}.pcap")
+    @pcap_file = "#{$config["TMPDIR"]}/#{pcap_name}"
   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")
+  def capture(filter="not ether src host #{@vmnet.bridge_mac} and not ether proto \\arp and not ether proto \\rarp")
+    job = IO.popen(["/usr/sbin/tcpdump", "-n", "-i", @vmnet.bridge_name, "-w",
+                    @pcap_file, "-U", filter, :err => ["/dev/null", "w"]])
     @pid = job.pid
   end
 
diff --git a/features/support/helpers/sshd_helper.rb b/features/support/helpers/sshd_helper.rb
new file mode 100644
index 0000000..2e0069c
--- /dev/null
+++ b/features/support/helpers/sshd_helper.rb
@@ -0,0 +1,67 @@
+require 'tempfile'
+
+class SSHServer
+  def initialize(sshd_host, sshd_port, authorized_keys = nil)
+    @sshd_host = sshd_host
+    @sshd_port = sshd_port
+    @authorized_keys = authorized_keys
+    @pid = nil
+  end
+
+  def start
+    @sshd_key_file = Tempfile.new("ssh_host_rsa_key", $config["TMPDIR"])
+    # 'hack' to prevent ssh-keygen from prompting to overwrite the file
+    File.delete(@sshd_key_file.path)
+    cmd_helper(['ssh-keygen', '-t', 'rsa', '-N', "", '-f', "#{@sshd_key_file.path}"])
+    @sshd_key_file.close
+
+    sshd_config =<<EOF
+Port #{@sshd_port}
+ListenAddress #{@sshd_host}
+UsePrivilegeSeparation no
+HostKey #{@sshd_key_file.path}
+Pidfile #{$config['TMPDIR']}/ssh.pid
+EOF
+
+    @sshd_config_file = Tempfile.new("sshd_config", $config["TMPDIR"])
+    @sshd_config_file.write(sshd_config)
+
+    if @authorized_keys
+      @authorized_keys_file = Tempfile.new("authorized_keys", $config['TMPDIR'])
+      @authorized_keys_file.write(@authorized_keys)
+      @authorized_keys_file.close
+      @sshd_config_file.write("AuthorizedKeysFile #{@authorized_keys_file.path}")
+    end
+
+    @sshd_config_file.close
+
+    cmd = ["/usr/sbin/sshd", "-4", "-f", @sshd_config_file.path, "-D"]
+
+    job = IO.popen(cmd)
+    @pid = job.pid
+  end
+
+  def stop
+    File.delete("#{@sshd_key_file.path}.pub")
+    File.delete("#{$config['TMPDIR']}/ssh.pid")
+    begin
+      Process.kill("TERM", @pid)
+    rescue
+      # noop
+    end
+  end
+
+  def active?
+    begin
+      ret = Process.kill(0, @pid)
+    rescue Errno::ESRCH => e
+      if e.message == "No such process"
+        return false
+      else
+        raise e
+      end
+    end
+    assert_equal(1, ret, "This shouldn't happen")
+    return true
+  end
+end
diff --git a/features/support/helpers/storage_helper.rb b/features/support/helpers/storage_helper.rb
index 80a1e1e..21537a9 100644
--- a/features/support/helpers/storage_helper.rb
+++ b/features/support/helpers/storage_helper.rb
@@ -7,30 +7,43 @@
 # sense.
 
 require 'libvirt'
+require 'guestfs'
 require 'rexml/document'
 require 'etc'
 
 class VMStorage
 
-  @@virt = nil
-
   def initialize(virt, xml_path)
-    @@virt ||= virt
+    @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
+    @pool_path = "#{$config["TMPDIR"]}/#{pool_name}"
     begin
-      @pool = @@virt.lookup_storage_pool_by_name(pool_name)
+      @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
+      @pool = nil
+    end
+    if @pool and not(KEEP_SNAPSHOTS)
       VMStorage.clear_storage_pool(@pool)
+      @pool = nil
+    end
+    unless @pool
+      pool_xml.elements['pool/target/path'].text = @pool_path
+      @pool = @virt.define_storage_pool_xml(pool_xml.to_s)
+      if not(Dir.exists?(@pool_path))
+        # We'd like to use @pool.build, which will just create the
+        # @pool_path directory, but it does so with root:root as owner
+        # (at least with libvirt 1.2.21-2). libvirt itself can handle
+        # that situation, but guestfs (at least with <=
+        # 1:1.28.12-1+b3) cannot when invoked by a non-root user,
+        # which we want to support.
+        FileUtils.mkdir(@pool_path)
+        FileUtils.chown(nil, 'libvirt-qemu', @pool_path)
+        FileUtils.chmod("ug+wrx", @pool_path)
+      end
     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.create unless @pool.active?
     @pool.refresh
   end
 
@@ -65,10 +78,23 @@ class VMStorage
     VMStorage.clear_storage_pool_volumes(@pool)
   end
 
+  def delete_volume(name)
+    @pool.lookup_volume_by_name(name).delete
+  end
+
   def create_new_disk(name, options = {})
     options[:size] ||= 2
     options[:unit] ||= "GiB"
     options[:type] ||= "qcow2"
+    # Require 'slightly' more space to be available to give a bit more leeway
+    # with rounding, temp file creation, etc.
+    reserved = 500
+    needed = convert_to_MiB(options[:size].to_i, options[:unit])
+    avail = convert_to_MiB(get_free_space('host', @pool_path), "KiB")
+    assert(avail - reserved >= needed,
+           "Error creating disk \"#{name}\" in \"#{@pool_path}\". " \
+           "Need #{needed} MiB but only #{avail} MiB is available of " \
+           "which #{reserved} MiB is reserved for other temporary files.")
     begin
       old_vol = @pool.lookup_volume_by_name(name)
     rescue Libvirt::RetrieveError
@@ -116,28 +142,75 @@ class VMStorage
     @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}")
+  def disk_mklabel(name, parttype)
+    disk = {
+      :path => disk_path(name),
+      :opts => {
+        :format => disk_format(name)
+      }
+    }
+    guestfs_disk_helper(disk) do |g, disk_handle|
+      g.part_init(disk_handle, parttype)
+    end
   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%")
+  def disk_mkpartfs(name, parttype, fstype, opts = {})
+    opts[:label] ||= nil
+    opts[:luks_password] ||= nil
+    disk = {
+      :path => disk_path(name),
+      :opts => {
+        :format => disk_format(name)
+      }
+    }
+    guestfs_disk_helper(disk) do |g, disk_handle|
+      g.part_disk(disk_handle, parttype)
+      g.part_set_name(disk_handle, 1, opts[:label]) if opts[:label]
+      primary_partition = g.list_partitions()[0]
+      if opts[:luks_password]
+        g.luks_format(primary_partition, opts[:luks_password], 0)
+        luks_mapping = File.basename(primary_partition) + "_unlocked"
+        g.luks_open(primary_partition, opts[:luks_password], luks_mapping)
+        luks_dev = "/dev/mapper/#{luks_mapping}"
+        g.mkfs(fstype, luks_dev)
+        g.luks_close(luks_dev)
+      else
+        g.mkfs(fstype, primary_partition)
+      end
+    end
+  end
+
+  def disk_mkswap(name, parttype)
+    disk = {
+      :path => disk_path(name),
+      :opts => {
+        :format => disk_format(name)
+      }
+    }
+    guestfs_disk_helper(disk) do |g, disk_handle|
+      g.part_disk(disk_handle, parttype)
+      primary_partition = g.list_partitions()[0]
+      g.mkswap(primary_partition)
+    end
+  end
+
+  def guestfs_disk_helper(*disks)
+    assert(block_given?)
+    g = Guestfs::Guestfs.new()
+    g.set_trace(1)
+    message_callback = Proc.new do |event, _, message, _|
+      debug_log("libguestfs: #{Guestfs.event_to_string(event)}: #{message}")
+    end
+    g.set_event_callback(message_callback,
+                         Guestfs::EVENT_TRACE)
+    g.set_autosync(1)
+    disks.each do |disk|
+      g.add_drive_opts(disk[:path], disk[:opts])
+    end
+    g.launch()
+    yield(g, *g.list_devices())
+  ensure
+    g.close
   end
 
 end
diff --git a/features/support/helpers/vm_helper.rb b/features/support/helpers/vm_helper.rb
index 2b5ad29..6d7204d 100644
--- a/features/support/helpers/vm_helper.rb
+++ b/features/support/helpers/vm_helper.rb
@@ -1,79 +1,122 @@
 require 'libvirt'
 require 'rexml/document'
 
-class VM
+class ExecutionFailedInVM < StandardError
+end
+
+class VMNet
 
-  # 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
+  attr_reader :net_name, :net
 
-  def VM.storage
-    return @@storage
+  def initialize(virt, xml_path)
+    @virt = virt
+    @net_name = LIBVIRT_NETWORK_NAME
+    net_xml = File.read("#{xml_path}/default_net.xml")
+    rexml = REXML::Document.new(net_xml)
+    rexml.elements['network'].add_element('name')
+    rexml.elements['network/name'].text = @net_name
+    rexml.elements['network'].add_element('uuid')
+    rexml.elements['network/uuid'].text = LIBVIRT_NETWORK_UUID
+    update(rexml.to_s)
+  rescue Exception => e
+    destroy_and_undefine
+    raise e
   end
 
-  def storage
-    return @@storage
+  # We lookup by name so we also catch networks from previous test
+  # suite runs that weren't properly cleaned up (e.g. aborted).
+  def destroy_and_undefine
+    begin
+      old_net = @virt.lookup_network_by_name(@net_name)
+      old_net.destroy if old_net.active?
+      old_net.undefine
+    rescue
+    end
   end
 
-  attr_reader :domain, :display, :ip, :net
+  def update(xml)
+    destroy_and_undefine
+    @net = @virt.define_network_xml(xml)
+    @net.create
+  end
+
+  def bridge_name
+    @net.bridge_name
+  end
+
+  def bridge_ip_addr
+    net_xml = REXML::Document.new(@net.xml_desc)
+    IPAddr.new(net_xml.elements['network/ip'].attributes['address']).to_s
+  end
+
+  def guest_real_mac
+    net_xml = REXML::Document.new(@net.xml_desc)
+    net_xml.elements['network/ip/dhcp/host/'].attributes['mac']
+  end
 
-  def initialize(xml_path, x_display)
-    @@virt ||= Libvirt::open("qemu:///system")
+  def bridge_mac
+    File.open("/sys/class/net/#{bridge_name}/address", "rb").read.chomp
+  end
+end
+
+
+class VM
+
+  attr_reader :domain, :display, :vmnet, :storage
+
+  def initialize(virt, xml_path, vmnet, storage, x_display)
+    @virt = virt
     @xml_path = xml_path
+    @vmnet = vmnet
+    @storage = storage
+    @domain_name = LIBVIRT_DOMAIN_NAME
     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)
+    rexml = REXML::Document.new(default_domain_xml)
+    rexml.elements['domain'].add_element('name')
+    rexml.elements['domain/name'].text = @domain_name
+    rexml.elements['domain'].add_element('uuid')
+    rexml.elements['domain/uuid'].text = LIBVIRT_DOMAIN_UUID
+    update(rexml.to_s)
     @display = Display.new(@domain_name, x_display)
-    set_cdrom_boot($tails_iso)
+    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
+    destroy_and_undefine
     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
+  def update(xml)
+    destroy_and_undefine
+    @domain = @virt.define_domain_xml(xml)
   end
 
-  def clean_up_domain
+  # We lookup by name so we also catch domains from previous test
+  # suite runs that weren't properly cleaned up (e.g. aborted).
+  def destroy_and_undefine
+    @display.stop if @display && @display.active?
     begin
-      domain = @@virt.lookup_domain_by_name(@domain_name)
-      domain.destroy if domain.active?
-      domain.undefine
+      old_domain = @virt.lookup_domain_by_name(@domain_name)
+      old_domain.destroy if old_domain.active?
+      old_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
+  def real_mac
+    @vmnet.guest_real_mac
+  end
+
+  def set_hardware_clock(time)
+    assert(not(is_running?), 'The hardware clock cannot be set when the ' +
+                             'VM is running')
+    assert(time.instance_of?(Time), "Argument must be of type 'Time'")
+    adjustment = (time - Time.now).to_i
+    domain_rexml = REXML::Document.new(@domain.xml_desc)
+    clock_rexml_element = domain_rexml.elements['domain'].add_element('clock')
+    clock_rexml_element.add_attributes('offset' => 'variable',
+                                       'basis' => 'utc',
+                                       'adjustment' => adjustment.to_s)
+    update(domain_rexml.to_s)
   end
 
   def set_network_link_state(state)
@@ -82,7 +125,7 @@ class VM
     if is_running?
       @domain.update_device(domain_xml.elements['domain/devices/interface'].to_s)
     else
-      update_domain(domain_xml.to_s)
+      update(domain_xml.to_s)
     end
   end
 
@@ -94,97 +137,101 @@ class VM
     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)
+    update(domain_xml.to_s)
   end
 
   def set_cdrom_image(image)
+    image = nil if 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')
+        if image.nil?
+          e.elements.delete('source')
+        else
+          if ! e.elements['source']
+            e.add_element('source')
+          end
+          e.elements['source'].attributes['file'] = image
         end
-        e.elements['source'].attributes['file'] = image
         if is_running?
-          @domain.update_device(e.to_s, Libvirt::Domain::DEVICE_MODIFY_FORCE)
+          @domain.update_device(e.to_s)
         else
-          update_domain(domain_xml.to_s)
+          update(domain_xml.to_s)
         end
       end
     end
   end
 
   def remove_cdrom
-    set_cdrom_image('')
+    set_cdrom_image(nil)
+  rescue Libvirt::Error => e
+    # While the CD-ROM is removed successfully we still get this
+    # error, so let's ignore it.
+    acceptable_error =
+      "Call to virDomainUpdateDeviceFlags failed: internal error: unable to " +
+      "execute QEMU command 'eject': (Tray of device '.*' is not open|" +
+      "Device '.*' is locked)"
+    raise e if not(Regexp.new(acceptable_error).match(e.to_s))
   end
 
   def set_cdrom_boot(image)
     if is_running?
-      raise "boot settings can only be set for inactice vms"
+      raise "boot settings can only be set for inactive 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 = []
+  def list_disk_devs
+    ret = []
     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
+    domain_xml.elements.each('domain/devices/disk') do |e|
+      ret << e.elements['target'].attribute('dev').to_s
+    end
+    return ret
+  end
+
+  def plug_drive(name, type)
+    if disk_plugged?(name)
+      raise "disk '#{name}' already plugged"
     end
+    removable_usb = nil
+    case type
+    when "removable usb", "usb"
+      type = "usb"
+      removable_usb = "on"
+    when "non-removable usb"
+      type = "usb"
+      removable_usb = "off"
+    end
+    # Get the next free /dev/sdX on guest
     letter = 'a'
     dev = "sd" + letter
-    while used_devs.include? dev
+    while list_disk_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/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
+    xml.elements['disk/target'].attributes['removable'] = removable_usb if removable_usb
 
     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)
+      update(domain_xml.to_s)
     end
   end
 
@@ -192,7 +239,7 @@ class VM
     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)
+        if e.elements['source'].attribute('file').to_s == @storage.disk_path(name)
           return e.to_s
         end
       rescue
@@ -202,25 +249,64 @@ class VM
     return nil
   end
 
+  def disk_rexml_desc(name)
+    xml = disk_xml_desc(name)
+    if xml
+      return REXML::Document.new(xml)
+    else
+      return nil
+    end
+  end
+
   def unplug_drive(name)
     xml = disk_xml_desc(name)
     @domain.detach_device(xml)
   end
 
+  def disk_type(dev)
+    domain_xml = REXML::Document.new(@domain.xml_desc)
+    domain_xml.elements.each('domain/devices/disk') do |e|
+      if e.elements['target'].attribute('dev').to_s == dev
+        return e.elements['driver'].attribute('type').to_s
+      end
+    end
+    raise "No such disk device '#{dev}'"
+  end
+
   def disk_dev(name)
-    xml = REXML::Document.new(disk_xml_desc(name))
-    return "/dev/" + xml.elements['disk/target'].attribute('dev').to_s
+    rexml = disk_rexml_desc(name) or return nil
+    return "/dev/" + rexml.elements['disk/target'].attribute('dev').to_s
+  end
+
+  def disk_name(dev)
+    dev = File.basename(dev)
+    domain_xml = REXML::Document.new(@domain.xml_desc)
+    domain_xml.elements.each('domain/devices/disk') do |e|
+      if /^#{e.elements['target'].attribute('dev').to_s}/.match(dev)
+        return File.basename(e.elements['source'].attribute('file').to_s)
+      end
+    end
+    raise "No such disk device '#{dev}'"
+  end
+
+  def udisks_disk_dev(name)
+    return disk_dev(name).gsub('/dev/', '/org/freedesktop/UDisks/devices/')
   end
 
   def disk_detected?(name)
-    return execute("test -b #{disk_dev(name)}").success?
+    dev = disk_dev(name) or return false
+    return execute("test -b #{dev}").success?
+  end
+
+  def disk_plugged?(name)
+    return not(disk_xml_desc(name).nil?)
   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)
+    plug_drive(name, type) if not(disk_plugged?(name))
     set_boot_device('hd')
     # For some reason setting the boot device doesn't prevent cdrom
     # boot unless it's empty
@@ -231,14 +317,19 @@ class VM
   # 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"
+      raise "shares can only be added to inactive vms"
     end
+    # The complete source directory must be group readable by the user
+    # running the virtual machine, and world readable so the user inside
+    # the VM can access it (since we use the passthrough security model).
+    FileUtils.chown_R(nil, "libvirt-qemu", source)
+    FileUtils.chmod_R("go+rX", source)
     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)
+    update(domain_xml.to_s)
   end
 
   def list_shares
@@ -251,13 +342,13 @@ class VM
   end
 
   def set_ram_size(size, unit = "KiB")
-    raise "System memory can only be added to inactice vms" if is_running?
+    raise "System memory can only be added to inactive 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)
+    update(domain_xml.to_s)
   end
 
   def get_ram_size_in_bytes
@@ -268,24 +359,24 @@ class VM
   end
 
   def set_arch(arch)
-    raise "System architecture can only be set to inactice vms" if is_running?
+    raise "System architecture can only be set to inactive 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)
+    update(domain_xml.to_s)
   end
 
   def add_hypervisor_feature(feature)
-    raise "Hypervisor features can only be added to inactice vms" if is_running?
+    raise "Hypervisor features can only be added to inactive 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)
+    update(domain_xml.to_s)
   end
 
   def drop_hypervisor_feature(feature)
-    raise "Hypervisor features can only be fropped from inactice vms" if is_running?
+    raise "Hypervisor features can only be fropped from inactive 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)
+    update(domain_xml.to_s)
   end
 
   def disable_pae_workaround
@@ -295,24 +386,24 @@ class VM
     xml = <<EOF
   <qemu:commandline xmlns:qemu='http://libvirt.org/schemas/domain/qemu/1.0'>
     <qemu:arg value='-cpu'/>
-    <qemu:arg value='pentium,-pae'/>
+    <qemu:arg value='qemu32,-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)
+    update(domain_xml.to_s)
   end
 
   def set_os_loader(type)
     if is_running?
-      raise "boot settings can only be set for inactice vms"
+      raise "boot settings can only be set for inactive 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)
+      update(domain_xml.to_s)
     else
       raise "unsupported OS loader type"
     end
@@ -326,21 +417,38 @@ EOF
     end
   end
 
-  def execute(cmd, user = "root")
-    return VMCommand.new(self, cmd, { :user => user, :spawn => false })
+  def execute(cmd, options = {})
+    options[:user] ||= "root"
+    options[:spawn] ||= false
+    if options[:libs]
+      libs = options[:libs]
+      options.delete(:libs)
+      libs = [libs] if not(libs.methods.include? :map)
+      cmds = libs.map do |lib_name|
+        ". /usr/local/lib/tails-shell-library/#{lib_name}.sh"
+      end
+      cmds << cmd
+      cmd = cmds.join(" && ")
+    end
+    return VMCommand.new(self, cmd, options)
   end
 
-  def execute_successfully(cmd, user = "root")
-    p = execute(cmd, user)
-    assert_vmcommand_success(p)
+  def execute_successfully(*args)
+    p = execute(*args)
+    begin
+      assert_vmcommand_success(p)
+    rescue Test::Unit::AssertionFailedError => e
+      raise ExecutionFailedInVM.new(e)
+    end
     return p
   end
 
-  def spawn(cmd, user = "root")
-    return VMCommand.new(self, cmd, { :user => user, :spawn => true })
+  def spawn(cmd, options = {})
+    options[:spawn] = true
+    return execute(cmd, options)
   end
 
-  def wait_until_remote_shell_is_up(timeout = 30)
+  def wait_until_remote_shell_is_up(timeout = 90)
     VMCommand.wait_until_remote_shell_is_up(self, timeout)
   end
 
@@ -361,32 +469,182 @@ EOF
     return execute("pidof -x -o '%PPID' " + process).stdout.chomp.split
   end
 
+  def select_virtual_desktop(desktop_number, user = LIVE_USER)
+    assert(desktop_number >= 0 && desktop_number <=3,
+           "Only values between 0 and 3 are valid virtual desktop numbers")
+    execute_successfully(
+      "xdotool set_desktop '#{desktop_number}'",
+      :user => user
+    )
+  end
+
+  def focus_window(window_title, user = LIVE_USER)
+    def do_focus(window_title, user)
+      execute_successfully(
+        "xdotool search --name '#{window_title}' windowactivate --sync",
+        :user => user
+      )
+    end
+
+    begin
+      do_focus(window_title, user)
+    rescue ExecutionFailedInVM
+      # Often when xdotool fails to focus a window it'll work when retried
+      # after redrawing the screen.  Switching to a new virtual desktop then
+      # back seems to be a reliable way to handle this.
+      select_virtual_desktop(3)
+      select_virtual_desktop(0)
+      sleep 5 # there aren't any visual indicators which can be used here
+      do_focus(window_title, user)
+    end
+  end
+
   def file_exist?(file)
-    execute("test -e #{file}").success?
+    execute("test -e '#{file}'").success?
+  end
+
+  def directory_exist?(directory)
+    execute("test -d '#{directory}'").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)
+    cmd = execute("cat #{file}", :user => 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
+  def file_append(file, lines, user = 'root')
+    lines = lines.split("\n") if lines.class == String
+    lines.each do |line|
+      cmd = execute("echo '#{line}' >> '#{file}'", :user => user)
+      assert(cmd.success?,
+             "Could not append to '#{file}':\n#{cmd.stdout}\n#{cmd.stderr}")
+    end
+  end
+
+  def set_clipboard(text)
+    execute_successfully("echo -n '#{text}' | xsel --input --clipboard",
+                         :user => LIVE_USER)
   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)
+  def get_clipboard
+    execute_successfully("xsel --output --clipboard", :user => LIVE_USER).stdout
+  end
+
+  def internal_snapshot_xml(name)
+    disk_devs = list_disk_devs
+    disks_xml = "    <disks>\n"
+    for dev in disk_devs
+      snapshot_type = disk_type(dev) == "qcow2" ? 'internal' : 'no'
+      disks_xml +=
+        "      <disk name='#{dev}' snapshot='#{snapshot_type}'></disk>\n"
+    end
+    disks_xml += "    </disks>"
+    return <<-EOF
+<domainsnapshot>
+  <name>#{name}</name>
+  <description>Snapshot for #{name}</description>
+#{disks_xml}
+  </domainsnapshot>
+EOF
+  end
+
+  def VM.ram_only_snapshot_path(name)
+    return "#{$config["TMPDIR"]}/#{name}-snapshot.memstate"
+  end
+
+  def save_snapshot(name)
+    # If we have no qcow2 disk device, we'll use "memory state"
+    # snapshots, and if we have at least one qcow2 disk device, we'll
+    # use internal "system checkpoint" (memory + disks) snapshots. We
+    # have to do this since internal snapshots don't work when no
+    # such disk is available. We can do this with external snapshots,
+    # which are better in many ways, but libvirt doesn't know how to
+    # restore (revert back to) them yet.
+    # WARNING: If only transient disks, i.e. disks that were plugged
+    # after starting the domain, are used then the memory state will
+    # be dropped. External snapshots would also fix this.
+    internal_snapshot = false
+    domain_xml = REXML::Document.new(@domain.xml_desc)
+    domain_xml.elements.each('domain/devices/disk') do |e|
+      if e.elements['driver'].attribute('type').to_s == "qcow2"
+        internal_snapshot = true
+        break
+      end
+    end
+
+    # Note: In this case the "opposite" of `internal_snapshot` is not
+    # anything relating to external snapshots, but actually "memory
+    # state"(-only) snapshots.
+    if internal_snapshot
+      xml = internal_snapshot_xml(name)
+      @domain.snapshot_create_xml(xml)
+    else
+      snapshot_path = VM.ram_only_snapshot_path(name)
+      @domain.save(snapshot_path)
+      # For consistency with the internal snapshot case (which is
+      # "live", so the domain doesn't go down) we immediately restore
+      # the snapshot.
+      # Assumption: that *immediate* save + restore doesn't mess up
+      # with network state and similar, and is fast enough to not make
+      # the clock drift too much.
+      restore_snapshot(name)
+    end
+  end
+
+  def restore_snapshot(name)
+    @domain.destroy if is_running?
+    @display.stop if @display and @display.active?
+    # See comment in save_snapshot() for details on why we use two
+    # different type of snapshots.
+    potential_ram_only_snapshot_path = VM.ram_only_snapshot_path(name)
+    if File.exist?(potential_ram_only_snapshot_path)
+      Libvirt::Domain::restore(@virt, potential_ram_only_snapshot_path)
+      @domain = @virt.lookup_domain_by_name(@domain_name)
+    else
+      begin
+        potential_internal_snapshot = @domain.lookup_snapshot_by_name(name)
+        @domain.revert_to_snapshot(potential_internal_snapshot)
+      rescue Libvirt::RetrieveError
+        raise "No such (internal nor external) snapshot #{name}"
+      end
+    end
     @display.start
   end
 
+  def VM.remove_snapshot(name)
+    old_domain = $virt.lookup_domain_by_name(LIBVIRT_DOMAIN_NAME)
+    potential_ram_only_snapshot_path = VM.ram_only_snapshot_path(name)
+    if File.exist?(potential_ram_only_snapshot_path)
+      File.delete(potential_ram_only_snapshot_path)
+    else
+      snapshot = old_domain.lookup_snapshot_by_name(name)
+      snapshot.delete
+    end
+  end
+
+  def VM.snapshot_exists?(name)
+    return true if File.exist?(VM.ram_only_snapshot_path(name))
+    old_domain = $virt.lookup_domain_by_name(LIBVIRT_DOMAIN_NAME)
+    snapshot = old_domain.lookup_snapshot_by_name(name)
+    return snapshot != nil
+  rescue Libvirt::RetrieveError
+    return false
+  end
+
+  def VM.remove_all_snapshots
+    Dir.glob("#{$config["TMPDIR"]}/*-snapshot.memstate").each do |file|
+      File.delete(file)
+    end
+    old_domain = $virt.lookup_domain_by_name(LIBVIRT_DOMAIN_NAME)
+    old_domain.list_all_snapshots.each { |snapshot| snapshot.delete }
+  rescue Libvirt::RetrieveError
+    # No such domain, so no snapshots either.
+  end
+
   def start
     return if is_running?
     @domain.create
@@ -394,9 +652,7 @@ EOF
   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?
+    @domain.reset if is_running?
   end
 
   def power_off
@@ -404,12 +660,6 @@ EOF
     @display.stop
   end
 
-  def destroy
-    clean_up_domain
-    clean_up_net
-    power_off
-  end
-
   def take_screenshot(description)
     @display.take_screenshot(description)
   end
diff --git a/features/support/hooks.rb b/features/support/hooks.rb
index d9dc03a..be8a023 100644
--- a/features/support/hooks.rb
+++ b/features/support/hooks.rb
@@ -1,55 +1,145 @@
 require 'fileutils'
+require 'rb-inotify'
 require 'time'
 require 'tmpdir'
 
-# For @product tests
-####################
-
-def delete_snapshot(snapshot)
-  if snapshot and File.exist?(snapshot)
-    File.delete(snapshot)
+# Run once, before any feature
+AfterConfiguration do |config|
+  # Reorder the execution of some features. As we progress through a
+  # run we accumulate more and more snapshots and hence use more and
+  # more disk space, but some features will leave nothing behind
+  # and/or possibly use large amounts of disk space temporarily for
+  # various reasons. By running these first we minimize the amount of
+  # disk space needed.
+  prioritized_features = [
+    # Features not using snapshots but using large amounts of scratch
+    # space for other reasons:
+    'features/erase_memory.feature',
+    'features/untrusted_partitions.feature',
+    # Features using temporary snapshots:
+    'features/apt.feature',
+    'features/i2p.feature',
+    'features/root_access_control.feature',
+    'features/time_syncing.feature',
+    'features/tor_bridges.feature',
+    # This feature needs the almost biggest snapshot (USB install,
+    # excluding persistence) and will create yet another disk and
+    # install Tails on it. This should be the peak of disk usage.
+    'features/usb_install.feature',
+  ]
+  feature_files = config.feature_files
+  # The &-intersection is specified to keep the element ordering of
+  # the *left* operand.
+  intersection = prioritized_features & feature_files
+  if not intersection.empty?
+    feature_files -= intersection
+    feature_files = intersection + feature_files
+    config.define_singleton_method(:feature_files) { feature_files }
   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
+  # Used to keep track of when we start our first @product feature, when
+  # we'll do some special things.
+  $started_first_product_feature = false
 
-BeforeFeature('@product') do |feature|
-  if File.exist?($tmp_dir)
-    if !File.directory?($tmp_dir)
-      raise "Temporary directory '#{$tmp_dir}' exists but is not a " +
+  if File.exist?($config["TMPDIR"])
+    if !File.directory?($config["TMPDIR"])
+      raise "Temporary directory '#{$config["TMPDIR"]}' exists but is not a " +
             "directory"
     end
-    if !File.owned?($tmp_dir)
-      raise "Temporary directory '#{$tmp_dir}' must be owned by the " +
+    if !File.owned?($config["TMPDIR"])
+      raise "Temporary directory '#{$config["TMPDIR"]}' must be owned by the " +
             "current user"
     end
-    FileUtils.chmod(0755, $tmp_dir)
+    FileUtils.chmod(0755, $config["TMPDIR"])
   else
     begin
-      Dir.mkdir($tmp_dir)
+      FileUtils.mkdir_p($config["TMPDIR"])
     rescue Errno::EACCES => e
       raise "Cannot create temporary directory: #{e.to_s}"
     end
   end
-  delete_all_snapshots if !$keep_snapshots
-  if $tails_iso.nil?
+
+  # Start a thread that monitors a pseudo fifo file and debug_log():s
+  # anything written to it "immediately" (well, as fast as inotify
+  # detects it). We're forced to a convoluted solution like this
+  # because CRuby's thread support is horribly as soon as IO is mixed
+  # in (other threads get blocked).
+  FileUtils.rm(DEBUG_LOG_PSEUDO_FIFO) if File.exist?(DEBUG_LOG_PSEUDO_FIFO)
+  FileUtils.touch(DEBUG_LOG_PSEUDO_FIFO)
+  at_exit do
+    FileUtils.rm(DEBUG_LOG_PSEUDO_FIFO) if File.exist?(DEBUG_LOG_PSEUDO_FIFO)
+  end
+  Thread.new do
+    File.open(DEBUG_LOG_PSEUDO_FIFO) do |fd|
+      watcher = INotify::Notifier.new
+      watcher.watch(DEBUG_LOG_PSEUDO_FIFO, :modify) do
+        line = fd.read.chomp
+        debug_log(line) if line and line.length > 0
+      end
+      watcher.run
+    end
+  end
+  # Fix Sikuli's debug_log():ing.
+  bind_java_to_pseudo_fifo_logger
+end
+
+# Common
+########
+
+After do
+  if @after_scenario_hooks
+    @after_scenario_hooks.each { |block| block.call }
+  end
+  @after_scenario_hooks = Array.new
+end
+
+BeforeFeature('@product', '@source') do |feature|
+  raise "Feature #{feature.file} is tagged both @product and @source, " +
+        "which is an impossible combination"
+end
+
+at_exit do
+  $vm.destroy_and_undefine if $vm
+  if $virt
+    unless KEEP_SNAPSHOTS
+      VM.remove_all_snapshots
+      $vmstorage.clear_pool
+    end
+    $vmnet.destroy_and_undefine
+    $virt.close
+  end
+  # The artifacts directory is empty (and useless) if it contains
+  # nothing but the mandatory . and ..
+  if Dir.entries(ARTIFACTS_DIR).size <= 2
+    FileUtils.rmdir(ARTIFACTS_DIR)
+  end
+end
+
+# For @product tests
+####################
+
+def add_after_scenario_hook(&block)
+  @after_scenario_hooks ||= Array.new
+  @after_scenario_hooks << block
+end
+
+def save_failure_artifact(type, path)
+  $failure_artifacts << [type, path]
+end
+
+BeforeFeature('@product') do |feature|
+  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)
+  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)
+    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 " +
@@ -57,78 +147,120 @@ BeforeFeature('@product') do |feature|
       end
     end
   else
-    raise "The specified Tails ISO image '#{$tails_iso}' does not exist"
+    raise "The specified Tails ISO image '#{TAILS_ISO}' does not exist"
+  end
+  if !File.exist?(OLD_TAILS_ISO)
+    raise "The specified old Tails ISO image '#{OLD_TAILS_ISO}' does not exist"
+  end
+  if not($started_first_product_feature)
+    $virt = Libvirt::open("qemu:///system")
+    VM.remove_all_snapshots if !KEEP_SNAPSHOTS
+    $vmnet = VMNet.new($virt, VM_XML_PATH)
+    $vmstorage = VMStorage.new($virt, VM_XML_PATH)
+    $started_first_product_feature = true
   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"
+  unless KEEP_SNAPSHOTS
+    checkpoints.each do |name, vals|
+      if vals[:temporary] and VM.snapshot_exists?(name)
+        VM.remove_snapshot(name)
+      end
+    end
   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
+# Cucumber Before hooks are executed in the order they are listed, and
+# we want this hook to always run first, so it must always be the
+# *first* Before hook matching @product listed in this file.
+Before('@product') do |scenario|
+  $failure_artifacts = Array.new
+  if $config["CAPTURE"]
+    video_name = sanitize_filename("#{scenario.name}.mkv")
+    @video_path = "#{ARTIFACTS_DIR}/#{video_name}"
+    capture = IO.popen(['avconv',
+                        '-f', 'x11grab',
+                        '-s', '1024x768',
+                        '-r', '15',
+                        '-i', "#{$config['DISPLAY']}.0",
+                        '-an',
+                        '-c:v', 'libx264',
+                        '-y',
+                        @video_path,
+                        :err => ['/dev/null', 'w'],
+                       ])
+    @video_capture_pid = capture.pid
   end
-  @theme = "gnome"
+  @screen = Sikuli::Screen.new
+  # English will be assumed if this is not overridden
+  @language = ""
   @os_loader = "MBR"
+  @sudo_password = "asdf"
+  @persistence_password = "asdf"
 end
 
-# AfterScenario
+# Cucumber After hooks are executed in the *reverse* order they are
+# listed, and we want this hook to always run second last, so it must always
+# be the *second* After hook matching @product listed in this file --
+# hooks added dynamically via add_after_scenario_hook() are supposed to
+# truly be last.
 After('@product') do |scenario|
-  if (scenario.status != :passed)
-    time_of_fail = Time.now - $time_at_start
+  if @video_capture_pid
+    # We can be incredibly fast at detecting errors sometimes, so the
+    # screen barely "settles" when we end up here and kill the video
+    # capture. Let's wait a few seconds more to make it easier to see
+    # what the error was.
+    sleep 3 if scenario.failed?
+    Process.kill("INT", @video_capture_pid)
+    save_failure_artifact("Video", @video_path)
+  end
+  if scenario.failed?
+    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"
-    jenkins_live_screenshot = "#{$tmp_dir}/screenshot.png"
-    jenkins_live_thumb = "#{$tmp_dir}/screenshot-thumb.png"
-    FileUtils.mv(tmp, out)
-    FileUtils.cp(out, jenkins_live_screenshot)
-    STDERR.puts("Took screenshot \"#{out}\"")
-    if $pause_on_fail
-      STDERR.puts ""
-      STDERR.puts "Press ENTER to continue running the test suite"
-      STDIN.gets
+    elapsed = "#{hrs}:#{mins}:#{secs}"
+    info_log("Scenario failed at time #{elapsed}")
+    screen_capture = @screen.capture
+    save_failure_artifact("Screenshot", screen_capture.getFilename)
+    $failure_artifacts.sort!
+    $failure_artifacts.each do |type, file|
+      artifact_name = sanitize_filename("#{elapsed}_#{scenario.name}#{File.extname(file)}")
+      artifact_path = "#{ARTIFACTS_DIR}/#{artifact_name}"
+      assert(File.exist?(file))
+      FileUtils.mv(file, artifact_path)
+      info_log
+      info_log_artifact_location(type, artifact_path)
+    end
+    pause("Scenario failed") if $config["PAUSE_ON_FAIL"]
+  else
+    if @video_path && File.exist?(@video_path) && not($config['CAPTURE_ALL'])
+      FileUtils.rm(@video_path)
     end
   end
-  unless system("convert #{jenkins_live_screenshot} -adaptive-resize 128x96 #{jenkins_live_thumb}")
-    raise StandardError.new("convert command exited with #{$?}")
-  end
-  if @sniffer
-    @sniffer.stop
-    @sniffer.clear
+end
+
+Before('@product', '@check_tor_leaks') do |scenario|
+  @tor_leaks_sniffer = Sniffer.new(sanitize_filename(scenario.name), $vmnet)
+  @tor_leaks_sniffer.capture
+  add_after_scenario_hook do
+    @tor_leaks_sniffer.clear
   end
-  @vm.destroy if @vm
 end
 
-After('@product', '~@keep_volumes') do
-  VM.storage.clear_volumes
+After('@product', '@check_tor_leaks') do |scenario|
+  @tor_leaks_sniffer.stop
+  if scenario.passed?
+    if @bridge_hosts.nil?
+      expected_tor_nodes = get_all_tor_nodes
+    else
+      expected_tor_nodes = @bridge_hosts
+    end
+    leaks = FirewallLeakCheck.new(@tor_leaks_sniffer.pcap_file,
+                                  :accepted_hosts => expected_tor_nodes)
+    leaks.assert_no_leaks
+  end
 end
 
 # For @source tests
@@ -146,17 +278,3 @@ 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
diff --git a/features/time_syncing.feature b/features/time_syncing.feature
index a32d5a7..69a0c9e 100644
--- a/features/time_syncing.feature
+++ b/features/time_syncing.feature
@@ -1,41 +1,129 @@
- at product
+ at product @check_tor_leaks
 Feature: Time syncing
   As a Tails user
   I want Tor to work properly
   And for that I need a reasonably accurate system clock
 
-  Background:
-    Given a computer
-    And I start Tails from DVD with network unplugged and I login
-    And I save the state so the background can be restored next scenario
-
+  #10497: wait_until_tor_is_working
+  @fragile
   Scenario: Clock with host's time
+    Given I have started Tails from DVD without network and logged in
     When the network is plugged
     And Tor is ready
     Then Tails clock is less than 5 minutes incorrect
 
+  #10497: wait_until_tor_is_working
+  @fragile
+  Scenario: Clock with host's time in bridge mode
+    Given I have started Tails from DVD without network and logged in with bridge mode enabled
+    When the network is plugged
+    And the Tor Launcher autostarts
+    And I configure some Bridge pluggable transports in Tor Launcher
+    And Tor is ready
+    Then Tails clock is less than 5 minutes incorrect
+
+  #10497: wait_until_tor_is_working
+  @fragile
   Scenario: Clock is one day in the past
+    Given I have started Tails from DVD without network and logged in
+    When I bump the system time with "-1 day"
+    And the network is plugged
+    And Tor is ready
+    Then Tails clock is less than 5 minutes incorrect
+
+  #10497: wait_until_tor_is_working
+  @fragile
+  Scenario: Clock is one day in the past in bridge mode
+    Given I have started Tails from DVD without network and logged in with bridge mode enabled
     When I bump the system time with "-1 day"
     And the network is plugged
+    And the Tor Launcher autostarts
+    And I configure some Bridge pluggable transports in Tor Launcher
+    And Tor is ready
+    Then Tails clock is less than 5 minutes incorrect
+
+  #10497: wait_until_tor_is_working
+  @fragile
+  Scenario: Clock is way in the past
+    Given I have started Tails from DVD without network and logged in
+    # 13 weeks will span over two Tails release cycles.
+    When I bump the system time with "-13 weeks"
+    And the network is plugged
     And Tor is ready
     Then Tails clock is less than 5 minutes incorrect
 
-  Scenario: Clock way in the past
-    When I set the system time to "01 Jan 2000 12:34:56"
+  #10497: wait_until_tor_is_working
+  @fragile
+  Scenario: Clock way in the past in bridge mode
+    Given I have started Tails from DVD without network and logged in with bridge mode enabled
+    When I bump the system time with "-6 weeks"
     And the network is plugged
+    And the Tor Launcher autostarts
+    And I configure some Bridge pluggable transports in Tor Launcher
     And Tor is ready
     Then Tails clock is less than 5 minutes incorrect
 
+  #10497: wait_until_tor_is_working
+  #10440: Time syncing tests are fragile
+  @fragile
   Scenario: Clock is one day in the future
+    Given I have started Tails from DVD without network and logged in
     When I bump the system time with "+1 day"
     And the network is plugged
     And Tor is ready
     Then Tails clock is less than 5 minutes incorrect
 
+  #10497: wait_until_tor_is_working
+  @fragile
+  Scenario: Clock is one day in the future in bridge mode
+    Given I have started Tails from DVD without network and logged in with bridge mode enabled
+    When I bump the system time with "+1 day"
+    And the network is plugged
+    And the Tor Launcher autostarts
+    And I configure some Bridge pluggable transports in Tor Launcher
+    And Tor is ready
+    Then Tails clock is less than 5 minutes incorrect
+
+  #10497: wait_until_tor_is_working
+  #10440: Time syncing tests are fragile
+  @fragile
   Scenario: Clock way in the future
+    Given I have started Tails from DVD without network and logged in
+    When I set the system time to "01 Jan 2020 12:34:56"
+    And the network is plugged
+    And Tor is ready
+    Then Tails clock is less than 5 minutes incorrect
+
+  #10497: wait_until_tor_is_working
+  #10440: Time syncing tests are fragile
+  @fragile
+  Scenario: Clock way in the future in bridge mode
+    Given I have started Tails from DVD without network and logged in with bridge mode enabled
     When I set the system time to "01 Jan 2020 12:34:56"
     And the network is plugged
+    And the Tor Launcher autostarts
+    And I configure some Bridge pluggable transports in Tor Launcher
     And Tor is ready
     Then Tails clock is less than 5 minutes incorrect
 
-#  Scenario: Clock vs Tor consensus' valid-{after,until} etc.
+Scenario: The system time is not synced to the hardware clock
+    Given I have started Tails from DVD without network and logged in
+    When I bump the system time with "-15 days"
+    And I warm reboot the computer
+    And the computer reboots Tails
+    Then Tails' hardware clock is close to the host system's time
+
+  Scenario: Anti-test: Changes to the hardware clock are kept when rebooting
+    Given I have started Tails from DVD without network and logged in
+    When I bump the hardware clock's time with "-15 days"
+    And I warm reboot the computer
+    And the computer reboots Tails
+    Then the hardware clock is still off by "-15 days"
+
+  Scenario: Boot with a hardware clock set way in the past and make sure that Tails sets the clock to the build date
+    Given a computer
+    And the network is unplugged
+    And the hardware clock is set to "01 Jan 2000 12:34:56"
+    And I start the computer
+    And the computer boots Tails
+    Then the system clock is just past Tails' build date
diff --git a/features/tor_bridges.feature b/features/tor_bridges.feature
new file mode 100644
index 0000000..b5277ca
--- /dev/null
+++ b/features/tor_bridges.feature
@@ -0,0 +1,36 @@
+ at product @fragile
+Feature: Using Tails with Tor pluggable transports
+  As a Tails user
+  I want to circumvent censorship of Tor by using Tor pluggable transports
+  And avoid connecting directly to the Tor Network
+
+  Background:
+    Given I have started Tails from DVD without network and logged in with bridge mode enabled
+    And I capture all network traffic
+    When the network is plugged
+    Then the Tor Launcher autostarts
+    And the Tor Launcher uses all expected TBB shared libraries
+
+  Scenario: Using bridges
+    When I configure some Bridge pluggable transports in Tor Launcher
+    Then Tor is ready
+    And available upgrades have been checked
+    And all Internet traffic has only flowed through the configured pluggable transports
+
+  Scenario: Using obfs2 pluggable transports
+    When I configure some obfs2 pluggable transports in Tor Launcher
+    Then Tor is ready
+    And available upgrades have been checked
+    And all Internet traffic has only flowed through the configured pluggable transports
+
+  Scenario: Using obfs3 pluggable transports
+    When I configure some obfs3 pluggable transports in Tor Launcher
+    Then Tor is ready
+    And available upgrades have been checked
+    And all Internet traffic has only flowed through the configured pluggable transports
+
+  Scenario: Using obfs4 pluggable transports
+    When I configure some obfs4 pluggable transports in Tor Launcher
+    Then Tor is ready
+    And available upgrades have been checked
+    And all Internet traffic has only flowed through the configured pluggable transports
diff --git a/features/tor_enforcement.feature b/features/tor_enforcement.feature
new file mode 100644
index 0000000..164220a
--- /dev/null
+++ b/features/tor_enforcement.feature
@@ -0,0 +1,76 @@
+#10497: wait_until_tor_is_working
+ at product @fragile
+Feature: The Tor enforcement is effective
+  As a Tails user
+  I want all direct Internet connections I do by mistake or applications do by misconfiguration or buggy leaks to be blocked
+  And as a Tails developer
+  I want to ensure that the automated test suite detects firewall leaks reliably
+
+  Scenario: Tails' Tor binary is configured to use the expected Tor authorities
+    Given I have started Tails from DVD and logged in and the network is connected
+    Then the Tor binary is configured to use the expected Tor authorities
+
+  Scenario: The firewall configuration is very restrictive
+    Given I have started Tails from DVD and logged in and the network is connected
+    Then the firewall's policy is to drop all IPv4 traffic
+    And the firewall is configured to only allow the clearnet and debian-tor users to connect directly to the Internet over IPv4
+    And the firewall's NAT rules only redirect traffic for Tor's TransPort and DNSPort
+    And the firewall is configured to block all external IPv6 traffic
+
+  @fragile
+  Scenario: Anti test: Detecting IPv4 TCP leaks from the Unsafe Browser with the firewall leak detector
+    Given I have started Tails from DVD and logged in and the network is connected
+    And I capture all network traffic
+    When I successfully start the Unsafe Browser
+    And I open the address "https://check.torproject.org" in the Unsafe Browser
+    And I see "UnsafeBrowserTorCheckFail.png" after at most 60 seconds
+    Then the firewall leak detector has detected IPv4 TCP leaks
+
+  Scenario: Anti test: Detecting IPv4 TCP leaks of TCP DNS lookups with the firewall leak detector
+    Given I have started Tails from DVD and logged in and the network is connected
+    And I capture all network traffic
+    And I disable Tails' firewall
+    When I do a TCP DNS lookup of "torproject.org"
+    Then the firewall leak detector has detected IPv4 TCP leaks
+
+  Scenario: Anti test: Detecting IPv4 non-TCP leaks (UDP) of UDP DNS lookups with the firewall leak detector
+    Given I have started Tails from DVD and logged in and the network is connected
+    And I capture all network traffic
+    And I disable Tails' firewall
+    When I do a UDP DNS lookup of "torproject.org"
+    Then the firewall leak detector has detected IPv4 non-TCP leaks
+
+  Scenario: Anti test: Detecting IPv4 non-TCP (ICMP) leaks of ping with the firewall leak detector
+    Given I have started Tails from DVD and logged in and the network is connected
+    And I capture all network traffic
+    And I disable Tails' firewall
+    When I send some ICMP pings
+    Then the firewall leak detector has detected IPv4 non-TCP leaks
+
+  @check_tor_leaks
+  Scenario: The Tor enforcement is effective at blocking untorified TCP connection attempts
+    Given I have started Tails from DVD and logged in and the network is connected
+    When I open an untorified TCP connections to 1.2.3.4 on port 42 that is expected to fail
+    Then the untorified connection fails
+    And the untorified connection is logged as dropped by the firewall
+
+  @check_tor_leaks
+  Scenario: The Tor enforcement is effective at blocking untorified UDP connection attempts
+    Given I have started Tails from DVD and logged in and the network is connected
+    When I open an untorified UDP connections to 1.2.3.4 on port 42 that is expected to fail
+    Then the untorified connection fails
+    And the untorified connection is logged as dropped by the firewall
+
+  @check_tor_leaks @fragile
+  Scenario: The Tor enforcement is effective at blocking untorified ICMP connection attempts
+    Given I have started Tails from DVD and logged in and the network is connected
+    When I open an untorified ICMP connections to 1.2.3.4 that is expected to fail
+    Then the untorified connection fails
+    And the untorified connection is logged as dropped by the firewall
+
+  Scenario: The system DNS is always set up to use Tor's DNSPort
+    Given I have started Tails from DVD without network and logged in
+    And the system DNS is using the local DNS resolver
+    And the network is plugged
+    And Tor is ready
+    Then the system DNS is still using the local DNS resolver
diff --git a/features/tor_stream_isolation.feature b/features/tor_stream_isolation.feature
new file mode 100644
index 0000000..c51c641
--- /dev/null
+++ b/features/tor_stream_isolation.feature
@@ -0,0 +1,62 @@
+#10497: wait_until_tor_is_working
+ at product @check_tor_leaks @fragile
+Feature: Tor stream isolation is effective
+  As a Tails user
+  I want my Torified sessions to be sensibly isolated from each other to prevent identity correlation
+
+  Background:
+    Given I have started Tails from DVD and logged in and the network is connected
+
+  Scenario: tails-security-check is using the Tails-specific SocksPort
+    When I monitor the network connections of tails-security-check
+    And I re-run tails-security-check
+    Then I see that tails-security-check is properly stream isolated
+
+  Scenario: htpdate is using the Tails-specific SocksPort
+    When I monitor the network connections of htpdate
+    And I re-run htpdate
+    Then I see that htpdate is properly stream isolated
+
+  Scenario: tails-upgrade-frontend-wrapper is using the Tails-specific SocksPort
+    When I monitor the network connections of tails-upgrade-frontend-wrapper
+    And I re-run tails-upgrade-frontend-wrapper
+    Then I see that tails-upgrade-frontend-wrapper is properly stream isolated
+
+  Scenario: The Tor Browser is using the web browser-specific SocksPort
+    When I monitor the network connections of Tor Browser
+    And I start the Tor Browser
+    And the Tor Browser has started and loaded the startup page
+    Then I see that Tor Browser is properly stream isolated
+
+  @fragile
+  Scenario: Gobby is using the default SocksPort
+    When I monitor the network connections of Gobby
+    And I start "Gobby" via the GNOME "Internet" applications menu
+    And I connect Gobby to "gobby.debian.org"
+    Then I see that Gobby is properly stream isolated
+
+  Scenario: SSH is using the default SocksPort
+    When I monitor the network connections of SSH
+    And I run "ssh lizard.tails.boum.org" in GNOME Terminal
+    And I see "SSHAuthVerification.png" after at most 60 seconds
+    Then I see that SSH is properly stream isolated
+
+  Scenario: whois lookups use the default SocksPort
+    When I monitor the network connections of whois
+    And I query the whois directory service for "boum.org"
+    And the whois command is successful
+    Then I see that whois is properly stream isolated
+
+  @fragile
+  Scenario: Explicitly torify-wrapped applications are using the default SocksPort
+    When I monitor the network connections of Gobby
+    And I run "torify /usr/bin/gobby-0.5" in GNOME Terminal
+    And I connect Gobby to "gobby.debian.org"
+    Then I see that Gobby is properly stream isolated
+
+  @fragile
+  Scenario: Explicitly torsocks-wrapped applications are using the default SocksPort
+    When I monitor the network connections of Gobby
+    And I run "torsocks /usr/bin/gobby-0.5" in GNOME Terminal
+    And I connect Gobby to "gobby.debian.org"
+    Then I see that Gobby is properly stream isolated
diff --git a/features/torified_browsing.feature b/features/torified_browsing.feature
index cc9ddf1..78a4013 100644
--- a/features/torified_browsing.feature
+++ b/features/torified_browsing.feature
@@ -1,35 +1,172 @@
- at product
+#10376: The "the Tor Browser loads the (startup page|Tails roadmap)" step is fragile
+#10497: wait_until_tor_is_working
+ at product @fragile
 Feature: Browsing the web using the Tor Browser
   As a Tails user
   when I browse the web using the Tor Browser
   all Internet traffic should flow only through Tor
 
-  Background:
-    Given a computer
+  Scenario: The Tor Browser cannot access the LAN
+    Given I have started Tails from DVD and logged in and the network is connected
+    And a web server is running on the LAN
     And I capture all network traffic
-    And I start the computer
-    And the computer boots Tails
-    And I log in to a new session
-    And GNOME has started
-    And Tor is ready
-    And available upgrades have been checked
-    And all notifications have disappeared
-    And I save the state so the background can be restored next scenario
+    When I start the Tor Browser
+    And the Tor Browser has started and loaded the startup page
+    And I open a page on the LAN web server in the Tor Browser
+    Then I see "TorBrowserUnableToConnect.png" after at most 20 seconds
+    And no traffic has flowed to the LAN
+
+  @check_tor_leaks
+  Scenario: The Tor Browser directory is usable
+    Given I have started Tails from DVD and logged in and the network is connected
+    Then the amnesiac Tor Browser directory exists
+    And there is a GNOME bookmark for the amnesiac Tor Browser directory
+    And the persistent Tor Browser directory does not exist
+    When I start the Tor Browser
+    And the Tor Browser has started and loaded the startup page
+    Then I can save the current page as "index.html" to the default downloads directory
+    And I can print the current page as "output.pdf" to the default downloads directory
+
+  @check_tor_leaks @fragile
+  Scenario: Downloading files with the Tor Browser
+    Given I have started Tails from DVD and logged in and the network is connected
+    When I start the Tor Browser
+    Then the Tor Browser has started and loaded the startup page
+    When I download some file in the Tor Browser
+    Then I get the browser download dialog
+    When I save the file to the default Tor Browser download directory
+    Then the file is saved to the default Tor Browser download directory
+
+  @check_tor_leaks @fragile
+  Scenario: Playing HTML5 audio
+    Given I have started Tails from DVD and logged in and the network is connected
+    When I start the Tor Browser
+    And the Tor Browser has started and loaded the startup page
+    And no application is playing audio
+    And I open the address "http://www.terrillthompson.com/tests/html5-audio.html" in the Tor Browser
+    And I click the HTML5 play button
+    And 1 application is playing audio after 10 seconds
+
+  @check_tor_leaks @fragile
+  Scenario: Watching a WebM video
+    Given I have started Tails from DVD and logged in and the network is connected
+    When I start the Tor Browser
+    And the Tor Browser has started and loaded the startup page
+    And I open the address "https://webm.html5.org/test.webm" in the Tor Browser
+    And I click the blocked video icon
+    And I see "TorBrowserNoScriptTemporarilyAllowDialog.png" after at most 30 seconds
+    And I accept to temporarily allow playing this video
+    Then I see "TorBrowserSampleRemoteWebMVideoFrame.png" after at most 180 seconds
+
+  Scenario: I can view a file stored in "~/Tor Browser" but not in ~/.gnupg
+    Given I have started Tails from DVD and logged in and the network is connected
+    And I copy "/usr/share/synaptic/html/index.html" to "/home/amnesia/Tor Browser/synaptic.html" as user "amnesia"
+    And I copy "/usr/share/synaptic/html/index.html" to "/home/amnesia/.gnupg/synaptic.html" as user "amnesia"
+    And I copy "/usr/share/synaptic/html/index.html" to "/tmp/synaptic.html" as user "amnesia"
+    Then the file "/home/amnesia/.gnupg/synaptic.html" exists
+    And the file "/lib/live/mount/overlay/home/amnesia/.gnupg/synaptic.html" exists
+    And the file "/live/overlay/home/amnesia/.gnupg/synaptic.html" exists
+    And the file "/tmp/synaptic.html" exists
+    Given I start monitoring the AppArmor log of "/usr/local/lib/tor-browser/firefox"
+    When I start the Tor Browser
+    And the Tor Browser has started and loaded the startup page
+    And I open the address "file:///home/amnesia/Tor Browser/synaptic.html" in the Tor Browser
+    Then I see "TorBrowserSynapticManual.png" after at most 5 seconds
+    And AppArmor has not denied "/usr/local/lib/tor-browser/firefox" from opening "/home/amnesia/Tor Browser/synaptic.html"
+    Given I restart monitoring the AppArmor log of "/usr/local/lib/tor-browser/firefox"
+    When I open the address "file:///home/amnesia/.gnupg/synaptic.html" in the Tor Browser
+    Then I do not see "TorBrowserSynapticManual.png" after at most 5 seconds
+    And AppArmor has denied "/usr/local/lib/tor-browser/firefox" from opening "/home/amnesia/.gnupg/synaptic.html"
+    Given I restart monitoring the AppArmor log of "/usr/local/lib/tor-browser/firefox"
+    When I open the address "file:///lib/live/mount/overlay/home/amnesia/.gnupg/synaptic.html" in the Tor Browser
+    Then I do not see "TorBrowserSynapticManual.png" after at most 5 seconds
+    And AppArmor has denied "/usr/local/lib/tor-browser/firefox" from opening "/lib/live/mount/overlay/home/amnesia/.gnupg/synaptic.html"
+    Given I restart monitoring the AppArmor log of "/usr/local/lib/tor-browser/firefox"
+    When I open the address "file:///live/overlay/home/amnesia/.gnupg/synaptic.html" in the Tor Browser
+    Then I do not see "TorBrowserSynapticManual.png" after at most 5 seconds
+    # Due to our AppArmor aliases, /live/overlay will be treated
+    # as /lib/live/mount/overlay.
+    And AppArmor has denied "/usr/local/lib/tor-browser/firefox" from opening "/lib/live/mount/overlay/home/amnesia/.gnupg/synaptic.html"
+    # We do not get any AppArmor log for when access to files in /tmp is denied
+    # since we explictly override (commit 51c0060) the rules (from the user-tmp
+    # abstration) that would otherwise allow it, and we do so with "deny", which
+    # also specifies "noaudit". We could explicitly specify "audit deny" and
+    # then have logs, but it could be a problem when we set up desktop
+    # notifications for AppArmor denials (#9337).
+    When I open the address "file:///tmp/synaptic.html" in the Tor Browser
+    Then I do not see "TorBrowserSynapticManual.png" after at most 5 seconds
+
+  @doc
+  Scenario: The "Tails documentation" link on the Desktop works
+    Given I have started Tails from DVD and logged in and the network is connected
+    When I double-click on the "Tails documentation" link on the Desktop
+    Then the Tor Browser has started
+    And I see "TailsOfflineDocHomepage.png" after at most 10 seconds
 
   Scenario: The Tor Browser uses TBB's shared libraries
+    Given I have started Tails from DVD and logged in and the network is connected
     When I start the Tor Browser
     And the Tor Browser has started
     Then the Tor Browser uses all expected TBB shared libraries
 
+  @check_tor_leaks @fragile
   Scenario: Opening check.torproject.org in the Tor Browser shows the green onion and the congratulations message
+    Given I have started Tails from DVD and logged in and the network is connected
+    When I start the Tor Browser
+    And the Tor Browser has started and loaded the startup page
+    And I open the address "https://check.torproject.org" in the Tor Browser
+    Then I see "TorBrowserTorCheck.png" after at most 180 seconds
+
+  @check_tor_leaks @fragile
+  Scenario: The Tor Browser's "New identity" feature works as expected
+    Given I have started Tails from DVD and logged in and the network is connected
     When I start the Tor Browser
     And the Tor Browser has started and loaded the startup page
     And I open the address "https://check.torproject.org" in the Tor Browser
     Then I see "TorBrowserTorCheck.png" after at most 180 seconds
-    And all Internet traffic has only flowed through Tor
+    When I request a new identity using Torbutton
+    And I acknowledge Torbutton's New Identity confirmation prompt
+    Then the Tor Browser loads the startup page
 
   Scenario: The Tor Browser should not have any plugins enabled
+    Given I have started Tails from DVD and logged in and the network is connected
     When I start the Tor Browser
     And the Tor Browser has started and loaded the startup page
-    And I open the address "about:plugins" in the Tor Browser
-    Then I see "TorBrowserNoPlugins.png" after at most 60 seconds
+    Then the Tor Browser has no plugins installed
+
+  #10497, #10720
+  @fragile
+  Scenario: The persistent Tor Browser directory is usable
+    Given I have started Tails without network from a USB drive with a persistent partition enabled and logged in
+    And the network is plugged
+    And Tor is ready
+    And available upgrades have been checked
+    And all notifications have disappeared
+    Then the persistent Tor Browser directory exists
+    And there is a GNOME bookmark for the persistent Tor Browser directory
+    When I start the Tor Browser
+    And the Tor Browser has started and loaded the startup page
+    And I can save the current page as "index.html" to the persistent Tor Browser directory
+    When I open the address "file:///home/amnesia/Persistent/Tor Browser/index.html" in the Tor Browser
+    Then I see "TorBrowserSavedStartupPage.png" after at most 10 seconds
+    And I can print the current page as "output.pdf" to the persistent Tor Browser directory
+
+  #10720
+  @fragile
+  Scenario: Persistent browser bookmarks
+    Given I have started Tails without network from a USB drive with a persistent partition enabled and logged in
+    And all persistence presets are enabled
+    And all persistent filesystems have safe access rights
+    And all persistence configuration files have safe access rights
+    And all persistent directories have safe access rights
+    And I start the Tor Browser in offline mode
+    And the Tor Browser has started in offline mode
+    And I add a bookmark to eff.org in the Tor Browser
+    And I warm reboot the computer
+    And the computer reboots Tails
+    And I enable read-only persistence
+    And I log in to a new session
+    And the Tails desktop is ready
+    And I start the Tor Browser in offline mode
+    And the Tor Browser has started in offline mode
+    Then the Tor Browser has a bookmark to eff.org
diff --git a/features/torified_git.feature b/features/torified_git.feature
new file mode 100644
index 0000000..04e19a5
--- /dev/null
+++ b/features/torified_git.feature
@@ -0,0 +1,31 @@
+#10497: wait_until_tor_is_working
+#10444: Git tests are fragile
+ at product @check_tor_leaks @fragile
+Feature: Cloning a Git repository
+  As a Tails user
+  when I clone a Git repository
+  all Internet traffic should flow only through Tor
+
+  Background:
+    Given I have started Tails from DVD and logged in and the network is connected
+
+  @fragile
+  Scenario: Cloning a Git repository anonymously over HTTPS
+    When I run "git clone https://git-tails.immerda.ch/myprivatekeyispublic/testing" in GNOME Terminal
+    Then process "git" is running within 10 seconds
+    And process "git" has stopped running after at most 180 seconds
+    And the Git repository "testing" has been cloned successfully
+
+  Scenario: Cloning a Git repository anonymously over the Git protocol
+    When I run "git clone git://git.tails.boum.org/myprivatekeyispublic/testing" in GNOME Terminal
+    Then process "git" is running within 10 seconds
+    And process "git" has stopped running after at most 180 seconds
+    And the Git repository "testing" has been cloned successfully
+
+  Scenario: Cloning git repository over SSH
+    Given I have the SSH key pair for a Git repository
+    When I run "git clone tails at git.tails.boum.org:myprivatekeyispublic/testing" in GNOME Terminal
+    Then process "git" is running within 10 seconds
+    When I verify the SSH fingerprint for the Git repository
+    And process "git" has stopped running after at most 180 seconds
+    Then the Git repository "testing" has been cloned successfully
diff --git a/features/torified_gnupg.feature b/features/torified_gnupg.feature
index a582068..374c7ba 100644
--- a/features/torified_gnupg.feature
+++ b/features/torified_gnupg.feature
@@ -1,4 +1,4 @@
- at product
+ at product @check_tor_leaks @fragile
 Feature: Keyserver interaction with GnuPG
   As a Tails user
   when I interact with keyservers using various GnuPG tools
@@ -6,26 +6,48 @@ Feature: Keyserver interaction with GnuPG
   and all Internet traffic should flow only through Tor.
 
   Background:
-    Given a computer
-    And I capture all network traffic
-    And I start the computer
-    And the computer boots Tails
-    And I log in to a new session
-    And GNOME has started
-    And Tor is ready
-    And all notifications have disappeared
-    And available upgrades have been checked
+    Given I have started Tails from DVD and logged in and the network is connected
     And the "10CC5BC7" OpenPGP key is not in the live user's public keyring
-    And I save the state so the background can be restored next scenario
+
+  Scenario: Seahorse is configured to use the correct keyserver
+   Then Seahorse is configured to use the correct keyserver
 
   Scenario: Fetching OpenPGP keys using GnuPG should work and be done over Tor.
     When I fetch the "10CC5BC7" OpenPGP key using the GnuPG CLI
     Then GnuPG uses the configured keyserver
     And the GnuPG fetch is successful
-    And the "10CC5BC7" key is in the live user's public keyring after at most 120 seconds
-    And all Internet traffic has only flowed through Tor
+    And the "10CC5BC7" key is in the live user's public keyring
 
   Scenario: Fetching OpenPGP keys using Seahorse should work and be done over Tor.
     When I fetch the "10CC5BC7" OpenPGP key using Seahorse
-    Then the "10CC5BC7" key is in the live user's public keyring after at most 120 seconds
-    And all Internet traffic has only flowed through Tor
+    And the Seahorse operation is successful
+    Then the "10CC5BC7" key is in the live user's public keyring
+
+  Scenario: Fetching OpenPGP keys using Seahorse via the Tails OpenPGP Applet should work and be done over Tor.
+    When I fetch the "10CC5BC7" OpenPGP key using Seahorse via the Tails OpenPGP Applet
+    And the Seahorse operation is successful
+    Then the "10CC5BC7" key is in the live user's public keyring
+
+  Scenario: Syncing OpenPGP keys using Seahorse should work and be done over Tor.
+    Given I fetch the "10CC5BC7" OpenPGP key using the GnuPG CLI without any signatures
+    And the GnuPG fetch is successful
+    And the "10CC5BC7" key is in the live user's public keyring
+    But the key "10CC5BC7" has only 2 signatures
+    When I start Seahorse
+    Then Seahorse has opened
+    And I enable key synchronization in Seahorse
+    And I synchronize keys in Seahorse
+    And the Seahorse operation is successful
+    Then the key "10CC5BC7" has more than 2 signatures
+
+  Scenario: Syncing OpenPGP keys using Seahorse started from the Tails OpenPGP Applet should work and be done over Tor.
+    Given I fetch the "10CC5BC7" OpenPGP key using the GnuPG CLI without any signatures
+    And the GnuPG fetch is successful
+    And the "10CC5BC7" key is in the live user's public keyring
+    But the key "10CC5BC7" has only 2 signatures
+    When I start Seahorse via the Tails OpenPGP Applet
+    Then Seahorse has opened
+    And I enable key synchronization in Seahorse
+    And I synchronize keys in Seahorse
+    And the Seahorse operation is successful
+    Then the key "10CC5BC7" has more than 2 signatures
diff --git a/features/torified_misc.feature b/features/torified_misc.feature
new file mode 100644
index 0000000..75f3fd0
--- /dev/null
+++ b/features/torified_misc.feature
@@ -0,0 +1,24 @@
+ at product @check_tor_leaks @fragile
+Feature: Various checks for torified software
+
+  Background:
+    Given I have started Tails from DVD and logged in and the network is connected
+
+  Scenario: wget(1) should work for HTTP and go through Tor.
+    When I wget "http://example.com/" to stdout
+    Then the wget command is successful
+    And the wget standard output contains "Example Domain"
+
+  Scenario: wget(1) should work for HTTPS and go through Tor.
+    When I wget "https://example.com/" to stdout
+    Then the wget command is successful
+    And the wget standard output contains "Example Domain"
+
+  Scenario: wget(1) with tricky options should work for HTTP and go through Tor.
+    When I wget "http://195.154.14.189/tails/stable/" to stdout with the '--spider --header="Host: dl.amnesia.boum.org"' options
+    Then the wget command is successful
+
+  Scenario: whois(1) should work and go through Tor.
+    When I query the whois directory service for "torproject.org"
+    Then the whois command is successful
+    Then the whois standard output contains "The Tor Project"
diff --git a/features/totem.feature b/features/totem.feature
index 2729b27..0e6fa05 100644
--- a/features/totem.feature
+++ b/features/totem.feature
@@ -5,10 +5,6 @@ Feature: Using Totem
   And AppArmor should prevent Totem from doing dangerous things
   And all Internet traffic should flow only through Tor
 
-  # We cannot use Background to save a snapshot of an already booted
-  # Tails here, due to bugs with filesystem shares vs. snapshots, as
-  # explained in checks.feature.
-
   Background:
     Given I create sample videos
 
@@ -17,43 +13,58 @@ Feature: Using Totem
     And I setup a filesystem share containing sample videos
     And I start Tails from DVD with network unplugged and I login
     And I copy the sample videos to "/home/amnesia" as user "amnesia"
+    And the file "/home/amnesia/video.mp4" exists
+    Given I start monitoring the AppArmor log of "/usr/bin/totem"
     When I open "/home/amnesia/video.mp4" with Totem
-    Then I see "SampleLocalMp4VideoFrame.png" after at most 10 seconds
+    Then I see "SampleLocalMp4VideoFrame.png" after at most 20 seconds
+    And AppArmor has not denied "/usr/bin/totem" from opening "/home/amnesia/video.mp4"
     Given I close Totem
     And I copy the sample videos to "/home/amnesia/.gnupg" as user "amnesia"
+    And the file "/home/amnesia/.gnupg/video.mp4" exists
+    And I restart monitoring the AppArmor log of "/usr/bin/totem"
     When I try to open "/home/amnesia/.gnupg/video.mp4" with Totem
     Then I see "TotemUnableToOpen.png" after at most 10 seconds
+    And AppArmor has denied "/usr/bin/totem" from opening "/home/amnesia/.gnupg/video.mp4"
+    Given I close Totem
+    And the file "/lib/live/mount/overlay/home/amnesia/.gnupg/video.mp4" exists
+    And I restart monitoring the AppArmor log of "/usr/bin/totem"
+    When I try to open "/lib/live/mount/overlay/home/amnesia/.gnupg/video.mp4" with Totem
+    Then I see "TotemUnableToOpen.png" after at most 10 seconds
+    And AppArmor has denied "/usr/bin/totem" from opening "/lib/live/mount/overlay/home/amnesia/.gnupg/video.mp4"
+    Given I close Totem
+    And the file "/live/overlay/home/amnesia/.gnupg/video.mp4" exists
+    And I restart monitoring the AppArmor log of "/usr/bin/totem"
+    When I try to open "/live/overlay/home/amnesia/.gnupg/video.mp4" with Totem
+    Then I see "TotemUnableToOpen.png" after at most 10 seconds
+    # Due to our AppArmor aliases, /live/overlay will be treated
+    # as /lib/live/mount/overlay.
+    And AppArmor has denied "/usr/bin/totem" from opening "/lib/live/mount/overlay/home/amnesia/.gnupg/video.mp4"
 
-  Scenario: Watching a WebM video over HTTPS, with and without the command-line
-    Given a computer
-    And I capture all network traffic
-    And I start Tails from DVD and I login
-    When I open "https://webm.html5.org/test.webm" with Totem
-    Then I see "SampleRemoteWebMVideoFrame.png" after at most 10 seconds
-    When I close Totem
-    And I start Totem through the GNOME menu
-    When I load the "https://webm.html5.org/test.webm" URL in Totem
-    Then I see "SampleRemoteWebMVideoFrame.png" after at most 10 seconds
-    And all Internet traffic has only flowed through Tor
+  #10497: wait_until_tor_is_working
+  @check_tor_leaks @fragile
+  Scenario: Watching a WebM video over HTTPS
+    Given I have started Tails from DVD and logged in and the network is connected
+    Then I can watch a WebM video over HTTPs
 
-  @keep_volumes
-  Scenario: Installing Tails on a USB drive, creating a persistent partition, copying video files to it
-    Given the USB drive "current" contains Tails with persistence configured and password "asdf"
-    And a computer
+  #10720: Tails Installer freezes on Jenkins
+  @fragile
+  Scenario: Watching MP4 videos stored on the persistent volume should work as expected given our AppArmor confinement
+    Given I have started Tails without network from a USB drive with a persistent partition and stopped at Tails Greeter's login screen
+    # Due to bug #5571 we have to reboot to be able to use
+    # filesystem shares.
+    And I shutdown Tails and wait for the computer to power off
     And I setup a filesystem share containing sample videos
-    And I start Tails from USB drive "current" with network unplugged and I login with persistence password "asdf"
+    And I start Tails from USB drive "__internal" with network unplugged and I login with persistence enabled
     And I copy the sample videos to "/home/amnesia/Persistent" as user "amnesia"
     And I copy the sample videos to "/home/amnesia/.gnupg" as user "amnesia"
     And I shutdown Tails and wait for the computer to power off
-
-  @keep_volumes
-  Scenario: Watching a MP4 video stored on the persistent volume
-    Given a computer
-    And I start Tails from USB drive "current" with network unplugged and I login with persistence password "asdf"
+    And I start Tails from USB drive "__internal" with network unplugged and I login with persistence enabled
     And the file "/home/amnesia/Persistent/video.mp4" exists
     When I open "/home/amnesia/Persistent/video.mp4" with Totem
     Then I see "SampleLocalMp4VideoFrame.png" after at most 10 seconds
     Given I close Totem
     And the file "/home/amnesia/.gnupg/video.mp4" exists
+    And I start monitoring the AppArmor log of "/usr/bin/totem"
     When I try to open "/home/amnesia/.gnupg/video.mp4" with Totem
     Then I see "TotemUnableToOpen.png" after at most 10 seconds
+    And AppArmor has denied "/usr/bin/totem" from opening "/home/amnesia/.gnupg/video.mp4"
diff --git a/features/unsafe_browser.feature b/features/unsafe_browser.feature
index 80d91af..d47f770 100644
--- a/features/unsafe_browser.feature
+++ b/features/unsafe_browser.feature
@@ -4,44 +4,70 @@ Feature: Browsing the web using the Unsafe Browser
   when I browse the web using the Unsafe Browser
   I should have direct access to the web
 
-  Background:
-    Given a computer
-    And I start the computer
-    And the computer boots Tails
-    And I log in to a new session
-    And GNOME has started
-    And Tor is ready
-    And all notifications have disappeared
-    And available upgrades have been checked
-    And I save the state so the background can be restored next scenario
+  @fragile
+  Scenario: The Unsafe Browser can access the LAN
+    Given I have started Tails from DVD and logged in and the network is connected
+    And a web server is running on the LAN
+    When I successfully start the Unsafe Browser
+    And I open a page on the LAN web server in the Unsafe Browser
+    Then I see "UnsafeBrowserHelloLANWebServer.png" after at most 20 seconds
 
+  @fragile
   Scenario: Starting the Unsafe Browser works as it should.
+    Given I have started Tails from DVD and logged in and the network is connected
     When I successfully start the Unsafe Browser
-    Then the Unsafe Browser has a red theme
+    Then the Unsafe Browser runs as the expected user
+    And the Unsafe Browser has a red theme
     And the Unsafe Browser shows a warning as its start page
+    And the Unsafe Browser has no plugins installed
+    And the Unsafe Browser has no add-ons installed
+    And the Unsafe Browser has only Firefox's default bookmarks configured
+    And the Unsafe Browser has no proxy configured
     And the Unsafe Browser uses all expected TBB shared libraries
 
-  Scenario: Closing the Unsafe Browser shows a stop notification.
+  @fragile
+  Scenario: Closing the Unsafe Browser shows a stop notification and properly tears down the chroot.
+    Given I have started Tails from DVD and logged in and the network is connected
     When I successfully start the Unsafe Browser
     And I close the Unsafe Browser
     Then I see the Unsafe Browser stop notification
+    And the Unsafe Browser chroot is torn down
 
+  @fragile
   Scenario: Starting a second instance of the Unsafe Browser results in an error message being shown.
+    Given I have started Tails from DVD and logged in and the network is connected
     When I successfully start the Unsafe Browser
     And I start the Unsafe Browser
     Then I see a warning about another instance already running
 
-  Scenario: The Unsafe Browser cannot be restarted before the previous instance has been cleaned up.
-    When I successfully start the Unsafe Browser
-    And I close the Unsafe Browser
-    And I start the Unsafe Browser
-    Then I see a warning about another instance already running
-
+  @fragile
   Scenario: Opening check.torproject.org in the Unsafe Browser shows the red onion and a warning message.
+    Given I have started Tails from DVD and logged in and the network is connected
     When I successfully start the Unsafe Browser
     And I open the address "https://check.torproject.org" in the Unsafe Browser
     Then I see "UnsafeBrowserTorCheckFail.png" after at most 60 seconds
+    And the clearnet user has sent packets out to the Internet
 
+  @fragile
   Scenario: The Unsafe Browser cannot be configured to use Tor and other local proxies.
+    Given I have started Tails from DVD and logged in and the network is connected
     When I successfully start the Unsafe Browser
     Then I cannot configure the Unsafe Browser to use any local proxies
+
+  @fragile
+  Scenario: The Unsafe Browser will not make any connections to the Internet which are not user initiated
+    Given I have started Tails from DVD and logged in and the network is connected
+    And I capture all network traffic
+    And Tor is ready
+    And I configure the Unsafe Browser to check for updates more frequently
+    But checking for updates is disabled in the Unsafe Browser's configuration
+    When I successfully start the Unsafe Browser
+    Then the Unsafe Browser has started
+    And I wait 120 seconds
+    And the clearnet user has not sent packets out to the Internet
+    And all Internet traffic has only flowed through Tor
+
+  Scenario: Starting the Unsafe Browser without a network connection results in a complaint about no DNS server being configured
+    Given I have started Tails from DVD without network and logged in
+    When I start the Unsafe Browser
+    Then the Unsafe Browser complains that no DNS server is configured
diff --git a/features/untrusted_partitions.feature b/features/untrusted_partitions.feature
index 816fbe7..5549013 100644
--- a/features/untrusted_partitions.feature
+++ b/features/untrusted_partitions.feature
@@ -3,18 +3,59 @@ Feature: Untrusted partitions
   As a Tails user
   I don't want to touch other media than the one Tails runs from
 
-  @keep_volumes
+  Scenario: Tails will not enable disk swap
+    Given a computer
+    And I temporarily create a 100 MiB disk named "swap"
+    And I create a gpt swap partition on disk "swap"
+    And I plug ide drive "swap"
+    When I start Tails with network unplugged and I login
+    Then a "swap" partition was detected by Tails on drive "swap"
+    But Tails has no disk swap enabled
+
+  Scenario: Tails will detect LUKS-encrypted GPT partitions labeled "TailsData" stored on USB drives as persistence volumes when the removable flag is set
+    Given a computer
+    And I temporarily create a 100 MiB disk named "fake_TailsData"
+    And I create a gpt partition labeled "TailsData" with an ext4 filesystem encrypted with password "asdf" on disk "fake_TailsData"
+    And I plug removable usb drive "fake_TailsData"
+    When I start the computer
+    And the computer boots Tails
+    Then drive "fake_TailsData" is detected by Tails
+    And Tails Greeter has detected a persistence partition
+
+  Scenario: Tails will not detect LUKS-encrypted GPT partitions labeled "TailsData" stored on USB drives as persistence volumes when the removable flag is unset
+    Given a computer
+    And I temporarily create a 100 MiB disk named "fake_TailsData"
+    And I create a gpt partition labeled "TailsData" with an ext4 filesystem encrypted with password "asdf" on disk "fake_TailsData"
+    And I plug non-removable usb drive "fake_TailsData"
+    When I start the computer
+    And the computer boots Tails
+    Then drive "fake_TailsData" is detected by Tails
+    And Tails Greeter has not detected a persistence partition
+
+  Scenario: Tails will not detect LUKS-encrypted GPT partitions labeled "TailsData" stored on local hard drives as persistence volumes
+    Given a computer
+    And I temporarily create a 100 MiB disk named "fake_TailsData"
+    And I create a gpt partition labeled "TailsData" with an ext4 filesystem encrypted with password "asdf" on disk "fake_TailsData"
+    And I plug ide drive "fake_TailsData"
+    When I start the computer
+    And the computer boots Tails
+    Then drive "fake_TailsData" is detected by Tails
+    And Tails Greeter has not detected a persistence partition
+
   Scenario: Tails can boot from live systems stored on hard drives
     Given a computer
-    And I create a 2 GiB disk named "live_hd"
-    And I cat an ISO hybrid of the Tails image to disk "live_hd"
+    And I temporarily create a 2 GiB disk named "live_hd"
+    And I cat an ISO of the Tails image to disk "live_hd"
     And the computer is set to boot from ide drive "live_hd"
     And I set Tails to boot with options "live-media="
-    And I start Tails from DVD with network unplugged and I login
-    Then Tails seems to have booted normally
+    When I start Tails with network unplugged and I login
+    Then Tails is running from ide drive "live_hd"
+    And Tails seems to have booted normally
 
   Scenario: Tails booting from a DVD does not use live systems stored on hard drives
     Given a computer
+    And I temporarily create a 2 GiB disk named "live_hd"
+    And I cat an ISO of the Tails image to disk "live_hd"
     And I plug ide drive "live_hd"
     And I start Tails from DVD with network unplugged and I login
     Then drive "live_hd" is detected by Tails
@@ -22,9 +63,8 @@ Feature: Untrusted partitions
 
   Scenario: Booting Tails does not automount untrusted ext2 partitions
     Given a computer
-    And I create a 100 MiB disk named "gpt_ext2"
-    And I create a gpt label on disk "gpt_ext2"
-    And I create a ext2 filesystem on disk "gpt_ext2"
+    And I temporarily create a 100 MiB disk named "gpt_ext2"
+    And I create a gpt partition with an ext2 filesystem on disk "gpt_ext2"
     And I plug ide drive "gpt_ext2"
     And I start Tails from DVD with network unplugged and I login
     Then drive "gpt_ext2" is detected by Tails
@@ -32,9 +72,8 @@ Feature: Untrusted partitions
 
   Scenario: Booting Tails does not automount untrusted fat32 partitions
     Given a computer
-    And I create a 100 MiB disk named "msdos_fat32"
-    And I create a msdos label on disk "msdos_fat32"
-    And I create a fat32 filesystem on disk "msdos_fat32"
+    And I temporarily create a 100 MiB disk named "msdos_fat32"
+    And I create an msdos partition with a vfat filesystem on disk "msdos_fat32"
     And I plug ide drive "msdos_fat32"
     And I start Tails from DVD with network unplugged and I login
     Then drive "msdos_fat32" is detected by Tails
diff --git a/features/usb_install.feature b/features/usb_install.feature
index b40ca93..750df7a 100644
--- a/features/usb_install.feature
+++ b/features/usb_install.feature
@@ -1,273 +1,106 @@
- at product @old_iso
-Feature: Installing Tails to a USB drive, upgrading it, and using persistence
+ at product
+Feature: Installing Tails to a USB drive
   As a Tails user
-  I may want to install Tails to a USB drive
-  and upgrade it to new Tails versions
-  and use persistence
-
-  @keep_volumes
+  I want to install Tails to a suitable USB drive
+
+  Scenario: Try installing Tails to a too small USB drive
+    Given I have started Tails from DVD without network and logged in
+    And I temporarily create a 2 GiB disk named "too-small-device"
+    And I start Tails Installer in "Clone & Install" mode
+    But a suitable USB device is not found
+    When I plug USB drive "too-small-device"
+    Then Tails Installer detects that a device is too small
+    And a suitable USB device is not found
+    When I unplug USB drive "too-small-device"
+    And I create a 4 GiB disk named "big-enough"
+    And I plug USB drive "big-enough"
+    Then the "big-enough" USB drive is selected
+
+  Scenario: Detecting when a target USB drive is inserted or removed
+    Given I have started Tails from DVD without network and logged in
+    And I temporarily create a 4 GiB disk named "temp"
+    And I start Tails Installer in "Clone & Install" mode
+    But a suitable USB device is not found
+    When I plug USB drive "temp"
+    Then the "temp" USB drive is selected
+    When I unplug USB drive "temp"
+    Then no USB drive is selected
+    And a suitable USB device is not found
+
+  #10720: Tails Installer freezes on Jenkins
+  @fragile
   Scenario: Installing Tails to a pristine USB drive
-    Given a computer
-    And I start Tails from DVD with network unplugged and I login
-    And I create a new 4 GiB USB drive named "current"
-    And I plug USB drive "current"
-    And I "Clone & Install" Tails to USB drive "current"
-    Then the running Tails is installed on USB drive "current"
-    But there is no persistence partition on USB drive "current"
-    And I unplug USB drive "current"
-
-  @keep_volumes
+    Given I have started Tails from DVD without network and logged in
+    And I temporarily create a 4 GiB disk named "install"
+    And I plug USB drive "install"
+    And I "Clone & Install" Tails to USB drive "install"
+    Then the running Tails is installed on USB drive "install"
+    But there is no persistence partition on USB drive "install"
+
+  #10720: Tails Installer freezes on Jenkins
+  @fragile
+  Scenario: Booting Tails from a USB drive without a persistent partition and creating one
+    Given I have started Tails without network from a USB drive without a persistent partition and stopped at Tails Greeter's login screen
+    And I log in to a new session
+    Then Tails seems to have booted normally
+    When I create a persistent partition
+    Then a Tails persistence partition exists on USB drive "__internal"
+
+  #10720: Tails Installer freezes on Jenkins
+  @fragile
+  Scenario: Booting Tails from a USB drive without a persistent partition
+    Given I have started Tails without network from a USB drive without a persistent partition and stopped at Tails Greeter's login screen
+    When I log in to a new session
+    Then Tails seems to have booted normally
+    And Tails is running from USB drive "__internal"
+    And the persistent Tor Browser directory does not exist
+    And there is no persistence partition on USB drive "__internal"
+
+  #10720: Tails Installer freezes on Jenkins
+  @fragile
   Scenario: Booting Tails from a USB drive in UEFI mode
-    Given a computer
-    And the computer is set to boot in UEFI mode
-    When I start Tails from USB drive "current" with network unplugged and I login
+    Given I have started Tails without network from a USB drive without a persistent partition and stopped at Tails Greeter's login screen
+    Then I power off the computer
+    Given the computer is set to boot in UEFI mode
+    When I start Tails from USB drive "__internal" with network unplugged and I login
     Then the boot device has safe access rights
-    And Tails is running from USB drive "current"
+    And Tails is running from USB drive "__internal"
     And the boot device has safe access rights
     And Tails has started in UEFI mode
 
-  @keep_volumes
-  Scenario: Booting Tails from a USB drive without a persistent partition and creating one
-    Given a computer
-    And I start Tails from USB drive "current" with network unplugged and I login
-    Then the boot device has safe access rights
-    And Tails is running from USB drive "current"
-    And the boot device has safe access rights
-    And there is no persistence partition on USB drive "current"
-    And I create a persistent partition with password "asdf"
-    Then a Tails persistence partition with password "asdf" exists on USB drive "current"
-    And I shutdown Tails and wait for the computer to power off
-
-  @keep_volumes
-  Scenario: Booting Tails from a USB drive with a disabled persistent partition
-    Given a computer
-    And I start Tails from USB drive "current" with network unplugged and I login
-    Then Tails is running from USB drive "current"
-    And the boot device has safe access rights
-    And persistence is disabled
-    But a Tails persistence partition with password "asdf" exists on USB drive "current"
-
-  @keep_volumes
-  Scenario: Persistent browser bookmarks
-    Given a computer
-    And the computer is set to boot from USB drive "current"
-    And the network is unplugged
-    When I start the computer
-    And the computer boots Tails
-    And Tails is running from USB drive "current"
-    And the boot device has safe access rights
-    And I enable persistence with password "asdf"
-    And I log in to a new session
-    And GNOME has started
-    And all notifications have disappeared
-    And persistence is enabled
-    And persistent filesystems have safe access rights
-    And persistence configuration files have safe access rights
-    And persistent directories have safe access rights
-    And I start the Tor Browser in offline mode
-    And the Tor Browser has started in offline mode
-    And I add a bookmark to eff.org in the Tor Browser
-    And I warm reboot the computer
-    And the computer reboots Tails
-    And I enable read-only persistence with password "asdf"
-    And I log in to a new session
-    And GNOME has started
-    And I start the Tor Browser in offline mode
-    And the Tor Browser has started in offline mode
-    Then the Tor Browser has a bookmark to eff.org
-
-  @keep_volumes
-  Scenario: Writing files to a read/write-enabled persistent partition
-    Given a computer
-    And I start Tails from USB drive "current" with network unplugged and I login with persistence password "asdf"
-    Then Tails is running from USB drive "current"
-    And the boot device has safe access rights
-    And persistence is enabled
-    And I write some files expected to persist
-    And persistent filesystems have safe access rights
-    And persistence configuration files have safe access rights
-    And persistent directories have safe access rights
-    And I shutdown Tails and wait for the computer to power off
-    Then only the expected files should persist on USB drive "current"
-
-  @keep_volumes
-  Scenario: Writing files to a read-only-enabled persistent partition
-    Given a computer
-    And I start Tails from USB drive "current" with network unplugged and I login with read-only persistence password "asdf"
-    Then Tails is running from USB drive "current"
-    And the boot device has safe access rights
-    And persistence is enabled
-    And I write some files not expected to persist
-    And I remove some files expected to persist
-    And I shutdown Tails and wait for the computer to power off
-    Then only the expected files should persist on USB drive "current"
-
-  @keep_volumes
-  Scenario: Deleting a Tails persistent partition
-    Given a computer
-    And I start Tails from USB drive "current" with network unplugged and I login
-    Then Tails is running from USB drive "current"
-    And the boot device has safe access rights
-    And persistence is disabled
-    But a Tails persistence partition with password "asdf" exists on USB drive "current"
-    And all notifications have disappeared
-    When I delete the persistent partition
-    Then there is no persistence partition on USB drive "current"
-
-  @keep_volumes
-  Scenario: Installing an old version of Tails to a pristine USB drive
-    Given a computer
-    And the computer is set to boot from the old Tails DVD
-    And the network is unplugged
-    And I start the computer
-    When the computer boots Tails
-    And I log in to a new session
-    And GNOME has started
-    And all notifications have disappeared
-    And I create a new 4 GiB USB drive named "old"
-    And I plug USB drive "old"
-    And I "Clone & Install" Tails to USB drive "old"
-    Then the running Tails is installed on USB drive "old"
-    But there is no persistence partition on USB drive "old"
-    And I unplug USB drive "old"
-
-  @keep_volumes
-  Scenario: Creating a persistent partition with the old Tails USB installation
-    Given a computer
-    And I start Tails from USB drive "old" with network unplugged and I login
-    Then Tails is running from USB drive "old"
-    And I create a persistent partition with password "asdf"
-    Then a Tails persistence partition with password "asdf" exists on USB drive "old"
-    And I shutdown Tails and wait for the computer to power off
-
-  @keep_volumes
-  Scenario: Writing files to a read/write-enabled persistent partition with the old Tails USB installation
-    Given a computer
-    And I start Tails from USB drive "old" with network unplugged and I login with persistence password "asdf"
-    Then Tails is running from USB drive "old"
-    And persistence is enabled
-    And I write some files expected to persist
-    And persistent filesystems have safe access rights
-    And persistence configuration files have safe access rights
-    And persistent directories have safe access rights
-    And I shutdown Tails and wait for the computer to power off
-    Then only the expected files should persist on USB drive "old"
-
-  @keep_volumes
-  Scenario: Upgrading an old Tails USB installation from a Tails DVD
-    Given a computer
-    And I clone USB drive "old" to a new USB drive "to_upgrade"
-    And I start Tails from DVD with network unplugged and I login
-    And I plug USB drive "to_upgrade"
-    And I "Clone & Upgrade" Tails to USB drive "to_upgrade"
-    Then the running Tails is installed on USB drive "to_upgrade"
-    And I unplug USB drive "to_upgrade"
-
-  @keep_volumes
-  Scenario: Booting Tails from a USB drive upgraded from DVD with persistence enabled
-    Given a computer
-    And I start Tails from USB drive "to_upgrade" with network unplugged and I login with persistence password "asdf"
-    Then Tails is running from USB drive "to_upgrade"
-    And the boot device has safe access rights
-    And the expected persistent files are present in the filesystem
-    And persistent directories have safe access rights
-
-  @keep_volumes
-  Scenario: Upgrading an old Tails USB installation from another Tails USB drive
-    Given a computer
-    And I clone USB drive "old" to a new USB drive "to_upgrade"
-    And I start Tails from USB drive "current" with network unplugged and I login
-    Then Tails is running from USB drive "current"
-    And the boot device has safe access rights
-    And I plug USB drive "to_upgrade"
-    And I "Clone & Upgrade" Tails to USB drive "to_upgrade"
-    Then the running Tails is installed on USB drive "to_upgrade"
-    And I unplug USB drive "to_upgrade"
-    And I unplug USB drive "current"
-
-  @keep_volumes
-  Scenario: Booting Tails from a USB drive upgraded from USB with persistence enabled
-    Given a computer
-    And I start Tails from USB drive "to_upgrade" with network unplugged and I login with persistence password "asdf"
-    Then persistence is enabled
-    And Tails is running from USB drive "to_upgrade"
-    And the boot device has safe access rights
-    And the expected persistent files are present in the filesystem
-    And persistent directories have safe access rights
-
-  @keep_volumes
-  Scenario: Upgrading an old Tails USB installation from an ISO image, running on the old version
-    Given a computer
-    And I clone USB drive "old" to a new USB drive "to_upgrade"
-    And I setup a filesystem share containing the Tails ISO
-    When I start Tails from USB drive "old" with network unplugged and I login
-    And I plug USB drive "to_upgrade"
-    And I do a "Upgrade from ISO" on USB drive "to_upgrade"
-    Then the ISO's Tails is installed on USB drive "to_upgrade"
-    And I unplug USB drive "to_upgrade"
-
-  @keep_volumes
-  Scenario: Upgrading an old Tails USB installation from an ISO image, running on the new version
-    Given a computer
-    And I clone USB drive "old" to a new USB drive "to_upgrade"
-    And I setup a filesystem share containing the Tails ISO
-    And I start Tails from DVD with network unplugged and I login
-    And I plug USB drive "to_upgrade"
-    And I do a "Upgrade from ISO" on USB drive "to_upgrade"
-    Then the ISO's Tails is installed on USB drive "to_upgrade"
-    And I unplug USB drive "to_upgrade"
-
-  Scenario: Booting a USB drive upgraded from ISO with persistence enabled
-    Given a computer
-    And I start Tails from USB drive "to_upgrade" with network unplugged and I login with persistence password "asdf"
-    Then persistence is enabled
-    And Tails is running from USB drive "to_upgrade"
-    And the boot device has safe access rights
-    And the expected persistent files are present in the filesystem
-    And persistent directories have safe access rights
-
-  @keep_volumes
-  Scenario: Installing Tails to a USB drive with an MBR partition table but no partitions
-    Given a computer
-    And I create a 4 GiB disk named "mbr"
+  #10720: Tails Installer freezes on Jenkins
+  @fragile
+  Scenario: Installing Tails to a USB drive with an MBR partition table but no partitions, and making sure that it boots
+    Given I have started Tails from DVD without network and logged in
+    And I temporarily create a 4 GiB disk named "mbr"
     And I create a msdos label on disk "mbr"
-    And I start Tails from DVD with network unplugged and I login
     And I plug USB drive "mbr"
     And I "Clone & Install" Tails to USB drive "mbr"
     Then the running Tails is installed on USB drive "mbr"
     But there is no persistence partition on USB drive "mbr"
-    And I unplug USB drive "mbr"
-
-  Scenario: Booting a USB drive that originally had an empty MBR partition table
-    Given a computer
+    When I shutdown Tails and wait for the computer to power off
     And I start Tails from USB drive "mbr" with network unplugged and I login
     Then Tails is running from USB drive "mbr"
     And the boot device has safe access rights
     And there is no persistence partition on USB drive "mbr"
 
-  @keep_volumes
-  Scenario: Cat:ing a Tails isohybrid to a USB drive and booting it
+  #10720: Tails Installer freezes on Jenkins
+  @fragile
+  Scenario: Cat:ing a Tails isohybrid to a USB drive and booting it, then trying to upgrading it but ending up having to do a fresh installation, which boots
     Given a computer
-    And I create a 4 GiB disk named "isohybrid"
-    And I cat an ISO hybrid of the Tails image to disk "isohybrid"
+    And I temporarily create a 4 GiB disk named "isohybrid"
+    And I cat an ISO of the Tails image to disk "isohybrid"
     And I start Tails from USB drive "isohybrid" with network unplugged and I login
     Then Tails is running from USB drive "isohybrid"
-
-  @keep_volumes
-  Scenario: Try upgrading but end up installing Tails to a USB drive containing a Tails isohybrid installation
-    Given a computer
+    When I shutdown Tails and wait for the computer to power off
     And I start Tails from DVD with network unplugged and I login
-    And I plug USB drive "isohybrid"
     And I try a "Clone & Upgrade" Tails to USB drive "isohybrid"
-    But I am suggested to do a "Clone & Install"
-    And I kill the process "liveusb-creator"
+    Then I am suggested to do a "Clone & Install"
+    When I kill the process "tails-installer"
     And I "Clone & Install" Tails to USB drive "isohybrid"
     Then the running Tails is installed on USB drive "isohybrid"
     But there is no persistence partition on USB drive "isohybrid"
-    And I unplug USB drive "isohybrid"
-
-  Scenario: Booting a USB drive that originally had a isohybrid installation
-    Given a computer
+    When I shutdown Tails and wait for the computer to power off
     And I start Tails from USB drive "isohybrid" with network unplugged and I login
     Then Tails is running from USB drive "isohybrid"
     And the boot device has safe access rights
diff --git a/features/usb_upgrade.feature b/features/usb_upgrade.feature
new file mode 100644
index 0000000..7462489
--- /dev/null
+++ b/features/usb_upgrade.feature
@@ -0,0 +1,164 @@
+#10720: Tails Installer freezes on Jenkins
+ at product @fragile
+Feature: Upgrading an old Tails USB installation
+  As a Tails user
+  If I have an old versoin of Tails installed on a USB device
+  and the USB device has a persistent partition
+  I want to upgrade Tails on it
+  and keep my persistent partition in the process
+
+  # An issue with this feature is that scenarios depend on each
+  # other. When editing this feature, make sure you understand these
+  # dependencies (which are documented below).
+
+  Scenario: Try to "Upgrade from ISO" Tails to a pristine USB drive
+    Given a computer
+    And I setup a filesystem share containing the Tails ISO
+    And I start Tails from DVD with network unplugged and I login
+    And I temporarily create a 4 GiB disk named "pristine"
+    And I plug USB drive "pristine"
+    And I start Tails Installer in "Upgrade from ISO" mode
+    Then a suitable USB device is not found
+    And I am told that the destination device cannot be upgraded
+
+  Scenario: Try to "Clone & Upgrade" Tails to a pristine USB drive
+    Given I have started Tails from DVD without network and logged in
+    And I temporarily create a 4 GiB disk named "pristine"
+    And I plug USB drive "pristine"
+    And I start Tails Installer in "Clone & Upgrade" mode
+    Then a suitable USB device is not found
+    And I am told that the destination device cannot be upgraded
+
+  Scenario: Try to "Upgrade from ISO" Tails to a USB drive with GPT and a FAT partition
+    Given a computer
+    And I setup a filesystem share containing the Tails ISO
+    And I start Tails from DVD with network unplugged and I login
+    And I temporarily create a 4 GiB disk named "gptfat"
+    And I create a gpt partition with a vfat filesystem on disk "gptfat"
+    And I plug USB drive "gptfat"
+    And I start Tails Installer in "Upgrade from ISO" mode
+    Then a suitable USB device is not found
+    And I am told that the destination device cannot be upgraded
+
+  Scenario: Try to "Clone & Upgrade" Tails to a USB drive with GPT and a FAT partition
+    Given I have started Tails from DVD without network and logged in
+    And I temporarily create a 4 GiB disk named "gptfat"
+    And I create a gpt partition with a vfat filesystem on disk "gptfat"
+    And I plug USB drive "gptfat"
+    And I start Tails Installer in "Upgrade from ISO" mode
+    Then a suitable USB device is not found
+    And I am told that the destination device cannot be upgraded
+
+  Scenario: Installing an old version of Tails to a pristine USB drive
+    Given a computer
+    And the computer is set to boot from the old Tails DVD
+    And the network is unplugged
+    And I start the computer
+    When the computer boots Tails
+    And I log in to a new session
+    And the Tails desktop is ready
+    And all notifications have disappeared
+    And I create a 4 GiB disk named "old"
+    And I plug USB drive "old"
+    And I "Clone & Install" Tails to USB drive "old"
+    Then the running Tails is installed on USB drive "old"
+    But there is no persistence partition on USB drive "old"
+    And I unplug USB drive "old"
+
+  # Depends on scenario: Installing an old version of Tails to a pristine USB drive
+  Scenario: Creating a persistent partition with the old Tails USB installation
+    Given a computer
+    And I start Tails from USB drive "old" with network unplugged and I login
+    Then Tails is running from USB drive "old"
+    And I create a persistent partition
+    And I take note of which persistence presets are available
+    Then a Tails persistence partition exists on USB drive "old"
+    And I shutdown Tails and wait for the computer to power off
+
+  # Depends on scenario: Creating a persistent partition with the old Tails USB installation
+  Scenario: Writing files to a read/write-enabled persistent partition with the old Tails USB installation
+    Given a computer
+    And I start Tails from USB drive "old" with network unplugged and I login with persistence enabled
+    Then Tails is running from USB drive "old"
+    And all persistence presets are enabled
+    And I write some files expected to persist
+    And all persistent filesystems have safe access rights
+    And all persistence configuration files have safe access rights
+    And all persistent directories from the old Tails version have safe access rights
+    And I take note of which persistence presets are available
+    And I shutdown Tails and wait for the computer to power off
+    # XXX: how does guestfs work vs snapshots?
+    Then only the expected files are present on the persistence partition on USB drive "old"
+
+  # Depends on scenario: Writing files to a read/write-enabled persistent partition with the old Tails USB installation
+  Scenario: Upgrading an old Tails USB installation from a Tails DVD
+    Given I have started Tails from DVD without network and logged in
+    And I clone USB drive "old" to a new USB drive "to_upgrade"
+    And I plug USB drive "to_upgrade"
+    When I "Clone & Upgrade" Tails to USB drive "to_upgrade"
+    Then the running Tails is installed on USB drive "to_upgrade"
+    And I unplug USB drive "to_upgrade"
+
+  # Depends on scenario: Upgrading an old Tails USB installation from a Tails DVD
+  Scenario: Booting Tails from a USB drive upgraded from DVD with persistence enabled
+    Given a computer
+    And I start Tails from USB drive "to_upgrade" with network unplugged and I login with persistence enabled
+    Then all persistence presets from the old Tails version are enabled
+    And Tails is running from USB drive "to_upgrade"
+    And the boot device has safe access rights
+    And the expected persistent files created with the old Tails version are present in the filesystem
+    And all persistent directories from the old Tails version have safe access rights
+
+  # Depends on scenario: Writing files to a read/write-enabled persistent partition with the old Tails USB installation
+  Scenario: Upgrading an old Tails USB installation from another Tails USB drive
+    Given I have started Tails without network from a USB drive without a persistent partition and stopped at Tails Greeter's login screen
+    And I log in to a new session
+    And Tails seems to have booted normally
+    And I clone USB drive "old" to a new USB drive "to_upgrade"
+    And I plug USB drive "to_upgrade"
+    When I "Clone & Upgrade" Tails to USB drive "to_upgrade"
+    Then the running Tails is installed on USB drive "to_upgrade"
+    And I unplug USB drive "to_upgrade"
+    And I unplug USB drive "__internal"
+
+  # Depends on scenario: Upgrading an old Tails USB installation from another Tails USB drive
+  Scenario: Booting Tails from a USB drive upgraded from USB with persistence enabled
+    Given a computer
+    And I start Tails from USB drive "to_upgrade" with network unplugged and I login with persistence enabled
+    Then all persistence presets from the old Tails version are enabled
+    And Tails is running from USB drive "to_upgrade"
+    And the boot device has safe access rights
+    And the expected persistent files created with the old Tails version are present in the filesystem
+    And all persistent directories from the old Tails version have safe access rights
+
+  # Depends on scenario: Writing files to a read/write-enabled persistent partition with the old Tails USB installation
+  Scenario: Upgrading an old Tails USB installation from an ISO image, running on the old version
+    Given a computer
+    And I clone USB drive "old" to a new USB drive "to_upgrade"
+    And I setup a filesystem share containing the Tails ISO
+    When I start Tails from USB drive "old" with network unplugged and I login
+    And I plug USB drive "to_upgrade"
+    And I do a "Upgrade from ISO" on USB drive "to_upgrade"
+    Then the ISO's Tails is installed on USB drive "to_upgrade"
+    And I unplug USB drive "to_upgrade"
+
+  # Depends on scenario: Writing files to a read/write-enabled persistent partition with the old Tails USB installation
+  Scenario: Upgrading an old Tails USB installation from an ISO image, running on the new version
+    Given a computer
+    And I clone USB drive "old" to a new USB drive "to_upgrade"
+    And I setup a filesystem share containing the Tails ISO
+    And I start Tails from DVD with network unplugged and I login
+    And I plug USB drive "to_upgrade"
+    And I do a "Upgrade from ISO" on USB drive "to_upgrade"
+    Then the ISO's Tails is installed on USB drive "to_upgrade"
+    And I unplug USB drive "to_upgrade"
+
+  # Depends on scenario: Upgrading an old Tails USB installation from an ISO image, running on the new version
+  Scenario: Booting a USB drive upgraded from ISO with persistence enabled
+    Given a computer
+    And I start Tails from USB drive "to_upgrade" with network unplugged and I login with persistence enabled
+    Then all persistence presets from the old Tails version are enabled
+    And Tails is running from USB drive "to_upgrade"
+    And the boot device has safe access rights
+    And the expected persistent files created with the old Tails version are present in the filesystem
+    And all persistent directories from the old Tails version have safe access rights
diff --git a/job-cfg/lvc.yaml b/job-cfg/lvc.yaml
index 8847017..b5b16f6 100644
--- a/job-cfg/lvc.yaml
+++ b/job-cfg/lvc.yaml
@@ -54,7 +54,7 @@
     my_time: '23 45 31 12 *'
     my_pngs: 'apt-*.png'
     my_description: 'Work in progress...'
-    my_params: '--debug --capture lvc_debian-live_{distro}_{flavour}_apt.webm --temp-dir $WORKSPACE --iso /srv/live-build/results/{distro}_{flavour}_live_amd64.iso DebianLive/apt.feature'
+    my_params: '--capture lvc_debian-live_{distro}_{flavour}_apt.webm --tmpdir $WORKSPACE --iso /srv/live-build/results/{distro}_{flavour}_live_amd64.iso DebianLive/apt.feature'
     jobs:
         - '{name}_debian-live_{distro}_{flavour}_apt':
             distro:
@@ -85,4 +85,4 @@
             flavour:
                 - 'gnome-desktop': {flav_name: 'GNOME desktop'}
                 - 'standard':      {flav_name: 'standard'}
-            my_params: '--debug --capture lvc_debian-live_{distro}_{flavour}_apt.webm --temp-dir $WORKSPACE --iso /var/lib/jenkins/debian-live-7.7.0-amd64-{flavour}.iso DebianLive/apt.feature'
+            my_params: '--capture lvc_debian-live_{distro}_{flavour}_apt.webm --tmpdir $WORKSPACE --iso /var/lib/jenkins/debian-live-7.7.0-amd64-{flavour}.iso DebianLive/apt.feature'
diff --git a/update_jdn.sh b/update_jdn.sh
index c1bd957..130524b 100755
--- a/update_jdn.sh
+++ b/update_jdn.sh
@@ -23,6 +23,7 @@ conditional_disable() {
 		case "$1" in
 			piuparts.yaml) return 1;;
 			schroot.yaml) return 1;;
+			lvc.yaml) return 1;;
 			d-i.yaml) return 1;;
 			*) return 0;;
 		esac
@@ -195,6 +196,7 @@ if [ -f /etc/debian_version ] ; then
 			"
 		case $HOSTNAME in
 			jenkins|jenkins-test-vm|profitbricks-build?-amd64) DEBS="$DEBS
+				libvirt-bin
 				python3-yaml
 				postfix-pcre
 				squid3"
@@ -426,7 +428,7 @@ explain "packages configured."
 # install the heart of jenkins.debian.net
 #
 cd $BASEDIR
-for dir in bin logparse job-cfg features live ; do
+for dir in bin bin/lvc logparse job-cfg features live ; do
 	sudo cp --preserve=mode,timestamps -r $dir /srv/jenkins/
 	sudo chown -R jenkins-adm.jenkins-adm /srv/jenkins/$dir
 done

-- 
Alioth's /usr/local/bin/git-commit-notice on /srv/git.debian.org/git/qa/jenkins.debian.net.git



More information about the Qa-jenkins-scm mailing list