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 | const GUEST_USER = "admin"; | 34 | const GUEST_USER = "admin"; |
| 35 | const GUEST_PASS = "admin"; | 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 | const baseImage = (os: "macos" | "linux"): string => | 54 | const baseImage = (os: "macos" | "linux"): string => |
| 38 | os === "macos" | 55 | os === "macos" |
| 39 | ? (process.env.E2E_TART_MACOS_BASE ?? "executor-macos-base") | 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 | const handle: VmHandle = { | 146 | const handle: VmHandle = { |
| 130 | os, | 147 | os, |
| 131 | arch, | 148 | arch, |
| 149 | get host() { | ||
| 150 | return ip; | ||
| 151 | }, | ||
| 132 | ssh, | 152 | ssh, |
| 133 | push: async (localPath, remotePath) => { | 153 | push: async (localPath, remotePath) => { |
| 134 | await execFileP(SSHPASS, [ | 154 | await execFileP(SSHPASS, [ |
| @@ -22,6 +22,8 @@ export interface Tunnel { · 21 unmodified lines | |||
| 22 | export interface VmHandle { | 22 | export interface VmHandle { |
| 23 | readonly os: VmOs; | 23 | readonly os: VmOs; |
| 24 | readonly arch: VmArch; | 24 | readonly arch: VmArch; |
| 25 | /** Current reachable address of the guest (re-resolved across reboots). */ | ||
| 26 | readonly host: string; | ||
| 25 | /** Run a command in the guest over SSH (shell on Unix, PowerShell on Windows). */ | 27 | /** Run a command in the guest over SSH (shell on Unix, PowerShell on Windows). */ |
| 26 | ssh(command: string): Promise<SshResult>; | 28 | ssh(command: string): Promise<SshResult>; |
| 27 | /** Copy a local file or directory into the guest (recursive for directories). */ | 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 | // once per worker. Adding a target = one factory entry here + a project in | 2 | // once per worker. Adding a target = one factory entry here + a project in |
| 3 | // vitest.config.ts + a globalsetup that boots (or attaches to) the instance. | 3 | // vitest.config.ts + a globalsetup that boots (or attaches to) the instance. |
| 4 | import type { Target } from "../src/target"; | 4 | import type { Target } from "../src/target"; |
| 5 | import { cliTarget } from "./cli"; | ||
| 5 | import { cloudTarget } from "./cloud"; | 6 | import { cloudTarget } from "./cloud"; |
| 6 | import { cloudflareTarget } from "./cloudflare"; | 7 | import { cloudflareTarget } from "./cloudflare"; |
| 7 | import { desktopTarget } from "./desktop"; | 8 | import { desktopTarget } from "./desktop"; |
| @@ -16,6 +17,11 @@ const factories: Record<string, () => Target> = { · 8 unmodified lines | |||
| 16 | cloudflare: cloudflareTarget, | 17 | cloudflare: cloudflareTarget, |
| 17 | desktop: desktopTarget, | 18 | desktop: desktopTarget, |
| 18 | local: localTarget, | 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 | let current: Target | undefined; | 27 | let current: Target | undefined; |
| @@ -68,6 +68,21 @@ export default defineConfig({ · 67 unmodified lines | |||
| 68 | fileParallelism: true, | 68 | fileParallelism: true, |
| 69 | testTimeout: 180_000, | 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 | }); |