Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/desktop/scripts/build-sidecar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ await rm(EXECUTOR_OUT_DIR, { recursive: true, force: true });
await mkdir(EXECUTOR_OUT_DIR, { recursive: true });
await cp(sourceBinDir, EXECUTOR_OUT_DIR, { recursive: true });

if (process.platform !== "win32") {
// Restore the unix executable bit — keyed on the TARGET, not the host. A
// windows-target cross-build (BUN_TARGET=bun-windows-x64 on macOS/linux) stages
// `executor.exe`, which needs no bit; chmod'ing a non-existent `executor` there
// would ENOENT.
if (!targetPackage.includes("windows")) {
await chmod(join(EXECUTOR_OUT_DIR, "executor"), 0o755);
}

Expand Down
42 changes: 42 additions & 0 deletions e2e/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,48 @@ When handing results to the user, follow the evidence contract in the root
[AGENTS.md](../AGENTS.md) (direct run links + a live instance + what to try);
[RUNNING.md](../RUNNING.md) has the current sharing/demo mechanics.

## Desktop targets (the app on real OSes, filmed)

The packaged desktop app runs as its own targets, each landing in its own
`runs/<target>/` bucket with a video. One shared scenario (`desktop-vm/`) and the
shared driver (`src/vm/desktop.ts`) + setup plumbing (`setup/desktop-vm.ts`); one
project + globalsetup per guest OS.

- **`desktop-packaged`** — the real electron-builder bundle on THIS machine's
display (the supervised-daemon attach path). Needs a logged-in GUI session.
- **`desktop-macos` / `desktop-linux`** — the same bundle inside a guest VM,
driven over CDP from the host and filmed. The globalsetup boots the guest
(tart), builds + pushes the bundle, brings the app up with
`--remote-debugging-port`, forwards it, and the scenario connects + drives +
records. Provisioned automatically — or attach to a running guest with
`E2E_DESKTOP_VM_IP=<ip>`:

```sh
vitest run --project desktop-macos # or desktop-linux
```

The guests run tart `--no-graphics` (no host window, never steals focus) but
still have a usable display:

- **macOS**: the base image's autologin reaches a real Aqua session
(WindowServer/Dock/Finder). Launch the app INTO it with `sudo launchctl asuser
<uid> …` (a plain SSH spawn lands in a non-GUI session); the unsigned arm64
bundle is ad-hoc `codesign`'d in the guest; `screencapture` films it.
- **linux**: no window server, so the app renders into an `Xvfb` display with a
minimal WM (`openbox` — without it the electron window never maps); the window
maps tiny (10x10) so the globalsetup `xdotool`-resizes it to fill, and ffmpeg
`x11grab` films it. `--no-sandbox` (the chrome-sandbox needs setuid root).

Base images (`admin`/`admin`): `executor-macos-base` (cirruslabs sequoia, autologin)
and `executor-linux-base` (cirruslabs ubuntu + Xvfb/ffmpeg/openbox/xdotool +
electron runtime libs). The bundle's `executor` binary is cross-compiled for the
guest (`BUN_TARGET`), and electron-builder's `dir` target assembles the unpacked
app on macOS — so both bundles build on this Mac.

Note: `desktop-packaged`'s `guiAvailable()` probe (`launchctl managername`) reads
"Background" over SSH even when Aqua is up, so it's host-only; the VM targets gate
on a CDP page target instead.

## Discovering endpoints

- The full OpenAPI spec: `curl http://127.0.0.1:<cloud port>/api/openapi.json`
Expand Down
75 changes: 75 additions & 0 deletions e2e/desktop-vm/console-renders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// The PACKAGED desktop app, on camera, inside a GUI guest — driven over CDP from
// the host. ONE scenario shared by every desktop-<os> project (desktop-macos,
// desktop-linux): the same bundle and CDP driver, proving it renders on a guest
// OS and filming the actual console. The desktop-<os> globalsetup boots the
// guest, launches the app, forwards its --remote-debugging-port (E2E_DESKTOP_CDP_PORT)
// and publishes the guest IP; this scenario connects, drives, and records. The
// run lands in runs/<target>/ (its own per-OS bucket). Without a guest it skips
// honestly, like desktop-packaged without a display.
import { writeFileSync } from "node:fs";
import { join } from "node:path";

import { expect, it } from "@effect/vitest";
import { Effect } from "effect";

import { scenario } from "../src/scenario";
import { RunDir } from "../src/services";
import { CdpPage, pageWsUrl, recordGuestScreen } from "../src/vm/desktop";

const NAME = "Desktop (packaged, in a VM) · the bundle renders its console";
const cdpPort = process.env.E2E_DESKTOP_CDP_PORT;
const guestIp = process.env.E2E_DESKTOP_VM_IP;
const recSeconds = Number(process.env.E2E_DESKTOP_REC_SECONDS ?? "12");
const os: "macos" | "linux" | "windows" =
process.env.E2E_TARGET === "desktop-windows"
? "windows"
: process.env.E2E_TARGET === "desktop-linux"
? "linux"
: "macos";

const run = async (runDir: string) => {
const cdp = await CdpPage.connect(await pageWsUrl(Number(cdpPort)));
try {
await cdp.command("Runtime.enable");
await cdp.command("Page.enable");

// Film the console while we drive it (OS-aware capture lands a playable mp4).
const recording = recordGuestScreen(
guestIp as string,
recSeconds,
join(runDir, "session.mp4"),
os,
);

// Reaching the nav proves the packaged bundle booted and connected to its
// daemon on this OS.
await cdp.waitForText("Integrations", 60_000).catch(() => cdp.waitForText("Settings", 60_000));
writeFileSync(join(runDir, "01-console-rendered.png"), await cdp.screenshot());

const body = await cdp.command<{ result?: { value?: string } }>("Runtime.evaluate", {
expression: "document.body.innerText",
returnByValue: true,
});
expect(body.result?.value ?? "", "the packaged console rendered its nav").toContain(
"Integrations",
);

await recording;
} finally {
cdp.close();
}
};

if (!cdpPort || !guestIp) {
it.skip(`${NAME} (needs a desktop guest — set E2E_DESKTOP_VM_IP or run the desktop-<os> project)`, () => {});
} else {
// Literal name (not NAME) so the run's test.ts review artifact captures it.
scenario(
"Desktop (packaged, in a VM) · the bundle renders its console",
{ timeout: 180_000 },
Effect.gen(function* () {
const runDir = yield* RunDir;
yield* Effect.promise(() => run(runDir));
}),
);
}
125 changes: 125 additions & 0 deletions e2e/setup/desktop-linux.globalsetup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// desktop-linux: bring the PACKAGED app up inside a Linux guest and forward its
// CDP port (the shared attach/forward lives in ./desktop-vm). No window server,
// so the app renders into an Xvfb virtual display; ffmpeg x11grab (in the
// scenario's recorder) films that display. Simpler than macOS: no Aqua, no
// codesign, no launchctl — just background processes with DISPLAY set and
// --no-sandbox (the chrome-sandbox needs setuid root, pointless on a throwaway
// guest). The base image (executor-linux-base) carries Xvfb + ffmpeg + the
// electron runtime libs.
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { basename, join } from "node:path";

import { pushDirAsTar } from "../src/vm/desktop";
import { tartVm } from "../src/vm/tart";
import {
attachOrProvision,
CDP_GUEST_PORT,
waitGuestHttp,
waitGuestPageTarget,
type ProvisionedGuest,
} from "./desktop-vm";

const DAEMON_PORT = 4789;
const GUEST_DIR = "/home/admin/exe";
const GUEST_HOME = "/home/admin/exe-home";
const DISPLAY = ":99";

const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url));
const hostBundle = () => {
// electron-builder names the dir `linux-<arch>-unpacked` for non-x64.
const dir = join(appDir, "dist", "linux-arm64-unpacked");
return {
dir,
exe: join(dir, "executor-desktop"),
executor: join(dir, "resources/executor/executor"),
};
};

