[installer] Add a test for the installer.

Boot the installer from a USB in QEMU, and install to an empty disk.
Then try booting from that disk (albeit with the ZBI pre-loaded by
QEMU).

This CL also adds support to the installer for running an "automated"
install, since we don't have an easy way to send input to QEMU at the
moment.

Bug: 92116
Cq-Include-Trybots: luci.fuchsia.try:core.x64-asan-slow
Change-Id: I6d6e62afb9f46a90f35dafdb225599579a988511
Reviewed-on: https://fuchsia-review.googlesource.com/c/fuchsia/+/660546
Reviewed-by: Abdulla Kamar <abdulla@google.com>
Reviewed-by: Sarah Pham <smpham@google.com>
Fuchsia-Auto-Submit: Simon Shields <simonshields@google.com>
Reviewed-by: Alex Legg <alexlegg@google.com>
Commit-Queue: Auto-Submit <auto-submit@fuchsia-infra.iam.gserviceaccount.com>
diff --git a/build/installer_images/BUILD.gn b/build/installer_images/BUILD.gn
index c0f3a97..bc0b2ee 100644
--- a/build/installer_images/BUILD.gn
+++ b/build/installer_images/BUILD.gn
@@ -1,84 +1,107 @@
 # Copyright 2020 The Fuchsia Authors. All rights reserved.
 # Use of this source code is governed by a BSD-style license that can be
 # found in the LICENSE file.
+import("//build/images/args.gni")
+import("//build/images/custom_signing.gni")
 
