Wires the VM substrate into the scenario suite as a `cli` target (one project per guest OS: cli-macos / cli-linux / cli-windows). The globalsetup builds the guest's executor binary, provisions a VM, `executor service install`s the supervised daemon, and forwards its loopback port over a reconnecting SSH tunnel — so target.baseUrl works unchanged for the api surface. restart() reboots the guest OS for REAL and waits for the daemon to auto-start, so the existing restart-persistence scenario now proves the boot-time auto-start path (launchd RunAtLoad / systemd linger / Task Scheduler AtStartup), not a process restart. Because globalsetup (main process) and restart() (test worker) are different processes, restart() re-derives the guest address from env and reboots statelessly, mirroring how selfhost-docker's restart re-derives container ops. Verified: `vitest run --project cli-macos` boots a macOS guest, installs the daemon, addSpec's an integration, reboots the guest, and asserts the data survived — green, with a run-record in the matrix. cli-linux reuses the tart provider; cli-windows awaits the ec2 provider.
Unified · Split
| @@ -0,0 +1,3 @@ | ||
| 1 | +import { setupCliTarget } from "./cli.globalsetup"; | |
| 2 | + | |
| 3 | +export default (): Promise<(() => Promise<void>) | void> => setupCliTarget("linux"); | |
| @@ -0,0 +1,3 @@ | ||
| 1 | +import { setupCliTarget } from "./cli.globalsetup"; | |
| 2 | + | |
| 3 | +export default (): Promise<(() => Promise<void>) | void> => setupCliTarget("macos"); | |
| @@ -0,0 +1,65 @@ | ||
| 1 | +// Boot the `cli` target: build the guest's `executor` binary, provision a VM, | |
| 2 | +// install it as a supervised OS service, and forward its loopback HTTP port to | |
| 3 | +// the host over a reconnecting SSH tunnel. Publishes connection + reboot info | |
| 4 | +// via env (inherited by the test workers spawned afterward). Per-OS entrypoints | |
| 5 | +// (cli-macos.globalsetup.ts, …) call setupCliTarget with their OS. | |
| 6 | +import { buildGuestBinary } from "../src/vm/build-binary"; | |
| 7 | +import { tartVm } from "../src/vm/tart"; | |
| 8 | +import type { VmArch, VmOs } from "../src/vm/types"; | |
| 9 | +import { waitForHttp } from "./boot"; | |
| 10 | + | |
| 11 | +const PORT = 4789; | |
| 12 | +const GUEST_DIR = "~/ed"; | |
| 13 | + | |
| 14 | +export async function setupCliTarget(os: VmOs): Promise<(() => Promise<void>) | void> { | |
| 15 | + process.env.E2E_VM_OS = os; // so the worker-side target resolves the same OS | |
| 16 | + if (os === "windows") { | |
| 17 | + throw new Error("cli-windows is pending the ec2 provider; run cli-macos / cli-linux for now"); | |
| 18 | + } | |
| 19 | + | |
| 20 | + const arch: VmArch = "arm64"; // tart guests on an Apple-Silicon host | |
| 21 | + const binDir = await buildGuestBinary(os, arch); | |
| 22 | + const vm = await tartVm(os, arch).provision(); | |
| 23 | + | |
| 24 | + let tunnelClose: (() => void) | undefined; | |
| 25 | + try { | |
| 26 | + await vm.ssh(`rm -rf ${GUEST_DIR} && mkdir -p ${GUEST_DIR}`); | |
| 27 | + await vm.push(`${binDir}/.`, `${GUEST_DIR}/`); | |
| 28 | + // macOS quarantines scp'd executables; clear it so the binary can run. | |
| 29 | + await vm.ssh( | |
| 30 | + os === "macos" | |
| 31 | + ? `chmod +x ${GUEST_DIR}/executor; xattr -dr com.apple.quarantine ${GUEST_DIR} 2>/dev/null || true` | |
| 32 | + : `chmod +x ${GUEST_DIR}/executor`, | |
| 33 | + ); | |
| 34 | + | |
| 35 | + const install = await vm.ssh(`${GUEST_DIR}/executor service install --port ${PORT}`); | |
| 36 | + if (install.code !== 0) { | |
| 37 | + throw new Error(`service install failed: ${install.stderr.trim() || install.stdout.trim()}`); | |
| 38 | + } | |
| 39 | + | |
| 40 | + const keyRaw = (await vm.ssh("cat ~/.executor/server-control/service.key")).stdout.trim(); | |
| 41 | + const password = (JSON.parse(keyRaw) as { password: string }).password; | |
| 42 | + | |
| 43 | + const tunnel = await vm.tunnel(PORT); | |
| 44 | + tunnelClose = tunnel.close; | |
| 45 | + const baseUrl = `http://127.0.0.1:${tunnel.localPort}`; | |
| 46 | + await waitForHttp(`${baseUrl}/`, { timeoutMs: 60_000 }); | |
| 47 | + | |
| 48 | + process.env.E2E_CLI_BASE_URL = baseUrl; | |
| 49 | + process.env.E2E_CLI_AUTH_PASSWORD = password; | |
| 50 | + process.env.E2E_CLI_VM_HOST = vm.host; | |
| 51 | + process.env.E2E_CLI_TUNNEL_PORT = String(tunnel.localPort); | |
| 52 | + process.env.E2E_CLI_BIN_DIR = GUEST_DIR; | |
| 53 | + } catch (error) { | |
| 54 | + tunnelClose?.(); | |
| 55 | + await vm.ssh(`${GUEST_DIR}/executor service uninstall`).catch(() => undefined); | |
| 56 | + await vm.discard(); | |
| 57 | + throw error; | |
| 58 | + } | |
| 59 | + | |
| 60 | + return async () => { | |
| 61 | + tunnelClose?.(); | |
| 62 | + await vm.ssh(`${GUEST_DIR}/executor service uninstall`).catch(() => undefined); | |
| 63 | + await vm.discard(); | |
| 64 | + }; | |
| 65 | +} | |
| @@ -34,6 +34,23 @@ const SSH_OPTS = [ · 33 unmodified lines | ||
| 34 | 34 | const GUEST_USER = "admin"; |
| 35 | 35 | const GUEST_PASS = "admin"; |
| 36 | 36 | |
| 37 | +/** | |
| 38 | + * Reboot a tart guest by address, with no live handle. `restart()` runs in a | |
| 39 | + * vitest worker (separate process from the globalsetup that owns the VM), so it | |
| 40 | + * re-derives the guest address from env and triggers the reboot statelessly — | |
| 41 | + * the reconnecting tunnel and a health poll confirm recovery. | |
| 42 | + */ | |
| 43 | +export const sshRebootGuest = async (ip: string): Promise<void> => { | |
| 44 | + await execFileP(SSHPASS, [ | |
| 45 | + "-p", | |
| 46 | + GUEST_PASS, | |
| 47 | + "ssh", | |
| 48 | + ...SSH_OPTS, | |
| 49 | + `${GUEST_USER}@${ip}`, | |
| 50 | + "sudo reboot", | |
| 51 | + ]).catch(() => undefined); // the connection drops mid-call | |
| 52 | +}; | |
| 53 | + | |
| 37 | 54 | const baseImage = (os: "macos" | "linux"): string => |
| 38 | 55 | os === "macos" |
| 39 | 56 | ? (process.env.E2E_TART_MACOS_BASE ?? "executor-macos-base") |
| @@ -129,6 +146,9 @@ export const tartVm = (os: "macos" | "linux", arch: VmArch = "arm64"): VmProvide · 89 unmodified lines | ||
| 129 | 146 | const handle: VmHandle = { |
| 130 | 147 | os, |
| 131 | 148 | arch, |
| 149 | + get host() { | |
| 150 | + return ip; | |
| 151 | + }, | |
| 132 | 152 | ssh, |
| 133 | 153 | push: async (localPath, remotePath) => { |
| 134 | 154 | await execFileP(SSHPASS, [ |
| @@ -22,6 +22,8 @@ export interface Tunnel { · 21 unmodified lines | ||
| 22 | 22 | export interface VmHandle { |
| 23 | 23 | readonly os: VmOs; |
| 24 | 24 | readonly arch: VmArch; |
| 25 | + /** Current reachable address of the guest (re-resolved across reboots). */ | |
| 26 | + readonly host: string; | |
| 25 | 27 | /** Run a command in the guest over SSH (shell on Unix, PowerShell on Windows). */ |
| 26 | 28 | ssh(command: string): Promise<SshResult>; |
| 27 | 29 | /** Copy a local file or directory into the guest (recursive for directories). */ |
| @@ -0,0 +1,47 @@ | ||
| 1 | +// The supervised local CLI daemon as a target. `executor service install` boots | |
| 2 | +// an OS-managed daemon (launchd / systemd / Task Scheduler) inside a guest VM; | |
| 3 | +// `restart()` reboots that guest for REAL, so restart-persistence proves the | |
| 4 | +// boot-time auto-start path (RunAtLoad / linger / AtStartup), not just a process | |
| 5 | +// restart. The guest daemon binds loopback, so globalsetup forwards it to a | |
| 6 | +// local port over a reconnecting SSH tunnel — making target.baseUrl work | |
| 7 | +// unchanged for the api surface. Boot + tunnel live in setup/cli.globalsetup.ts; | |
| 8 | +// this target only reads what that published via env. | |
| 9 | +import { Effect } from "effect"; | |
| 10 | + | |
| 11 | +import { waitForHttp } from "../setup/boot"; | |
| 12 | +import { sshRebootGuest } from "../src/vm/tart"; | |
| 13 | +import type { VmOs } from "../src/vm/types"; | |
| 14 | +import type { Capability, Identity, Target } from "../src/target"; | |
| 15 | + | |
| 16 | +const env = (key: string): string => { | |
| 17 | + const value = process.env[key]; | |
| 18 | + if (!value) throw new Error(`cli target: ${key} not set — did cli.globalsetup run?`); | |
| 19 | + return value; | |
| 20 | +}; | |
| 21 | + | |
| 22 | +export const cliTarget = (): Target => { | |
| 23 | + const baseUrl = env("E2E_CLI_BASE_URL"); | |
| 24 | + const os = (process.env.E2E_VM_OS ?? "macos") as VmOs; | |
| 25 | + const username = process.env.E2E_CLI_AUTH_USER ?? "executor"; | |
| 26 | + | |
| 27 | + return { | |
| 28 | + name: process.env.E2E_TARGET ?? "cli", | |
| 29 | + baseUrl, | |
| 30 | + mcpUrl: `${baseUrl}/mcp`, | |
| 31 | + capabilities: new Set<Capability>(["api"]), | |
| 32 | + newIdentity: () => | |
| 33 | + Effect.sync((): Identity => { | |
| 34 | + const basic = Buffer.from(`${username}:${env("E2E_CLI_AUTH_PASSWORD")}`).toString("base64"); | |
| 35 | + return { label: "cli-daemon", headers: { Authorization: `Basic ${basic}` } }; | |
| 36 | + }), | |
| 37 | + // A genuine machine reboot, not a service kick: reboot the guest OS and wait | |
| 38 | + // for the supervised daemon to auto-start and serve again (401 = up). The | |
| 39 | + // reconnecting tunnel re-establishes the forward, so the same baseUrl works. | |
| 40 | + restart: () => | |
| 41 | + Effect.promise(async () => { | |
| 42 | + if (os === "windows") throw new Error("cli-windows restart pending the ec2 provider"); | |
| 43 | + await sshRebootGuest(env("E2E_CLI_VM_HOST")); | |
| 44 | + await waitForHttp(`${baseUrl}/`, { timeoutMs: 240_000 }); | |
| 45 | + }), | |
| 46 | + }; | |
| 47 | +}; | |
| @@ -2,6 +2,7 @@ · 1 unmodified line | ||
| 2 | 2 | // once per worker. Adding a target = one factory entry here + a project in |
| 3 | 3 | // vitest.config.ts + a globalsetup that boots (or attaches to) the instance. |
| 4 | 4 | import type { Target } from "../src/target"; |
| 5 | +import { cliTarget } from "./cli"; | |
| 5 | 6 | import { cloudTarget } from "./cloud"; |
| 6 | 7 | import { cloudflareTarget } from "./cloudflare"; |
| 7 | 8 | import { desktopTarget } from "./desktop"; |
| @@ -16,6 +17,11 @@ const factories: Record<string, () => Target> = { · 8 unmodified lines | ||
| 16 | 17 | cloudflare: cloudflareTarget, |
| 17 | 18 | desktop: desktopTarget, |
| 18 | 19 | local: localTarget, |
| 20 | + // The supervised CLI daemon inside a VM, one project per guest OS — restart() | |
| 21 | + // is a real reboot. See setup/cli.globalsetup.ts. | |
| 22 | + "cli-macos": cliTarget, | |
| 23 | + "cli-linux": cliTarget, | |
| 24 | + "cli-windows": cliTarget, | |
| 19 | 25 | }; |
| 20 | 26 | |
| 21 | 27 | let current: Target | undefined; |
| @@ -68,6 +68,21 @@ export default defineConfig({ · 67 unmodified lines | ||
| 68 | 68 | fileParallelism: true, |
| 69 | 69 | testTimeout: 180_000, |
| 70 | 70 | }), |
| 71 | + // The supervised CLI daemon inside a guest VM, one project per OS. The | |
| 72 | + // globalsetup provisions a VM, `executor service install`s the daemon, and | |
| 73 | + // tunnels it; restart() reboots the guest for REAL, so restart-persistence | |
| 74 | + // proves the boot-time auto-start path. Needs tart (macOS/Linux) or an EC2 | |
| 75 | + // credential (Windows); not part of the default `npm run test` chain — run | |
| 76 | + // with `vitest run --project cli-macos` (etc.) on the Mini. | |
| 77 | + ...(["macos", "linux", "windows"] as const).map((os) => | |
| 78 | + project(`cli-${os}`, { | |
| 79 | + include: ["scenarios/restart-persistence.test.ts", "cli/**/*.test.ts"], | |
| 80 | + env: { E2E_TARGET: `cli-${os}`, E2E_VM_OS: os }, | |
| 81 | + fileParallelism: false, | |
| 82 | + testTimeout: 300_000, | |
| 83 | + hookTimeout: 900_000, | |
| 84 | + }), | |
| 85 | + ), | |
| 71 | 86 | ], |
| 72 | 87 | }, |
| 73 | 88 | }); |