/** Build the packaged linux-arm64 bundle if it isn't on disk. The `executor`
* binary is cross-compiled here via BUN_TARGET (same as the cli-linux lane);
* electron-builder's `dir` target assembles the unpacked app on macOS without
* Docker. */
const ensureBundle = (): void => {
if (existsSync(hostBundle().dir)) return;
const run = (cmd: string, args: string[], env: Record<string, string> = {}) =>
execFileSync(cmd, args, { cwd: appDir, stdio: "inherit", env: { ...process.env, ...env } });
run("bun", ["./scripts/build-sidecar.ts"], { BUN_TARGET: "bun-linux-arm64" });
run("bunx", ["--bun", "electron-vite", "build"]);
run(
"bunx",
[
"--bun",
"electron-builder",
"--config",
"electron-builder.e2e.config.ts",
"--linux",
"--arm64",
],
{ CSC_IDENTITY_AUTO_DISCOVERY: "false" },
);
};

const provisionLinux = async (): Promise<ProvisionedGuest> => {
ensureBundle();
const { dir } = hostBundle();
const vm = await tartVm("linux", "arm64").provision();
try {
await vm.ssh(`rm -rf ${GUEST_DIR} ${GUEST_HOME}; mkdir -p ${GUEST_HOME}/.executor`);
await pushDirAsTar(vm.host, dir, GUEST_DIR);

const guestApp = `${GUEST_DIR}/${basename(dir)}`;
const guestExe = `${guestApp}/executor-desktop`;
const guestExecutor = `${guestApp}/resources/executor/executor`;
await vm.ssh(`chmod +x '${guestExe}' '${guestExecutor}' 2>/dev/null || true`);
const env = `HOME=${GUEST_HOME} EXECUTOR_DATA_DIR=${GUEST_HOME}/.executor`;

// A virtual display + a minimal WM (openbox) — without a window manager the
// electron window doesn't map onto the framebuffer that x11grab records.
await vm.ssh(
`pkill Xvfb 2>/dev/null; pkill openbox 2>/dev/null; ` +
`nohup Xvfb ${DISPLAY} -screen 0 1280x800x24 >/tmp/xvfb.log 2>&1 & sleep 2; ` +
`DISPLAY=${DISPLAY} nohup openbox >/tmp/openbox.log 2>&1 & sleep 1; echo up`,
);

// 1) the bundled daemon, supervised — the app attaches to this.
await vm.ssh(
`nohup env ${env} EXECUTOR_SUPERVISED=1 EXECUTOR_AUTH_TOKEN=desktop-linux-e2e EXECUTOR_CLIENT=desktop ` +
`'${guestExecutor}' daemon run --foreground --port ${DAEMON_PORT} --hostname 127.0.0.1 ` +
`>/tmp/executor-daemon.log 2>&1 &`,
);
if (!(await waitGuestHttp(vm, `http://127.0.0.1:${DAEMON_PORT}/`))) {
throw new Error(
"supervised daemon never came up in the guest (see /tmp/executor-daemon.log)",
);
}

// 2) the packaged app on the virtual display, with CDP enabled.
await vm.ssh(
`nohup env ${env} DISPLAY=${DISPLAY} '${guestExe}' --no-sandbox ` +
`--remote-debugging-port=${CDP_GUEST_PORT} --remote-allow-origins='*' ` +
`>/tmp/executor-app.log 2>&1 &`,
);
if (!(await waitGuestPageTarget(vm, CDP_GUEST_PORT))) {
const log = (await vm.ssh("tail -40 /tmp/executor-app.log 2>/dev/null").catch(() => null))
?.stdout;
throw new Error(`the app's CDP page target never appeared:\n${log ?? "(no app log)"}`);
}

// The electron window maps tiny (10x10) under Xvfb; size it to the screen so
// the x11grab recording captures the full console (CDP screenshots the
// renderer surface regardless, but the film grabs the X framebuffer).
await vm.ssh(
`WID=$(DISPLAY=${DISPLAY} xdotool search --name executor-desktop | head -1); ` +
`[ -n "$WID" ] && DISPLAY=${DISPLAY} xdotool windowsize "$WID" 1280 800 windowmove "$WID" 0 0 || true`,
);

return { ip: vm.host, teardown: async () => void (await vm.discard()) };
} catch (error) {
await vm.discard();
throw error;
}
};

