From 671759938e0af169ffa79093cea6866423862d84 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Sun, 14 Jun 2026 09:44:55 -0700 Subject: [PATCH] =?UTF-8?q?e2e:=20packaged-desktop=20target=20=E2=80=94=20?= =?UTF-8?q?the=20real=20bundle=20attaches=20to=20the=20supervised=20daemon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The supervised-daemon attach path (ensureSupervisedConnection → attachToSupervisedDaemon) and the bundled compiled sidecar only run when app.isPackaged is true. Dev electron skips that branch and always spawns its own desktop-sidecar, so the attach behaviour can't be proven against a dev launch — the prior dev-electron attach test could never satisfy its own assertion. Test the production artifact instead. - electron-builder.e2e.config.ts: unsigned bundle (no notarize/hardenedRuntime, `dir` target) so the real package builds without Apple credentials; the release config is untouched. - desktop-packaged vitest project + globalsetup: builds web UI → compiled sidecar → electron-vite → electron-builder, then publishes the launch exe and bundled sidecar path. - desktop-packaged/supervised-attach.test.ts: starts the bundle's own compiled executor-sidecar as the supervised daemon, launches the packaged app, and asserts it attached (manifest still names the daemon's pid/kind, console renders through the bearer-gated daemon) rather than spawning a sidecar. Skips honestly where no GUI display is reachable instead of hanging on launch. - Remove the superseded dev-electron attach test; fix a stale "Basic-auth" comment (the header is bearer). --- apps/desktop/electron-builder.e2e.config.ts | 45 ++++ apps/desktop/src/main/index.ts | 2 +- .../supervised-attach.test.ts | 214 ++++++++++++++++++ e2e/desktop/supervised-attach.test.ts | 182 --------------- e2e/setup/desktop-packaged.globalsetup.ts | 84 +++++++ e2e/targets/registry.ts | 3 + e2e/vitest.config.ts | 13 ++ 7 files changed, 360 insertions(+), 183 deletions(-) create mode 100644 apps/desktop/electron-builder.e2e.config.ts create mode 100644 e2e/desktop-packaged/supervised-attach.test.ts delete mode 100644 e2e/desktop/supervised-attach.test.ts create mode 100644 e2e/setup/desktop-packaged.globalsetup.ts diff --git a/apps/desktop/electron-builder.e2e.config.ts b/apps/desktop/electron-builder.e2e.config.ts new file mode 100644 index 000000000..b99f66a04 --- /dev/null +++ b/apps/desktop/electron-builder.e2e.config.ts @@ -0,0 +1,45 @@ +// Unsigned packaging config for e2e: produces the SAME app bundle as the +// release config (same extraResources sidecar binary + web-ui, same main/ +// preload `out/`), but skips Apple signing/notarization so it builds with no +// CSC_LINK / APPLE_API_KEY. The e2e drives the resulting bundle through +// Playwright `_electron`; in a VM, Gatekeeper is bypassed (the app is +// quarantine-cleared) so the unsigned bundle launches. +// +// Used via `electron-builder --config electron-builder.e2e.config.ts`. The +// release path (publish-desktop.yml) still uses electron-builder.config.ts — +// this override never touches production signing. +import base from "./electron-builder.config"; + +import type { Configuration } from "electron-builder"; + +const config: Configuration = { + ...base, + mac: { + ...base.mac, + // No Apple Developer credentials in e2e — produce an unsigned bundle. + hardenedRuntime: false, + gatekeeperAssess: false, + notarize: false, + identity: null, + // `dir` = the unpacked .app (the real bundle electron-builder assembles, + // with extraResources), without the slow DMG/zip wrap. _electron launches + // the .app directly, so the distribution container adds nothing here. + target: ["dir"], + }, + win: { + ...base.win, + target: ["dir"], + }, + linux: { + ...base.linux, + // Pin a clean executable name — electron-builder otherwise derives it from + // the scoped package name (`@executor-jsdesktop`), which the e2e globalsetup + // would have to special-case. + executableName: "executor-desktop", + target: ["dir"], + }, + // The release config publishes to GitHub; an e2e build must never try to. + publish: null, +}; + +export default config; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 249eba6d2..1c9a785bd 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -768,7 +768,7 @@ const boot = async () => { const supervised = await ensureSupervisedConnection(); if (supervised) { connection = supervised; - await createWindow(supervised); // installs the Basic-auth header itself + await createWindow(supervised); // installs the bearer-auth header itself armSupervisedMonitor(); void runUpdateCheck({ alertOnFail: false }); return; diff --git a/e2e/desktop-packaged/supervised-attach.test.ts b/e2e/desktop-packaged/supervised-attach.test.ts new file mode 100644 index 000000000..bee105846 --- /dev/null +++ b/e2e/desktop-packaged/supervised-attach.test.ts @@ -0,0 +1,214 @@ +// Packaged desktop, on camera: the REAL electron-builder bundle (app.isPackaged +// === true) attaches to an already-running OS-supervised daemon instead of +// spawning its own sidecar. This is the production-only path — dev electron skips +// ensureSupervisedConnection entirely and always spawns a desktop-sidecar, so the +// attach behavior can ONLY be proven against the packaged artifact. +// +// We start the daemon as the bundle's OWN compiled `executor-sidecar` (the exact +// binary a supervised install runs) in EXECUTOR_SUPERVISED mode → it self- +// publishes a manifest of kind "cli-daemon". Then we launch the packaged app +// pointed at the same HOME and prove it attached: the manifest still names the +// daemon's pid (a spawned sidecar would rewrite it to "desktop-sidecar" with a +// fresh pid), and the console — served by the bearer-gated daemon — renders, +// which only happens if the app injected the bearer it read from the manifest. +// The recording (session.mp4 + screenshots) is the artifact; the waits assert. +import { type ChildProcess, execFile, execFileSync, spawn } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import net from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; + +import { expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { _electron } from "playwright"; + +import { scenario } from "../src/scenario"; +import { RunDir } from "../src/services"; +import { waitForHttp } from "../setup/boot"; + +// Driving the packaged Electron app needs a real window-server session: Aqua on +// macOS, an X/Wayland display on Linux. An SSH/CI shell runs in the background +// (non-GUI) session where Electron can't open a window — so this scenario runs +// only where a display is reachable (a logged-in console, or a guest under +// autologin/Xvfb) and skips honestly elsewhere rather than hanging on launch. +const guiAvailable = (): boolean => { + if (process.platform === "darwin") { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: probing the session manager; absence = no GUI + try { + return execFileSync("launchctl", ["managername"], { encoding: "utf8" }).trim() === "Aqua"; + } catch { + return false; + } + } + if (process.platform === "linux") { + return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY); + } + return true; // windows: the runner places this in an interactive session +}; + +const SCENARIO_NAME = "Desktop (packaged) · the real bundle attaches to the OS-supervised daemon"; + +const appExe = process.env.E2E_DESKTOP_APP_EXE; +const sidecarBin = process.env.E2E_DESKTOP_SIDECAR_BIN; +// The bundled web UI sits beside the sidecar in Resources/ (…/sidecar/ → +// …/web-ui). The compiled sidecar serves it via EXECUTOR_CLIENT_DIR. +const clientDir = sidecarBin ? join(dirname(dirname(sidecarBin)), "web-ui") : ""; + +const freePort = (): Promise => + new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.on("error", reject); + srv.listen(0, "127.0.0.1", () => { + const port = (srv.address() as net.AddressInfo).port; + srv.close(() => resolve(port)); + }); + }); + +interface Manifest { + readonly kind: string; + readonly pid: number; +} + +interface DaemonStart { + readonly child: ChildProcess; + readonly ready: boolean; + readonly stderr: string; +} + +/** Spawn the bundle's compiled sidecar as a supervised daemon; resolves once it + * announces EXECUTOR_READY (or times out / exits early, ready:false). */ +const startSupervisedDaemon = (env: NodeJS.ProcessEnv): Promise => + new Promise((resolve) => { + const child = spawn(sidecarBin as string, [], { env, stdio: ["ignore", "pipe", "pipe"] }); + let stderr = ""; + const settle = (ready: boolean) => resolve({ child, ready, stderr }); + const timer = setTimeout(() => settle(false), 60_000); + child.stdout.on("data", (chunk: Buffer) => { + if (chunk.toString().includes("EXECUTOR_READY:")) { + clearTimeout(timer); + settle(true); + } + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on("exit", () => { + clearTimeout(timer); + settle(false); + }); + }); + +if (!guiAvailable()) { + it.skip(`${SCENARIO_NAME} (needs a GUI display — Aqua / X / Wayland)`, () => {}); +} else { + scenario( + SCENARIO_NAME, + { timeout: 240_000 }, + Effect.gen(function* () { + if (!appExe || !sidecarBin) { + return yield* Effect.die( + "E2E_DESKTOP_APP_EXE / E2E_DESKTOP_SIDECAR_BIN not set — did desktop-packaged.globalsetup run?", + ); + } + const runDir = yield* RunDir; + yield* Effect.promise(() => run(runDir)); + }), + ); +} + +const run = async (runDir: string) => { + const home = mkdtempSync(join(tmpdir(), "executor-pkg-attach-")); + const dataDir = join(home, ".executor"); + const manifestPath = join(dataDir, "server-control", "server.json"); + const videoTmp = join(runDir, ".video-tmp"); + const port = await freePort(); + + let daemon: ChildProcess | undefined; + let app: Awaited> | undefined; + let stepIndex = 0; + + try { + const started = await startSupervisedDaemon({ + ...process.env, + HOME: home, + EXECUTOR_SUPERVISED: "1", + EXECUTOR_DATA_DIR: dataDir, + EXECUTOR_PORT: String(port), + EXECUTOR_HOST: "127.0.0.1", + EXECUTOR_AUTH_TOKEN: "packaged-attach-film", + EXECUTOR_CLIENT_DIR: clientDir, + }); + daemon = started.child; + expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(true); + await waitForHttp(`http://127.0.0.1:${port}/`, { timeoutMs: 30_000 }); + + const daemonManifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Manifest; + expect(daemonManifest.kind, "the compiled sidecar advertises itself as cli-daemon").toBe( + "cli-daemon", + ); + const daemonPid = daemonManifest.pid; + + // Launch the PACKAGED bundle (executablePath = the installed app binary, no + // app-dir arg) → app.isPackaged is true → boot() runs the supervised attach. + app = await _electron.launch({ + executablePath: appExe as string, + env: { ...process.env, HOME: home }, + recordVideo: { dir: videoTmp, size: { width: 1280, height: 800 } }, + timeout: 120_000, + }); + + const page = await app.firstWindow({ timeout: 120_000 }); + const step = async (label: string, body: () => Promise) => { + await body(); + stepIndex += 1; + const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, "-"); + await page.screenshot({ + path: join(runDir, `${String(stepIndex).padStart(2, "0")}-${slug}.png`), + }); + }; + + // The console only renders once the app has a live connection AND the bearer + // it injects is accepted by the gated daemon — so reaching it proves both the + // attach and the bearer wiring through the packaged session layer. + await step("packaged app boots into the bearer-gated console", async () => { + await page.getByText("Settings").first().waitFor({ timeout: 120_000 }); + }); + + // Proof it ATTACHED, not spawned: the manifest is untouched — same pid, still + // cli-daemon. A managed sidecar would have rewritten it to "desktop-sidecar". + await step("server manifest still names the supervised daemon", async () => { + const after = JSON.parse(readFileSync(manifestPath, "utf8")) as Manifest; + expect(after.kind, "still the supervised daemon (not a desktop sidecar)").toBe("cli-daemon"); + expect(after.pid, "the packaged app attached to our daemon, not a new sidecar").toBe( + daemonPid, + ); + }); + } finally { + const page = app?.windows()[0]; + const video = page?.video(); + await app?.close().catch(() => {}); + const recordedPath = await video?.path().catch(() => undefined); + if (recordedPath && existsSync(recordedPath)) { + await promisify(execFile)("ffmpeg", [ + "-y", + "-i", + recordedPath, + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "26", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + join(runDir, "session.mp4"), + ]).catch(() => {}); + } + daemon?.kill("SIGTERM"); + rmSync(videoTmp, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } +}; diff --git a/e2e/desktop/supervised-attach.test.ts b/e2e/desktop/supervised-attach.test.ts deleted file mode 100644 index ce8eef41f..000000000 --- a/e2e/desktop/supervised-attach.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -// Desktop-only, on camera: the app ATTACHES to an already-running OS-supervised -// daemon instead of spawning its own sidecar. We start a real supervised gateway -// (the desktop sidecar server in EXECUTOR_SUPERVISED mode → it self-publishes a -// manifest of kind "cli-daemon"), launch the Electron app pointed at the same -// HOME, and prove it attached: the manifest still names OUR daemon's pid (a -// spawned sidecar would be a fresh pid + kind "desktop-sidecar"). The recording -// (session.mp4 + screenshots) is the artifact; the waits are the assertions. No -// launchd — only a throwaway home and one short-lived daemon process. -import { type ChildProcess, execFile, spawn } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { createRequire } from "node:module"; -import net from "node:net"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; - -import { expect } from "@effect/vitest"; -import { Effect } from "effect"; -import { _electron } from "playwright"; - -import { scenario } from "../src/scenario"; -import { RunDir } from "../src/services"; -import { waitForHttp } from "../setup/boot"; - -const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url)); -const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); -const sidecarServer = join(appDir, "src/sidecar/server.ts"); -const clientDir = join(repoRoot, "apps/local/dist"); -const electronBinary = createRequire(join(appDir, "package.json"))("electron") as string; - -const freePort = (): Promise => - new Promise((resolve, reject) => { - const srv = net.createServer(); - srv.on("error", reject); - srv.listen(0, "127.0.0.1", () => { - const port = (srv.address() as net.AddressInfo).port; - srv.close(() => resolve(port)); - }); - }); - -interface Manifest { - readonly kind: string; - readonly pid: number; -} - -interface DaemonStart { - readonly child: ChildProcess; - readonly ready: boolean; - readonly stderr: string; -} - -/** Spawn the supervised gateway; resolves once it announces EXECUTOR_READY (or - * times out / exits early, with `ready: false`). The caller asserts readiness, - * so the executor only ever resolves. */ -const startSupervisedDaemon = (env: NodeJS.ProcessEnv): Promise => - new Promise((resolve) => { - const child = spawn("bun", ["run", sidecarServer], { - cwd: repoRoot, - env, - stdio: ["ignore", "pipe", "pipe"], - }); - let stderr = ""; - const settle = (ready: boolean) => resolve({ child, ready, stderr }); - const timer = setTimeout(() => settle(false), 60_000); - child.stdout.on("data", (chunk: Buffer) => { - if (chunk.toString().includes("EXECUTOR_READY:")) { - clearTimeout(timer); - settle(true); - } - }); - child.stderr.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - child.on("exit", () => { - clearTimeout(timer); - settle(false); - }); - }); - -scenario( - "Desktop · attaches to the OS-supervised daemon instead of spawning a sidecar", - { timeout: 240_000 }, - Effect.gen(function* () { - const runDir = yield* RunDir; - yield* Effect.promise(() => run(runDir)); - }), -); - -const run = async (runDir: string) => { - const home = mkdtempSync(join(tmpdir(), "executor-attach-e2e-")); - const dataDir = join(home, ".executor"); - const manifestPath = join(dataDir, "server-control", "server.json"); - const videoTmp = join(runDir, ".video-tmp"); - const port = await freePort(); - - let daemon: ChildProcess | undefined; - let app: Awaited> | undefined; - let stepIndex = 0; - - try { - const started = await startSupervisedDaemon({ - ...process.env, - HOME: home, - EXECUTOR_SUPERVISED: "1", - EXECUTOR_DATA_DIR: dataDir, - EXECUTOR_PORT: String(port), - EXECUTOR_HOST: "127.0.0.1", - EXECUTOR_AUTH_TOKEN: "supervised-attach-film", - EXECUTOR_CLIENT_DIR: clientDir, - }); - daemon = started.child; - expect(started.ready, `supervised daemon became ready; stderr:\n${started.stderr}`).toBe(true); - await waitForHttp(`http://127.0.0.1:${port}/`, { timeoutMs: 30_000 }); - - const daemonManifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Manifest; - expect(daemonManifest.kind, "the running daemon advertises itself as cli-daemon").toBe( - "cli-daemon", - ); - const daemonPid = daemonManifest.pid; - - app = await _electron.launch({ - executablePath: electronBinary, - args: [appDir], - cwd: appDir, - env: { ...process.env, HOME: home }, - recordVideo: { dir: videoTmp, size: { width: 1280, height: 800 } }, - timeout: 120_000, - }); - - const page = await app.firstWindow({ timeout: 120_000 }); - const step = async (label: string, body: () => Promise) => { - await body(); - stepIndex += 1; - const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, "-"); - await page.screenshot({ - path: join(runDir, `${String(stepIndex).padStart(2, "0")}-${slug}.png`), - }); - }; - - // The window only loads the console once the app has a connection — and it - // attaches to the supervised daemon before it would ever spawn a sidecar. - await step("desktop boots into the console", async () => { - await page.getByText("Settings").first().waitFor({ timeout: 120_000 }); - }); - - // The proof it ATTACHED rather than spawned: the manifest is untouched — - // same pid, still cli-daemon. A managed sidecar would have rewritten it to - // kind "desktop-sidecar" with a fresh child pid. - await step("server manifest still names the supervised daemon", async () => { - const after = JSON.parse(readFileSync(manifestPath, "utf8")) as Manifest; - expect(after.kind, "still the supervised daemon (not a desktop sidecar)").toBe("cli-daemon"); - expect(after.pid, "the desktop attached to our daemon, not a new sidecar").toBe(daemonPid); - }); - } finally { - const page = app?.windows()[0]; - const video = page?.video(); - await app?.close().catch(() => {}); - const recordedPath = await video?.path().catch(() => undefined); - if (recordedPath && existsSync(recordedPath)) { - await promisify(execFile)("ffmpeg", [ - "-y", - "-i", - recordedPath, - "-c:v", - "libx264", - "-preset", - "veryfast", - "-crf", - "26", - "-pix_fmt", - "yuv420p", - "-movflags", - "+faststart", - join(runDir, "session.mp4"), - ]).catch(() => {}); - } - daemon?.kill("SIGTERM"); - rmSync(videoTmp, { recursive: true, force: true }); - rmSync(home, { recursive: true, force: true }); - } -}; diff --git a/e2e/setup/desktop-packaged.globalsetup.ts b/e2e/setup/desktop-packaged.globalsetup.ts new file mode 100644 index 000000000..a7568e44d --- /dev/null +++ b/e2e/setup/desktop-packaged.globalsetup.ts @@ -0,0 +1,84 @@ +// Packaged-desktop project setup: produce the REAL electron-builder bundle (not +// dev electron) so the scenarios drive the production artifact — the only place +// app.isPackaged is true, which is what gates the supervised-daemon attach path +// (ensureSupervisedConnection → attachToSupervisedDaemon) and the bundled +// compiled sidecar (executor-sidecar + extraResources). The dev-electron desktop +// project can't reach any of that. +// +// Builds web UI → compiled sidecar → electron-vite main/preload → electron-builder +// (unsigned e2e config, `dir` target = the unpacked .app/.exe, no DMG/notarize). +// Publishes the launch exe + the bundled sidecar path via env for the workers. +// +// Slow (~3-5min: a full compile + package). Set E2E_DESKTOP_SKIP_BUILD=1 to +// reuse an existing dist/ bundle while iterating. +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; + +const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); +const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url)); + +// (launch exe, bundled sidecar binary) inside the packaged bundle, per platform. +const bundlePaths = (): { exe: string; sidecar: string } => { + const arch = process.arch; // arm64 | x64 + if (process.platform === "darwin") { + const app = join(appDir, "dist", `mac${arch === "arm64" ? "-arm64" : ""}`, "Executor.app"); + return { + exe: join(app, "Contents/MacOS/Executor"), + sidecar: join(app, "Contents/Resources/sidecar/executor-sidecar"), + }; + } + if (process.platform === "win32") { + const dir = join(appDir, "dist", "win-unpacked"); + return { + exe: join(dir, "Executor.exe"), + sidecar: join(dir, "resources/sidecar/executor-sidecar.exe"), + }; + } + // electron-builder names the dir `linux-unpacked` for x64 and + // `linux--unpacked` otherwise; executableName is pinned in the e2e config. + const dir = join(appDir, "dist", arch === "x64" ? "linux-unpacked" : `linux-${arch}-unpacked`); + return { + exe: join(dir, "executor-desktop"), + sidecar: join(dir, "resources/sidecar/executor-sidecar"), + }; +}; + +const builderFlag = + process.platform === "darwin" ? "--mac" : process.platform === "win32" ? "--win" : "--linux"; + +export default function setup() { + const { exe, sidecar } = bundlePaths(); + + if (process.env.E2E_DESKTOP_SKIP_BUILD !== "1" || !existsSync(exe)) { + const run = (cmd: string, args: string[], cwd: string) => + execFileSync(cmd, args, { cwd, stdio: "inherit", env: { ...process.env } }); + // 1. web UI bundle (served by the sidecar; staged into the package). + run("bun", ["run", "--filter", "@executor-js/local", "build"], repoRoot); + // 2. compiled sidecar + native bindings → resources/sidecar. + run("bun", ["./scripts/build-sidecar.ts"], appDir); + // 3. electron-vite main/preload → out/. + run("bunx", ["--bun", "electron-vite", "build"], appDir); + // 4. electron-builder unsigned bundle (dir target). CSC_IDENTITY_AUTO_DISCOVERY + // off so it never reaches for a signing identity. + execFileSync( + "bunx", + ["--bun", "electron-builder", "--config", "electron-builder.e2e.config.ts", builderFlag], + { + cwd: appDir, + stdio: "inherit", + env: { ...process.env, CSC_IDENTITY_AUTO_DISCOVERY: "false" }, + }, + ); + } + + if (!existsSync(exe)) { + throw new Error(`packaged desktop exe not found at ${exe} after build`); + } + if (!existsSync(sidecar)) { + throw new Error(`bundled sidecar not found at ${sidecar} after build`); + } + process.env.E2E_DESKTOP_APP_EXE = exe; + process.env.E2E_DESKTOP_SIDECAR_BIN = sidecar; +} diff --git a/e2e/targets/registry.ts b/e2e/targets/registry.ts index 93e4edc66..94e966746 100644 --- a/e2e/targets/registry.ts +++ b/e2e/targets/registry.ts @@ -16,6 +16,9 @@ const factories: Record Target> = { "selfhost-docker": selfhostDockerTarget, cloudflare: cloudflareTarget, desktop: desktopTarget, + // The packaged desktop bundle launches its own app per scenario, same as + // `desktop` — no standard surfaces to carry. See desktop-packaged.globalsetup. + "desktop-packaged": desktopTarget, local: localTarget, // The supervised CLI daemon inside a VM, one project per guest OS — restart() // is a real reboot. See setup/cli.globalsetup.ts. diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts index 449de4b32..d9e85d463 100644 --- a/e2e/vitest.config.ts +++ b/e2e/vitest.config.ts @@ -56,6 +56,19 @@ export default defineConfig({ fileParallelism: false, testTimeout: 300_000, }), + // The PACKAGED desktop app: the real electron-builder bundle, where + // app.isPackaged is true — the ONLY target that exercises the supervised- + // daemon attach path (ensureSupervisedConnection) and the bundled compiled + // sidecar. Its globalsetup builds the bundle (slow), so it's separate from + // `desktop` to keep the fast dev-electron suite off the package build. + // Needs a display; not part of the default `npm run test` chain — run with + // `vitest run --project desktop-packaged`. + project("desktop-packaged", { + include: ["desktop-packaged/**/*.test.ts"], + fileParallelism: false, + testTimeout: 360_000, + hookTimeout: 600_000, + }), // The single-user local app. Each scenario launches its OWN `executor // web` via the CLI on a throwaway data dir + an OS-assigned port, so // there is no shared instance and scenarios are independent — file