-cgpt_path = "$root_build_dir/host-tools/cgpt"
-fvm_path = "$root_build_dir/obj/build/images/fuchsia/fuchsia/fvm.sparse.blk"
-zbi_path = "$root_build_dir/fuchsia.zbi"
-zbi_signed_path = "$root_build_dir/fuchsia.zbi.signed"
-zedboot_path = "$root_build_dir/zedboot.zbi"
-zedboot_signed_path = "$root_build_dir/zedboot.zbi.signed"
+if (bootfs_only) {
+  # bootfs_only builds don't need installer images.
+  group("installer_images") {
+  }
+} else {
+  cgpt_path = "$root_build_dir/host-tools/cgpt"
+  fvm_path = "$root_build_dir/obj/build/images/fuchsia/fuchsia/fvm.sparse.blk"
+  zbi_path = "$root_build_dir/fuchsia.zbi"
+  zedboot_path = "$root_build_dir/zedboot.zbi"
+  recovery_installer_path = "$root_build_dir/obj/build/images/recovery/recovery-installer/recovery-installer.zbi"
 
-input_partition_labels = [
-  "//build/images/fuchsia:fuchsia.copy_zbi",
-  "//build/images/fuchsia:fuchsia.copy_zbi_signed",
-  "//build/images/fuchsia:fuchsia.image_assembler",
-  "//build/images/zedboot:zedboot.copy_zbi",
-  "//build/images/zedboot:zedboot.copy_zbi_signed",
-]
-
-input_partition_paths = [
-  cgpt_path,
-  fvm_path,
-  zbi_path,
-  zbi_signed_path,
-  zedboot_path,
-  zedboot_signed_path,
-]
-
-# To avoid a circular dependency, collect image-related metadata into a JSON file with the same
-# format as the one produced by //:images (which $target_name cannot depend on), but only for the
-# input partitions.
-# TODO(fxbug.dev/91796): Have //build/images:default-images depend on installer_image and
-# system_image to prevent this.
-input_images_json = "installer_images_json"
-input_images_json_file =
-    get_label_info(":$input_images_json", "target_gen_dir") + "/images.json"
-generated_file(input_images_json) {
-  testonly = true
-  data_keys = [ "images" ]
-  deps = input_partition_labels
-  outputs = [ input_images_json_file ]
-  output_conversion = "json"
-}
-
-action("installer_images") {
-  testonly = true
-  script = "//scripts/mkinstaller/mkinstaller.py"
-  outputs = [ "$target_out_dir/$target_name.img" ]
-  dest_image = rebase_path("$target_out_dir/$target_name.img", root_build_dir)
-  args = [
-    "--create",
-    "--force",
-    "--cgpt-path",
-    "host-tools/cgpt",
-    "--images",
-    rebase_path(input_images_json_file, root_build_dir),
-    "--build-dir",
-    ".",
-    dest_image,
+  input_partition_labels = [
+    "//build/images/fuchsia:fuchsia.copy_zbi",
+    "//build/images/fuchsia:fuchsia.image_assembler",
+    "//build/images/zedboot:zedboot.copy_zbi",
+    "//build/images/recovery:recovery-installer.image_assembler",
   ]
 
-  # this pulls in all the image dependencies we need.
-  deps = [
-    ":$input_images_json",
-    "//build/images:paver-script",
-    "//tools/vboot_reference:cgpt_host",
+  input_partition_paths = [
+    cgpt_path,
+    fvm_path,
+    zbi_path,
+    zedboot_path,
+    recovery_installer_path,
   ]
 
-  deps += input_partition_labels
-
-  inputs = input_partition_paths + [ input_images_json_file ]
-
-  metadata = {
-    images = [
-      {
-        label = get_label_info(":$target_name", "label_with_toolchain")
-        name = "installer"
-        path = dest_image
-        type = "installer"
-      },
+  if (custom_signing_script != "" || use_vboot) {
+    zbi_signed_path = "$root_build_dir/fuchsia.zbi.signed"
+    zedboot_signed_path = "$root_build_dir/zedboot.zbi.signed"
+    recovery_installer_signed_path = "$root_build_dir/obj/build/images/recovery/recovery-installer/recovery-installer.zbi.signed"
+    input_partition_labels += [
+      "//build/images/fuchsia:fuchsia.copy_zbi_signed",
+      "//build/images/zedboot:zedboot.copy_zbi_signed",
     ]
-    image_paths = [ "INSTALLER_IMAGE=$target_name.img" ]
+
+    input_partition_paths += [
+      zbi_signed_path,
+      zedboot_signed_path,
+      recovery_installer_signed_path,
+    ]
+  }
+
+  # To avoid a circular dependency, collect image-related metadata into a JSON file with the same
+  # format as the one produced by //:images (which $target_name cannot depend on), but only for the
+  # input partitions.
+  # TODO(fxbug.dev/91796): Have //build/images:default-images depend on installer_image and
+  # system_image to prevent this.
+  input_images_json = "installer_images_json"
+  input_images_json_file =
+      get_label_info(":$input_images_json", "target_gen_dir") + "/images.json"
+  generated_file(input_images_json) {
+    testonly = true
+    data_keys = [ "images" ]
+    deps = input_partition_labels
+    outputs = [ input_images_json_file ]
+    output_conversion = "json"
+  }
+
+  action("installer_images") {
+    testonly = true
+    script = "//scripts/mkinstaller/mkinstaller.py"
+    outputs = [ "$target_out_dir/$target_name.img" ]
+    dest_image = rebase_path("$target_out_dir/$target_name.img", root_build_dir)
+    no_output_dir_leaks = false
+    args = [
+      "--create",
+      "--force",
+      "--new-installer",
+      "--cgpt-path",
+      "host-tools/cgpt",
+      "--images",
+      rebase_path(input_images_json_file, root_build_dir),
+      "--build-dir",
+      ".",
+      dest_image,
+    ]
+
+    # this pulls in all the image dependencies we need.
+    deps = [
+      ":$input_images_json",
+      "//build/images:paver-script",
+      "//tools/vboot_reference:cgpt_host",
+    ]
+
+    deps += input_partition_labels
+
+    inputs = input_partition_paths + [ input_images_json_file ]
+
+    metadata = {
+      images = [
+        {
+          label = get_label_info(":$target_name", "label_with_toolchain")
+          name = "installer"
+          path = dest_image
+          type = "installer"
+        },
+      ]
+      image_paths = [ "INSTALLER_IMAGE=$target_name.img" ]
+    }
   }
 }
diff --git a/bundles/buildbot/BUILD.gn b/bundles/buildbot/BUILD.gn
index 15acc56..403ec69 100644
--- a/bundles/buildbot/BUILD.gn
+++ b/bundles/buildbot/BUILD.gn
@@ -281,6 +281,13 @@
   testonly = true
   deps = [ ":host-tests_no_e2e" ]
   if (is_linux) {
-    deps += [ "//tools/fvdl/e2e:tests" ]
+    deps += [
+      "//tools/fvdl/e2e:tests",
+
+      # Installer tests.
+      "//build/images/recovery:recovery-installer($target_toolchain)",
+      "//build/installer_images($target_toolchain)",
+      "//src/tests/installer:tests",
+    ]
   }
 }
diff --git a/src/recovery/system/BUILD.gn b/src/recovery/system/BUILD.gn
index fd7ebd1..918a513 100644
--- a/src/recovery/system/BUILD.gn
+++ b/src/recovery/system/BUILD.gn
@@ -226,6 +226,7 @@
   source_root = "installer/main.rs"
   with_unit_tests = true
   deps = [
+    "//sdk/fidl/fuchsia.boot:fuchsia.boot-rustc",
     "//sdk/fidl/fuchsia.device:fuchsia.device-rustc",
     "//sdk/fidl/fuchsia.fshost:fuchsia.fshost-rustc",
     "//sdk/fidl/fuchsia.hardware.block:fuchsia.hardware.block-rustc",
diff --git a/src/recovery/system/installer/installer.rs b/src/recovery/system/installer/installer.rs
index c440440..c83b4d7 100644
--- a/src/recovery/system/installer/installer.rs
+++ b/src/recovery/system/installer/installer.rs
@@ -86,16 +86,19 @@
     }
 }
 
+pub async fn get_block_device(class_path: String) -> Result<Option<BlockDevice>, Error> {
+    let block_channel = connect_to_service(&class_path).await?;
+    let result = block_device_get_info(block_channel).await.context("Getting block device info")?;
+    Ok(result.map(|(topo_path, size)| BlockDevice { topo_path, class_path, size }))
+}
+
 pub async fn get_block_devices() -> Result<Vec<BlockDevice>, Error> {
     let block_dir = Path::new("/dev/class/block");
     let mut devices = Vec::new();
     for entry in fs::read_dir(block_dir)? {
         let name = entry?.path().to_str().unwrap().to_owned();
-        let block_channel = connect_to_service(&name).await?;
-        let result =
-            block_device_get_info(block_channel).await.context("Getting block device info")?;
-        if let Some((topo_path, size)) = result {
-            devices.push(BlockDevice { topo_path, class_path: name, size });
+        if let Some(bd) = get_block_device(name.clone()).await? {
+            devices.push(bd);
         } else {
             println!("Bad disk: {:?}", name);
         }
diff --git a/src/recovery/system/installer/main.rs b/src/recovery/system/installer/main.rs
index 1597e35..02fe2c0 100644
--- a/src/recovery/system/installer/main.rs
+++ b/src/recovery/system/installer/main.rs
@@ -14,10 +14,11 @@
         facets::{RiveFacet, TextFacet, TextFacetOptions, TextHorizontalAlignment},
         scene::{Scene, SceneBuilder},
     },
-    App, AppAssistant, AppAssistantPtr, AppSender, AssistantCreatorFunc, LocalBoxFuture,
-    MessageTarget, Point, Size, ViewAssistant, ViewAssistantContext, ViewAssistantPtr, ViewKey,
+    App, AppAssistant, AppAssistantPtr, AppSender, MessageTarget, Point, Size, ViewAssistant,
+    ViewAssistantContext, ViewAssistantPtr, ViewKey,
 };
 use euclid::{point2, size2};
+use fidl_fuchsia_boot::ArgumentsMarker;
 use fuchsia_async::{self as fasync};
 use fuchsia_watch::PathEvent;
 use fuchsia_zircon::Event;
@@ -33,7 +34,7 @@
 
 pub mod installer;
 use installer::{
-    find_install_source, get_block_devices, get_bootloader_type, paver_connect,
+    find_install_source, get_block_device, get_block_devices, get_bootloader_type, paver_connect,
     set_active_configuration, BlockDevice, BootloaderType,
 };
 
@@ -97,12 +98,17 @@
 struct InstallerAppAssistant {
     app_sender: AppSender,
     display_rotation: DisplayRotation,
+    automated: bool,
 }
 
 impl InstallerAppAssistant {
-    fn new(app_sender: AppSender) -> Self {
+    fn new(app_sender: AppSender, automated: bool) -> Self {
         let args: Args = argh::from_env();
-        Self { app_sender, display_rotation: args.rotation.unwrap_or(DisplayRotation::Deg0) }
+        Self {
+            app_sender,
+            display_rotation: args.rotation.unwrap_or(DisplayRotation::Deg0),
+            automated,
+        }
     }
 }
 
@@ -118,6 +124,7 @@
             view_key,
             file,
             INSTALLER_HEADLINE,
+            self.automated,
         )?))
     }
 
@@ -199,6 +206,8 @@
     view_key: ViewKey,
     file: Option<rive::File>,
     render_resources: Option<RenderResources>,
+    automated: bool,
+    prev_state: MenuState,
 }
 
 impl InstallerViewAssistant {
@@ -207,6 +216,7 @@
         view_key: ViewKey,
         file: Option<rive::File>,
         heading: &'static str,
+        automated: bool,
     ) -> Result<InstallerViewAssistant, Error> {
         InstallerViewAssistant::setup(app_sender, view_key)?;
 
@@ -221,6 +231,8 @@
             view_key,
             file,
             render_resources: None,
+            automated,
+            prev_state: MenuState::Warning,
         })
     }
 
