Skip to content

Commit 259e9d0

Browse files
committed
e2e: cross-OS packaged-desktop targets (macOS/Linux/Windows), filmed
Run the real electron-builder desktop bundle inside a guest VM on macOS, Linux, and Windows, driven over a CDP tunnel and filmed. One shared scenario (desktop-vm/console-renders.test.ts) and driver (src/vm/desktop.ts); each target lands test.ts + session.mp4 + step screenshots in runs/<target>/. Only launch and capture differ per OS: macOS: autologin Aqua session, launchctl asuser, screencapture linux: Xvfb + openbox, xdotool window resize, ffmpeg x11grab windows: dockur (QEMU) interactive session, QEMU screendump macOS and Linux auto-provision a tart guest and build the bundle locally (the executor binary cross-compiles via BUN_TARGET); Windows attaches to a dockur host via E2E_DESKTOP_WIN_* env. Not in the default test chain; skips honestly without a guest, like desktop-packaged skips without a display. Also: - Force tart SSH to password-only (PubkeyAuthentication=no, IdentitiesOnly=yes) so a loaded SSH agent does not exhaust the guest's MaxAuthTries, an intermittent failure the existing cli-{os} lanes also hit. - build-sidecar keys the executable-bit chmod on the build target, not the host, so a windows-target cross-build no longer ENOENTs on a unix executor binary.
1 parent eb9ed89 commit 259e9d0

12 files changed

Lines changed: 926 additions & 3 deletions

apps/desktop/scripts/build-sidecar.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,11 @@ await rm(EXECUTOR_OUT_DIR, { recursive: true, force: true });
4545
await mkdir(EXECUTOR_OUT_DIR, { recursive: true });
4646
await cp(sourceBinDir, EXECUTOR_OUT_DIR, { recursive: true });
4747

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

e2e/AGENTS.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,48 @@ When handing results to the user, follow the evidence contract in the root
130130
[AGENTS.md](../AGENTS.md) (direct run links + a live instance + what to try);
131131
[RUNNING.md](../RUNNING.md) has the current sharing/demo mechanics.
132132

133+
## Desktop targets (the app on real OSes, filmed)
134+
135+
The packaged desktop app runs as its own targets, each landing in its own
136+
`runs/<target>/` bucket with a video. One shared scenario (`desktop-vm/`) and the
137+
shared driver (`src/vm/desktop.ts`) + setup plumbing (`setup/desktop-vm.ts`); one
138+
project + globalsetup per guest OS.
139+
140+
- **`desktop-packaged`** — the real electron-builder bundle on THIS machine's
141+
display (the supervised-daemon attach path). Needs a logged-in GUI session.
142+
- **`desktop-macos` / `desktop-linux`** — the same bundle inside a guest VM,
143+
driven over CDP from the host and filmed. The globalsetup boots the guest
144+
(tart), builds + pushes the bundle, brings the app up with
145+
`--remote-debugging-port`, forwards it, and the scenario connects + drives +
146+
records. Provisioned automatically — or attach to a running guest with
147+
`E2E_DESKTOP_VM_IP=<ip>`:
148+
149+
```sh
150+
vitest run --project desktop-macos # or desktop-linux
151+
```
152+
153+
The guests run tart `--no-graphics` (no host window, never steals focus) but
154+
still have a usable display:
155+
156+
- **macOS**: the base image's autologin reaches a real Aqua session
157+
(WindowServer/Dock/Finder). Launch the app INTO it with `sudo launchctl asuser
158+
<uid> …` (a plain SSH spawn lands in a non-GUI session); the unsigned arm64
159+
bundle is ad-hoc `codesign`'d in the guest; `screencapture` films it.
160+
- **linux**: no window server, so the app renders into an `Xvfb` display with a
161+
minimal WM (`openbox` — without it the electron window never maps); the window
162+
maps tiny (10x10) so the globalsetup `xdotool`-resizes it to fill, and ffmpeg
163+
`x11grab` films it. `--no-sandbox` (the chrome-sandbox needs setuid root).
164+
165+
Base images (`admin`/`admin`): `executor-macos-base` (cirruslabs sequoia, autologin)
166+
and `executor-linux-base` (cirruslabs ubuntu + Xvfb/ffmpeg/openbox/xdotool +
167+
electron runtime libs). The bundle's `executor` binary is cross-compiled for the
168+
guest (`BUN_TARGET`), and electron-builder's `dir` target assembles the unpacked
169+
app on macOS — so both bundles build on this Mac.
170+
171+
Note: `desktop-packaged`'s `guiAvailable()` probe (`launchctl managername`) reads
172+
"Background" over SSH even when Aqua is up, so it's host-only; the VM targets gate
173+
on a CDP page target instead.
174+
133175
## Discovering endpoints
134176

