//go:build smoke package smoketest import ( "os" "os/exec" "path/filepath" "strings" "testing" ) // testWorkspaceRun ports scenario_workspace_run. Ships the throwaway // git repo to a fresh VM and reads the marker file from the guest. func testWorkspaceRun(t *testing.T) { out := mustBanger(t, "vm", "run", "--rm", repoDir, "--", "cat", "/root/repo/smoke-file.txt") wantContains(t, out, "smoke-workspace-marker", "workspace vm run guest read") } // testWorkspaceDryrun ports scenario_workspace_dryrun. `--dry-run` // lists the tracked files and the resolved transfer mode without // creating a VM. func testWorkspaceDryrun(t *testing.T) { out := mustBanger(t, "vm", "run", "--dry-run", repoDir) wantContains(t, out, "smoke-file.txt", "dry-run file list") wantContains(t, out, "mode: tracked only", "dry-run mode line") } // testIncludeUntracked ports scenario_include_untracked. Drops an // untracked file in the fixture and asserts --include-untracked picks // it up. The cleanup hook removes the file even if the scenario fails // so downstream repodir scenarios see the original tree. func testIncludeUntracked(t *testing.T) { untracked := filepath.Join(repoDir, "smoke-untracked.txt") if err := os.WriteFile(untracked, []byte("untracked-marker\n"), 0o644); err != nil { t.Fatalf("write untracked file: %v", err) } t.Cleanup(func() { _ = os.Remove(untracked) }) out := mustBanger(t, "vm", "run", "--rm", "--include-untracked", repoDir, "--", "cat", "/root/repo/smoke-untracked.txt") wantContains(t, out, "untracked-marker", "include-untracked guest read") } // testWorkspaceExport ports scenario_workspace_export. Round-trips a // guest-side edit back out as a patch via `vm workspace export`. func testWorkspaceExport(t *testing.T) { const name = "smoke-export" vmCreate(t, name, "--image", "debian-bookworm") mustBanger(t, "vm", "workspace", "prepare", name, repoDir) mustBanger(t, "vm", "ssh", name, "--", "sh", "-c", "echo guest-edit > /root/repo/new-guest-file.txt") patch := filepath.Join(runtimeDir, "smoke-export.diff") mustBanger(t, "vm", "workspace", "export", name, "--output", patch) st, err := os.Stat(patch) if err != nil { t.Fatalf("export: stat patch %s: %v", patch, err) } if st.Size() == 0 { t.Fatalf("export: patch file empty at %s", patch) } body, err := os.ReadFile(patch) if err != nil { t.Fatalf("export: read patch: %v", err) } wantContains(t, string(body), "new-guest-file.txt", "export: patch must reference new-guest-file.txt") } // testWorkspaceFullCopy ports scenario_workspace_full_copy. Verifies // the alternate transfer path (--mode full_copy) lands the same fixture // in the guest. func testWorkspaceFullCopy(t *testing.T) { const name = "smoke-fc" vmCreate(t, name) mustBanger(t, "vm", "workspace", "prepare", name, repoDir, "--mode", "full_copy") out := mustBanger(t, "vm", "ssh", name, "--", "cat", "/root/repo/smoke-file.txt") wantContains(t, out, "smoke-workspace-marker", "full_copy: marker missing in guest") } // testWorkspaceBasecommit ports scenario_workspace_basecommit. Confirms // that `vm workspace export` without --base-commit captures only the // working-copy diff, while --base-commit also captures guest-side // commits made on top of HEAD. func testWorkspaceBasecommit(t *testing.T) { const name = "smoke-basecommit" vmCreate(t, name) mustBanger(t, "vm", "workspace", "prepare", name, repoDir) baseSHA := strings.TrimSpace(mustBanger(t, "vm", "ssh", name, "--", "sh", "-c", "cd /root/repo && git rev-parse HEAD")) if len(baseSHA) != 40 { t.Fatalf("export base: bad base sha: %q", baseSHA) } mustBanger(t, "vm", "ssh", name, "--", "sh", "-c", "cd /root/repo && "+ "git -c user.email=smoke@smoke -c user.name=smoke checkout -b smoke-branch >/dev/null 2>&1 && "+ "echo committed-marker > smoke-committed.txt && "+ "git add smoke-committed.txt && "+ "git -c user.email=smoke@smoke -c user.name=smoke commit -q -m 'guest side'") plain := filepath.Join(runtimeDir, "smoke-plain.diff") mustBanger(t, "vm", "workspace", "export", name, "--output", plain) if body, err := os.ReadFile(plain); err == nil { wantNotContains(t, string(body), "smoke-committed.txt", "export base: plain export must NOT capture guest-side commit") } base := filepath.Join(runtimeDir, "smoke-base.diff") mustBanger(t, "vm", "workspace", "export", name, "--base-commit", baseSHA, "--output", base) st, err := os.Stat(base) if err != nil || st.Size() == 0 { t.Fatalf("export base: --base-commit patch empty/missing: stat=%v err=%v", st, err) } body, _ := os.ReadFile(base) wantContains(t, string(body), "smoke-committed.txt", "export base: --base-commit patch must include committed marker") } // testWorkspaceRestart ports scenario_workspace_restart. Verifies the // workspace marker survives a stop/start cycle (rootfs persistence). func testWorkspaceRestart(t *testing.T) { const name = "smoke-wsrestart" vmCreate(t, name) mustBanger(t, "vm", "workspace", "prepare", name, repoDir) pre := mustBanger(t, "vm", "ssh", name, "--", "cat", "/root/repo/smoke-file.txt") wantContains(t, pre, "smoke-workspace-marker", "workspace stop/start: pre-cycle marker") mustBanger(t, "vm", "stop", name) mustBanger(t, "vm", "start", name) waitForSSH(t, name) post := mustBanger(t, "vm", "ssh", name, "--", "cat", "/root/repo/smoke-file.txt") wantContains(t, post, "smoke-workspace-marker", "workspace stop/start: post-cycle marker") } // testVMExec ports scenario_vm_exec. The longest scenario in the suite // — covers auto-cd, exit-code propagation, stale-workspace detection, // --auto-prepare resync, and the not-running refusal. The repodir // commit added mid-scenario is rolled back via t.Cleanup so subsequent // repodir-chain scenarios see the original fixture state. func testVMExec(t *testing.T) { const name = "smoke-exec" vmCreate(t, name) mustBanger(t, "vm", "workspace", "prepare", name, repoDir) show := mustBanger(t, "vm", "show", name) wantContains(t, show, `"guest_path": "/root/repo"`, "vm exec: workspace.guest_path not persisted") out := mustBanger(t, "vm", "exec", name, "--", "cat", "smoke-file.txt") wantContains(t, out, "smoke-workspace-marker", "vm exec: workspace marker") if got := strings.TrimSpace(mustBanger(t, "vm", "exec", name, "--", "pwd")); got != "/root/repo" { t.Fatalf("vm exec: pwd got %q, want /root/repo (auto-cd didn't happen)", got) } res := banger(t, "vm", "exec", name, "--", "sh", "-c", "exit 17") wantExit(t, res, 17, "vm exec: exit-code propagation") // Advance host HEAD so the workspace goes stale, register the // rollback before mutating so a Fatal anywhere below still // restores the fixture. t.Cleanup(func() { cmd := exec.Command("git", "reset", "--hard", "HEAD~1", "-q") cmd.Dir = repoDir _ = cmd.Run() }) for _, args := range [][]string{ {"sh", "-c", "echo post-prepare-marker > smoke-exec-new.txt"}, {"git", "add", "smoke-exec-new.txt"}, {"git", "commit", "-q", "-m", "add smoke-exec-new.txt after prepare"}, } { cmd := exec.Command(args[0], args[1:]...) cmd.Dir = repoDir if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("vm exec: stage host commit: %s: %v\n%s", args, err, out) } } stale := banger(t, "vm", "exec", name, "--", "ls", "smoke-exec-new.txt") if stale.rc == 0 { t.Fatalf("vm exec: stale workspace already had the new file (dirty path didn't take effect)") } wantContains(t, stale.stderr, "workspace stale", "vm exec: stale-workspace warning on stderr") wantContains(t, stale.stderr, "--auto-prepare", "vm exec: stale warning must mention --auto-prepare") auto := mustBanger(t, "vm", "exec", name, "--auto-prepare", "--", "cat", "smoke-exec-new.txt") wantContains(t, auto, "post-prepare-marker", "vm exec: --auto-prepare didn't re-sync new file") clean := banger(t, "vm", "exec", name, "--", "true") wantExit(t, clean, 0, "vm exec: post-auto-prepare run") wantNotContains(t, clean.stderr, "workspace stale", "vm exec: stale warning persisted after --auto-prepare") mustBanger(t, "vm", "stop", name) stopped := banger(t, "vm", "exec", name, "--", "true") if stopped.rc == 0 { t.Fatalf("vm exec: exec on stopped VM unexpectedly succeeded") } wantContains(t, stopped.stderr, "not running", "vm exec: stopped-VM error message") }