@@ -326,6 +338,25 @@
         let render_resources = self.render_resources.as_mut().unwrap();
         render_resources.scene.render(_render_context, ready_event, context)?;
         context.request_render();
+
+        if self.automated && self.menu_state_machine.get_state() != self.prev_state {
+            self.prev_state = self.menu_state_machine.get_state();
+            match self.menu_state_machine.get_state() {
+                MenuState::SelectInstall | MenuState::SelectDisk | MenuState::Warning => {
+                    println!(
+                        "installer: {:?}, proceeding to next screen",
+                        self.menu_state_machine.get_state()
+                    );
+                    self.app_sender.queue_message(
+                        MessageTarget::View(self.view_key),
+                        make_message(InstallerMessages::MenuEnter),
+                    );
+                }
+                MenuState::Progress => println!("Install in progress"),
+                MenuState::Error => println!("install failed :("),
+            }
+        }
+
         Ok(())
     }
 
@@ -361,20 +392,6 @@
     }
 }
 
-fn make_app_assistant_fut(
-    app_sender: &AppSender,
-) -> LocalBoxFuture<'_, Result<AppAssistantPtr, Error>> {
-    let f = async move {
-        let assistant = Box::new(InstallerAppAssistant::new(app_sender.clone()));
-        Ok::<AppAssistantPtr, Error>(assistant)
-    };
-    Box::pin(f)
-}
-
-fn make_app_assistant() -> AssistantCreatorFunc {
-    Box::new(make_app_assistant_fut)
-}
-
 fn menu_builder(
     builder: &mut SceneBuilder,
     menu_state_machine: &mut MenuStateMachine,
@@ -728,24 +745,78 @@
     Ok(())
 }
 