export default (): Promise<(() => Promise<void>) | void> => attachOrProvision(provisionLinux);
108 changes: 108 additions & 0 deletions e2e/setup/desktop-macos.globalsetup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// desktop-macos: bring the PACKAGED app up inside a macOS GUI guest and forward
// its CDP port (the shared attach/forward lives in ./desktop-vm). The guest runs
// tart `--no-graphics` (no host window) but the base image's autologin still
// reaches a real Aqua session, so the GUI renders and `screencapture` films it.
// We come up the SAME way desktop-packaged does — start the bundled daemon, then
// launch the app so it ATTACHES (no sidecar spawn → no first-run consent modal).
// The app must be launched INTO the Aqua session (`launchctl asuser`); a plain
// SSH spawn lands in a non-GUI session.
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { join } from "node:path";

import { pushDirAsTar } from "../src/vm/desktop";
import { tartVm } from "../src/vm/tart";
import {
attachOrProvision,
CDP_GUEST_PORT,
waitGuestHttp,
waitGuestPageTarget,
type ProvisionedGuest,
} from "./desktop-vm";

const DAEMON_PORT = 4789;
const GUEST_DIR = "/Users/admin/exe";
const GUEST_HOME = "/Users/admin/exe-home";

const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url));
const hostBundle = () => {
const app = join(appDir, "dist", "mac-arm64", "Executor.app");
return {
app,
exe: join(app, "Contents/MacOS/Executor"),
executor: join(app, "Contents/Resources/executor/executor"),
};
};