135177
- The full OpenAPI spec: `curl http://127.0.0.1:<cloud port>/api/openapi.json`
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// The PACKAGED desktop app, on camera, inside a GUI guest — driven over CDP from
2+
// the host. ONE scenario shared by every desktop-<os> project (desktop-macos,
3+
// desktop-linux): the same bundle and CDP driver, proving it renders on a guest
4+
// OS and filming the actual console. The desktop-<os> globalsetup boots the
5+
// guest, launches the app, forwards its --remote-debugging-port (E2E_DESKTOP_CDP_PORT)
6+
// and publishes the guest IP; this scenario connects, drives, and records. The
7+
// run lands in runs/<target>/ (its own per-OS bucket). Without a guest it skips
8+
// honestly, like desktop-packaged without a display.
9+
import { writeFileSync } from "node:fs";
10+
import { join } from "node:path";
11+
12+
import { expect, it } from "@effect/vitest";
13+
import { Effect } from "effect";
14+
15+
import { scenario } from "../src/scenario";
16+
import { RunDir } from "../src/services";
17+
import { CdpPage, pageWsUrl, recordGuestScreen } from "../src/vm/desktop";
18+
19+
const NAME = "Desktop (packaged, in a VM) · the bundle renders its console";
20+
const cdpPort = process.env.E2E_DESKTOP_CDP_PORT;
21+
const guestIp = process.env.E2E_DESKTOP_VM_IP;
22+
const recSeconds = Number(process.env.E2E_DESKTOP_REC_SECONDS ?? "12");
23+
const os: "macos" | "linux" | "windows" =
24+
process.env.E2E_TARGET === "desktop-windows"
25+
? "windows"
26+
: process.env.E2E_TARGET === "desktop-linux"
27+
? "linux"
28+
: "macos";
29+
30+
const run = async (runDir: string) => {
31+
const cdp = await CdpPage.connect(await pageWsUrl(Number(cdpPort)));
32+
try {
33+
await cdp.command("Runtime.enable");
34+
await cdp.command("Page.enable");
35+
36+
// Film the console while we drive it (OS-aware capture lands a playable mp4).
37+
const recording = recordGuestScreen(
38+
guestIp as string,
39+
recSeconds,
40+
join(runDir, "session.mp4"),
41+
os,
42+
);
43+
44+
// Reaching the nav proves the packaged bundle booted and connected to its
45+
// daemon on this OS.
46+
await cdp.waitForText("Integrations", 60_000).catch(() => cdp.waitForText("Settings", 60_000));
47+
writeFileSync(join(runDir, "01-console-rendered.png"), await cdp.screenshot());
48+
49+
const body = await cdp.command<{ result?: { value?: string } }>("Runtime.evaluate", {
50+
expression: "document.body.innerText",
51+
returnByValue: true,
52+
});
53+
expect(body.result?.value ?? "", "the packaged console rendered its nav").toContain(
54+
"Integrations",
55+
);
56+
57+
await recording;
58+
} finally {
59+
cdp.close();
60+
}
61+
};
62+
63+
if (!cdpPort || !guestIp) {
64+
it.skip(`${NAME} (needs a desktop guest — set E2E_DESKTOP_VM_IP or run the desktop-<os> project)`, () => {});
65+
} else {
66+
// Literal name (not NAME) so the run's test.ts review artifact captures it.
67+
scenario(
68+
"Desktop (packaged, in a VM) · the bundle renders its console",
69+
{ timeout: 180_000 },
70+
Effect.gen(function* () {
71+
const runDir = yield* RunDir;
72+
yield* Effect.promise(() => run(runDir));
73+
}),
74+
);
75+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// desktop-linux: bring the PACKAGED app up inside a Linux guest and forward its
2+
// CDP port (the shared attach/forward lives in ./desktop-vm). No window server,
3+
// so the app renders into an Xvfb virtual display; ffmpeg x11grab (in the
4+
// scenario's recorder) films that display. Simpler than macOS: no Aqua, no
5+
// codesign, no launchctl — just background processes with DISPLAY set and
6+
// --no-sandbox (the chrome-sandbox needs setuid root, pointless on a throwaway
7+
// guest). The base image (executor-linux-base) carries Xvfb + ffmpeg + the
8+
// electron runtime libs.
9+
import { execFileSync } from "node:child_process";
10+
import { existsSync } from "node:fs";
11+
import { fileURLToPath } from "node:url";
12+
import { basename, join } from "node:path";
13+
14+
import { pushDirAsTar } from "../src/vm/desktop";
15+
import { tartVm } from "../src/vm/tart";
16+
import {
17+
attachOrProvision,
18+
CDP_GUEST_PORT,
19+
waitGuestHttp,
20+
waitGuestPageTarget,
21+
type ProvisionedGuest,
22+
} from "./desktop-vm";
23+
24+
const DAEMON_PORT = 4789;
25+
const GUEST_DIR = "/home/admin/exe";
26+
const GUEST_HOME = "/home/admin/exe-home";
27+
const DISPLAY = ":99";
28+
29+
const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url));
30+
const hostBundle = () => {
31+
// electron-builder names the dir `linux-<arch>-unpacked` for non-x64.
32+
const dir = join(appDir, "dist", "linux-arm64-unpacked");
33+
return {
34+
dir,
35+
exe: join(dir, "executor-desktop"),
36+
executor: join(dir, "resources/executor/executor"),
37+
};
38+
};
39+
40+
/** Build the packaged linux-arm64 bundle if it isn't on disk. The `executor`
41+
* binary is cross-compiled here via BUN_TARGET (same as the cli-linux lane);
42+
* electron-builder's `dir` target assembles the unpacked app on macOS without
43+
* Docker. */
44+
const ensureBundle = (): void => {
45+
if (existsSync(hostBundle().dir)) return;
46+
const run = (cmd: string, args: string[], env: Record<string, string> = {}) =>
47+
execFileSync(cmd, args, { cwd: appDir, stdio: "inherit", env: { ...process.env, ...env } });
48+
run("bun", ["./scripts/build-sidecar.ts"], { BUN_TARGET: "bun-linux-arm64" });
49+
run("bunx", ["--bun", "electron-vite", "build"]);
50+
run(
51+
"bunx",
52+
[
53+
"--bun",
54+
"electron-builder",
55+
"--config",
56+
"electron-builder.e2e.config.ts",
57+
"--linux",
58+
"--arm64",
59+
],
60+
{ CSC_IDENTITY_AUTO_DISCOVERY: "false" },
61+
);
62+
};
63+
64+
const provisionLinux = async (): Promise<ProvisionedGuest> => {
65+
ensureBundle();
66+
const { dir } = hostBundle();
67+
const vm = await tartVm("linux", "arm64").provision();
68+
try {
69+
await vm.ssh(`rm -rf ${GUEST_DIR} ${GUEST_HOME}; mkdir -p ${GUEST_HOME}/.executor`);
70+
await pushDirAsTar(vm.host, dir, GUEST_DIR);
71+
72+
const guestApp = `${GUEST_DIR}/${basename(dir)}`;
73+
const guestExe = `${guestApp}/executor-desktop`;
74+
const guestExecutor = `${guestApp}/resources/executor/executor`;
75+
await vm.ssh(`chmod +x '${guestExe}' '${guestExecutor}' 2>/dev/null || true`);
76+
const env = `HOME=${GUEST_HOME} EXECUTOR_DATA_DIR=${GUEST_HOME}/.executor`;
77+
78+
// A virtual display + a minimal WM (openbox) — without a window manager the
79+
// electron window doesn't map onto the framebuffer that x11grab records.
80+
await vm.ssh(
81+
`pkill Xvfb 2>/dev/null; pkill openbox 2>/dev/null; ` +
82+
`nohup Xvfb ${DISPLAY} -screen 0 1280x800x24 >/tmp/xvfb.log 2>&1 & sleep 2; ` +
83+
`DISPLAY=${DISPLAY} nohup openbox >/tmp/openbox.log 2>&1 & sleep 1; echo up`,
84+
);
85+
86+
// 1) the bundled daemon, supervised — the app attaches to this.
87+
await vm.ssh(
88+
`nohup env ${env} EXECUTOR_SUPERVISED=1 EXECUTOR_AUTH_TOKEN=desktop-linux-e2e EXECUTOR_CLIENT=desktop ` +
89+
`'${guestExecutor}' daemon run --foreground --port ${DAEMON_PORT} --hostname 127.0.0.1 ` +
90+
`>/tmp/executor-daemon.log 2>&1 &`,
91+
);
92+
if (!(await waitGuestHttp(vm, `http://127.0.0.1:${DAEMON_PORT}/`))) {
93+
throw new Error(
94+
"supervised daemon never came up in the guest (see /tmp/executor-daemon.log)",
95+
);
96+
}
97+
98+
// 2) the packaged app on the virtual display, with CDP enabled.
99+
await vm.ssh(
100+
`nohup env ${env} DISPLAY=${DISPLAY} '${guestExe}' --no-sandbox ` +
101+
`--remote-debugging-port=${CDP_GUEST_PORT} --remote-allow-origins='*' ` +
102+
`>/tmp/executor-app.log 2>&1 &`,
103+
);
104+
if (!(await waitGuestPageTarget(vm, CDP_GUEST_PORT))) {
105+
const log = (await vm.ssh("tail -40 /tmp/executor-app.log 2>/dev/null").catch(() => null))
106+
?.stdout;
107+
throw new Error(`the app's CDP page target never appeared:\n${log ?? "(no app log)"}`);
108+
}
109+
110+
// The electron window maps tiny (10x10) under Xvfb; size it to the screen so
111+
// the x11grab recording captures the full console (CDP screenshots the
112+
// renderer surface regardless, but the film grabs the X framebuffer).
113+
await vm.ssh(
114+
`WID=$(DISPLAY=${DISPLAY} xdotool search --name executor-desktop | head -1); ` +
115+
`[ -n "$WID" ] && DISPLAY=${DISPLAY} xdotool windowsize "$WID" 1280 800 windowmove "$WID" 0 0 || true`,
116+
);
117+
118+
return { ip: vm.host, teardown: async () => void (await vm.discard()) };
119+
} catch (error) {
120+
await vm.discard();
121+
throw error;
122+
}
123+
};
124+
125+
export default (): Promise<(() => Promise<void>) | void> => attachOrProvision(provisionLinux);
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// desktop-macos: bring the PACKAGED app up inside a macOS GUI guest and forward
2+
// its CDP port (the shared attach/forward lives in ./desktop-vm). The guest runs
3+
// tart `--no-graphics` (no host window) but the base image's autologin still
4+
// reaches a real Aqua session, so the GUI renders and `screencapture` films it.
5+
// We come up the SAME way desktop-packaged does — start the bundled daemon, then
6+
// launch the app so it ATTACHES (no sidecar spawn → no first-run consent modal).
7+
// The app must be launched INTO the Aqua session (`launchctl asuser`); a plain
8+
// SSH spawn lands in a non-GUI session.
9+
import { execFileSync } from "node:child_process";
10+
import { existsSync } from "node:fs";
11+
import { fileURLToPath } from "node:url";
12+
import { join } from "node:path";
13+
14+
import { pushDirAsTar } from "../src/vm/desktop";
15+
import { tartVm } from "../src/vm/tart";
16+
import {
17+
attachOrProvision,
18+
CDP_GUEST_PORT,
19+
waitGuestHttp,
20+
waitGuestPageTarget,
21+
type ProvisionedGuest,
22+
} from "./desktop-vm";
23+
24+
const DAEMON_PORT = 4789;
25+
const GUEST_DIR = "/Users/admin/exe";
26+
const GUEST_HOME = "/Users/admin/exe-home";
27+
28+
const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url));
29+
const hostBundle = () => {
30+
const app = join(appDir, "dist", "mac-arm64", "Executor.app");
31+
return {
32+
app,
33+
exe: join(app, "Contents/MacOS/Executor"),
34+
executor: join(app, "Contents/Resources/executor/executor"),
35+
};
36+
};
37+
38+
/** Build the packaged mac bundle if it isn't on disk (slow; reuse an existing
39+
* dist/ while iterating). Mirrors desktop-packaged.globalsetup. */
40+
const ensureBundle = (): void => {
41+
if (existsSync(hostBundle().app)) return;
42+
const run = (cmd: string, args: string[]) =>
43+
execFileSync(cmd, args, { cwd: appDir, stdio: "inherit", env: { ...process.env } });
44+
run("bun", ["./scripts/build-sidecar.ts"]);
45+
run("bunx", ["--bun", "electron-vite", "build"]);
46+
execFileSync(
47+
"bunx",
48+
["--bun", "electron-builder", "--config", "electron-builder.e2e.config.ts", "--mac"],
49+
{
50+
cwd: appDir,
51+
stdio: "inherit",
52+
env: { ...process.env, CSC_IDENTITY_AUTO_DISCOVERY: "false" },
53+
},
54+
);
55+
};
56+
57+
const provisionMac = async (): Promise<ProvisionedGuest> => {
58+
ensureBundle();
59+
const { exe, executor } = hostBundle();
60+
const vm = await tartVm("macos", "arm64").provision();
61+
try {
62+
// Push the bundle (tar-stream, robust over the just-booted link) and clear
63+
// the scp quarantine so it can run.
64+
await vm.ssh(`rm -rf ${GUEST_DIR} ${GUEST_HOME} && mkdir -p ${GUEST_HOME}/.executor`);
65+
await pushDirAsTar(vm.host, hostBundle().app, GUEST_DIR);
66+
await vm.ssh(`xattr -dr com.apple.quarantine ${GUEST_DIR} 2>/dev/null || true`);
67+
// The e2e build is unsigned; an arm64 app needs at least an ad-hoc signature
68+
// to execute, and the host build's signature isn't trusted on another Mac.
69+
await vm.ssh(
70+
`codesign --force --deep --sign - ${GUEST_DIR}/Executor.app 2>&1 | tail -2 || true`,
71+
);
72+
73+
const guestExe = `${GUEST_DIR}/Executor.app/${exe.split("/Executor.app/")[1]}`;
74+
const guestExecutor = `${GUEST_DIR}/Executor.app/${executor.split("/Executor.app/")[1]}`;
75+
const env = `HOME=${GUEST_HOME} EXECUTOR_DATA_DIR=${GUEST_HOME}/.executor`;
76+
77+
// 1) the bundled daemon, supervised — the app attaches to this.
78+
await vm.ssh(
79+
`nohup env ${env} EXECUTOR_SUPERVISED=1 EXECUTOR_AUTH_TOKEN=desktop-macos-e2e EXECUTOR_CLIENT=desktop ` +
80+
`'${guestExecutor}' daemon run --foreground --port ${DAEMON_PORT} --hostname 127.0.0.1 ` +
81+
`>/tmp/executor-daemon.log 2>&1 &`,
82+
);
83+
if (!(await waitGuestHttp(vm, `http://127.0.0.1:${DAEMON_PORT}/`))) {
84+
throw new Error(
85+
"supervised daemon never came up in the guest (see /tmp/executor-daemon.log)",
86+
);
87+
}
88+
89+
// 2) the packaged app, launched INTO the Aqua session with CDP enabled.
90+
await vm.ssh(
91+
`U=$(id -u); sudo launchctl asuser $U bash -lc ` +
92+
`'nohup env HOME=${GUEST_HOME} "${guestExe}" --remote-debugging-port=${CDP_GUEST_PORT} --remote-allow-origins="*" ` +
93+
`>/tmp/executor-app.log 2>&1 &'`,
94+
);
95+
if (!(await waitGuestPageTarget(vm, CDP_GUEST_PORT))) {
96+
const log = (await vm.ssh("tail -40 /tmp/executor-app.log 2>/dev/null").catch(() => null))
97+
?.stdout;
98+
throw new Error(`the app's CDP page target never appeared:\n${log ?? "(no app log)"}`);
99+
}
100+
101+
return { ip: vm.host, teardown: async () => void (await vm.discard()) };
102+
} catch (error) {
103+
await vm.discard();
104+
throw error;
105+
}
106+
};
107+
108+
export default (): Promise<(() => Promise<void>) | void> => attachOrProvision(provisionMac);

0 commit comments

Comments
 (0)