-fn main() -> Result<(), Error> {
-    println!("workstation installer: started.");
-    // Before we give control to carnelian, wait until a display driver is bound.
-    fuchsia_async::LocalExecutor::new()
-        .context("Creating executor")?
-        .run_singlethreaded(async move {
-            let mut stream = fuchsia_watch::watch("/dev/class/display-controller")
-                .await
-                .context("Starting watch")?;
-            while let Some(element) = stream.next().await {
-                match element {
-                    PathEvent::Added(_, _) | PathEvent::Existing(_, _) => return Ok(()),
+/// Wait for a display to become available.
+async fn wait_for_display() -> Result<(), Error> {
+    let mut stream =
+        fuchsia_watch::watch("/dev/class/display-controller").await.context("Starting watch")?;
+    while let Some(element) = stream.next().await {
+        match element {
+            PathEvent::Added(_, _) | PathEvent::Existing(_, _) => return Ok(()),
+            _ => {}
+        }
+    }
+    Err(anyhow!("Didn't find a display device"))
+}
+
+/// Check to see if we're doing a non-interactive installs.
+/// Non-interactive installs are very limited and will likely only work on systems with a single
+/// disk.
+/// They are intended to be used in end-to-end tests.
+async fn check_is_interactive() -> Result<bool, Error> {
+    let proxy = fuchsia_component::client::connect_to_protocol::<ArgumentsMarker>()
+        .context("Connecting to boot arguments service")?;
+    let automated =
+        proxy.get_bool("installer.non-interactive", false).await.context("Getting bool")?;
+    println!(
+        "workstation installer: {}doing automated install.",
+        if automated { "" } else { "not " }
+    );
+
+    if automated {
+        wait_for_install_disk().await.context("Waiting for install disk")?;
+    }
+    Ok(automated)
+}
+
+/// Wait for an installation source to become present on the system.
+async fn wait_for_install_disk() -> Result<(), Error> {
+    let mut stream = fuchsia_watch::watch("/dev/class/block").await.context("Starting watch")?;
+    let bootloader_type = get_bootloader_type().await?;
+    let mut devices = vec![];
+    while let Some(element) = stream.next().await {
+        match element {
+            PathEvent::Added(path, _) | PathEvent::Existing(path, _) => {
+                match get_block_device(path.to_str().unwrap().to_owned()).await {
+                    Ok(Some(bd)) => {
+                        devices.push(bd);
+                        if let Ok(_) = find_install_source(&devices, bootloader_type).await {
+                            return Ok(());
+                        }
+                    }
                     _ => {}
                 }
             }
-            Err(anyhow!("Didn't find anything"))
-        })
-        .context("Watching for display controller device")?;
+            _ => {}
+        }
+    }
+    Err(anyhow!("Didn't find an install disk"))
+}
 
-    App::run(make_app_assistant())
+fn main() -> Result<(), Error> {
+    println!("workstation installer: started.");
+
+    // Before we give control to carnelian, wait until a display driver is bound.
+    let (display_result, interactive_result) =
+        fuchsia_async::LocalExecutor::new().context("Creating executor")?.run_singlethreaded(
+            async move { futures::join!(wait_for_display(), check_is_interactive()) },
+        );
+    display_result.context("Waiting for display controller")?;
+    let automated = interactive_result.context("Fetching installer boot arguments")?;
+
+    App::run(Box::new(move |app_sender: &AppSender| {
+        Box::pin(async move {
+            let assistant = Box::new(InstallerAppAssistant::new(app_sender.clone(), automated));
+            Ok::<AppAssistantPtr, Error>(assistant)
+        })
+    }))
 }
diff --git a/src/recovery/system/meta/system_recovery_installer.cmx b/src/recovery/system/meta/system_recovery_installer.cmx
index 788c489..e4d37a3 100644
--- a/src/recovery/system/meta/system_recovery_installer.cmx
+++ b/src/recovery/system/meta/system_recovery_installer.cmx
@@ -21,6 +21,7 @@
             "root-ssl-certificates"
         ],
         "services": [
+            "fuchsia.boot.Arguments",
             "fuchsia.fshost.BlockWatcher",
             "fuchsia.paver.Paver",
             "fuchsia.process.Launcher",
diff --git a/src/tests/installer/BUILD.gn b/src/tests/installer/BUILD.gn
new file mode 100644
index 0000000..87ce6f3
--- /dev/null
+++ b/src/tests/installer/BUILD.gn
@@ -0,0 +1,54 @@
+# Copyright 2022 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("//build/go/go_library.gni")
+import("//build/go/go_test.gni")
+import("//build/testing/environments.gni")
+import("//build/testing/host_test_data.gni")
+
+group("tests") {
+  testonly = true
+
+  deps = []
+
+  # //tools/emulator only works on linux.
+  # The installer only works on x64 targets.
+  if (host_os == "linux" && target_cpu == "x64") {
+    deps += [ ":installer_test($host_toolchain)" ]
+  }
+}
+
+if (is_linux) {
+  go_library("lib") {
+    testonly = true
+    sources = [ "installer_test.go" ]
+    deps = [ "//tools/emulator/emulatortest" ]
+  }
+
+  host_test_data("installer_image") {
+    sources =
+        [ "$root_build_dir/obj/build/installer_images/installer_images.img" ]
+
+    deps = [ "//build/installer_images:installer_images($target_toolchain)" ]
+  }
+
+  go_test("installer_test") {
+    gopackages = [ "go.fuchsia.dev/fuchsia/src/tests/installer" ]
+    deps = [
+      ":lib",
+      "//tools/virtual_device",
+      "//tools/virtual_device:proto",
+    ]
+    non_go_deps = [ ":installer_image" ]
+
+    # The installer test is slow.
+    timeout = "30m"
+    environments = [
+      {
+        dimensions = linux_env.dimensions
+        tags = [ "slow" ]
+      },
+    ]
+  }
+}
diff --git a/src/tests/installer/OWNERS b/src/tests/installer/OWNERS
new file mode 100644
index 0000000..5ba82e0
--- /dev/null
+++ b/src/tests/installer/OWNERS
@@ -0,0 +1,7 @@
+achyb@google.com
+alexlegg@google.com
+natlf@google.com
+simonshields@google.com
+smpham@google.com
+
+# Component: Workstation>platform
diff --git a/src/tests/installer/README.md b/src/tests/installer/README.md
new file mode 100644
index 0000000..de55360
--- /dev/null
+++ b/src/tests/installer/README.md
@@ -0,0 +1 @@
+This test runs through the installer in a very simple manner to ensure that the installer produces bootable disks.
diff --git a/src/tests/installer/installer_test.go b/src/tests/installer/installer_test.go
new file mode 100644
index 0000000..bfb0dd2
--- /dev/null
+++ b/src/tests/installer/installer_test.go
@@ -0,0 +1,106 @@
+// Copyright 2022 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package main
+
+import (
+	"context"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"go.fuchsia.dev/fuchsia/tools/emulator"
+	"go.fuchsia.dev/fuchsia/tools/emulator/emulatortest"
+	fvdpb "go.fuchsia.dev/fuchsia/tools/virtual_device/proto"
+)
+
+var cmdline = []string{"kernel.halt-on-panic=true", "kernel.bypass-debuglog=true", "installer.non-interactive=true", "zvb.current_slot=_r"}
+
+func execDir(t *testing.T) string {
+	ex, err := os.Executable()
+	if err != nil {
+		t.Fatal(err)
+		return ""
+	}
+	return filepath.Dir(ex)
+}
+
+func TestInstaller(t *testing.T) {
+	exDir := execDir(t)
+	distro := emulatortest.UnpackFrom(t, filepath.Join(exDir, "test_data"), emulator.DistributionParams{
+		Emulator: emulator.Femu,
+	})
+	arch := distro.TargetCPU()
+	device := emulator.DefaultVirtualDevice(string(arch))
+	device.Initrd = "recovery-installer"
+	device.KernelArgs = append(device.KernelArgs, cmdline...)
+
+	// Create a new empty disk to install to.
+	f, err := os.CreateTemp("", "recovery-installer")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer os.Remove(f.Name())
+
+	// Make it 64GB.
+	err = f.Truncate(64 * 1024 * 1024 * 1024)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Enable xHCI in the emulator.
+	device.Hw.Hci = "xhci"
+
+	// The installer disk is connected as a UMS device.
+	device.Hw.Drives = append(device.Hw.Drives, &fvdpb.Drive{
+		Id:         "installer",
+		Image:      filepath.Join(exDir, "../obj/build/installer_images/installer_images.img"),
+		IsFilename: true,
+		Device:     &fvdpb.Device{Model: "usb-storage", Options: []string{"drive=installer", "bus=xhci.0"}},
+	})
+
+	// The target install disk is connected as a PCI device.
+	device.Hw.Drives = append(device.Hw.Drives, &fvdpb.Drive{
+		Id:         "disk",
+		Image:      f.Name(),
+		IsFilename: true,
+		Device:     &fvdpb.Device{Model: "virtio-blk-pci"},
+	})
+
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+	i := distro.CreateContext(ctx, device)
+	i.Start()
+
+	// This message indicates that disks have been bound.  This message comes from fshost.
+	i.WaitForLogMessage("/dev/class/block/008 ignored")
+	i.RunCommand("lsblk")
+
+	// Wait for install to be finished.
+	i.RunCommand("log_listener &")
+	i.WaitForLogMessage("Set active configuration to 1")
+
+	i.RunCommand("dm shutdown")
+	i.WaitForLogMessage("fshost shutdown complete")
+	cancel()
+
+	// Now, we try booting the installed image.
+	device = emulator.DefaultVirtualDevice(string(arch))
+	device.KernelArgs = append(device.KernelArgs, cmdline...)
+
+	device.Hw.Drives = append(device.Hw.Drives, &fvdpb.Drive{
+		Id:         "disk",
+		Image:      f.Name(),
+		IsFilename: true,
+		Device:     &fvdpb.Device{Model: "virtio-blk-pci"},
+	})
+
+	ctx, cancel = context.WithCancel(context.Background())
+	defer cancel()
+	i = distro.CreateContext(ctx, device)
+	i.Start()
+
+	// This message indicates that virtcon successfully started.
+	i.WaitForLogMessage("vc: started with args VirtualConsoleArgs")
+}