/** Build the packaged mac bundle if it isn't on disk (slow; reuse an existing
* dist/ while iterating). Mirrors desktop-packaged.globalsetup. */
const ensureBundle = (): void => {
if (existsSync(hostBundle().app)) return;
const run = (cmd: string, args: string[]) =>
execFileSync(cmd, args, { cwd: appDir, stdio: "inherit", env: { ...process.env } });
run("bun", ["./scripts/build-sidecar.ts"]);
run("bunx", ["--bun", "electron-vite", "build"]);
execFileSync(
"bunx",
["--bun", "electron-builder", "--config", "electron-builder.e2e.config.ts", "--mac"],
{
cwd: appDir,
stdio: "inherit",
env: { ...process.env, CSC_IDENTITY_AUTO_DISCOVERY: "false" },
},
);
};

const provisionMac = async (): Promise<ProvisionedGuest> => {
ensureBundle();
const { exe, executor } = hostBundle();
const vm = await tartVm("macos", "arm64").provision();
try {
// Push the bundle (tar-stream, robust over the just-booted link) and clear
// the scp quarantine so it can run.
await vm.ssh(`rm -rf ${GUEST_DIR} ${GUEST_HOME} && mkdir -p ${GUEST_HOME}/.executor`);
await pushDirAsTar(vm.host, hostBundle().app, GUEST_DIR);
await vm.ssh(`xattr -dr com.apple.quarantine ${GUEST_DIR} 2>/dev/null || true`);
// The e2e build is unsigned; an arm64 app needs at least an ad-hoc signature
// to execute, and the host build's signature isn't trusted on another Mac.
await vm.ssh(
`codesign --force --deep --sign - ${GUEST_DIR}/Executor.app 2>&1 | tail -2 || true`,
);

const guestExe = `${GUEST_DIR}/Executor.app/${exe.split("/Executor.app/")[1]}`;
const guestExecutor = `${GUEST_DIR}/Executor.app/${executor.split("/Executor.app/")[1]}`;
const env = `HOME=${GUEST_HOME} EXECUTOR_DATA_DIR=${GUEST_HOME}/.executor`;

// 1) the bundled daemon, supervised — the app attaches to this.
await vm.ssh(
`nohup env ${env} EXECUTOR_SUPERVISED=1 EXECUTOR_AUTH_TOKEN=desktop-macos-e2e EXECUTOR_CLIENT=desktop ` +
`'${guestExecutor}' daemon run --foreground --port ${DAEMON_PORT} --hostname 127.0.0.1 ` +
`>/tmp/executor-daemon.log 2>&1 &`,
);
if (!(await waitGuestHttp(vm, `http://127.0.0.1:${DAEMON_PORT}/`))) {
throw new Error(
"supervised daemon never came up in the guest (see /tmp/executor-daemon.log)",
);
}

// 2) the packaged app, launched INTO the Aqua session with CDP enabled.
await vm.ssh(
`U=$(id -u); sudo launchctl asuser $U bash -lc ` +
`'nohup env HOME=${GUEST_HOME} "${guestExe}" --remote-debugging-port=${CDP_GUEST_PORT} --remote-allow-origins="*" ` +
`>/tmp/executor-app.log 2>&1 &'`,
);
if (!(await waitGuestPageTarget(vm, CDP_GUEST_PORT))) {
const log = (await vm.ssh("tail -40 /tmp/executor-app.log 2>/dev/null").catch(() => null))
?.stdout;
throw new Error(`the app's CDP page target never appeared:\n${log ?? "(no app log)"}`);
}

return { ip: vm.host, teardown: async () => void (await vm.discard()) };
} catch (error) {
await vm.discard();
throw error;
}
};

export default (): Promise<(() => Promise<void>) | void> => attachOrProvision(provisionMac);
Loading
Loading