diff --git a/packages/app/e2e/desktop-updates.spec.ts b/packages/app/e2e/desktop-updates.spec.ts new file mode 100644 index 000000000..8198b4988 --- /dev/null +++ b/packages/app/e2e/desktop-updates.spec.ts @@ -0,0 +1,151 @@ +import { test } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { + loadRealDaemonState, + injectDesktopBridge, + openDesktopSettings, + expectUpdateBanner, + clickInstallUpdate, + expectInstallInProgress, + interceptDaemonManagementConfirmDialog, + toggleDaemonManagement, + expectDaemonManagementConfirmDialog, + expectDaemonManagementEnabled, + expectDaemonManagementDisabled, + expectDaemonStatusPid, + expectDaemonStatusLogPath, + expectDaemonStatusVersion, +} from "./helpers/desktop-updates"; + +function getSeededServerId(): string { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); + } + return serverId; +} + +// No Playwright Electron runner exists; we simulate the desktop bridge via +// addInitScript so Electron-gated UI activates without a real Electron process. +test.describe("Desktop updates", () => { + test("update banner appears in the sidebar when an app update is available", async ({ page }) => { + await injectDesktopBridge(page, { + serverId: getSeededServerId(), + updateAvailable: true, + latestVersion: "1.2.3", + }); + await gotoAppShell(page); + + await expectUpdateBanner(page, "1.2.3"); + }); + + test("clicking install shows the installing state on the callout", async ({ page }) => { + await injectDesktopBridge(page, { + serverId: getSeededServerId(), + updateAvailable: true, + latestVersion: "1.2.3", + slowInstall: true, + }); + await gotoAppShell(page); + + await expectUpdateBanner(page, "1.2.3"); + await clickInstallUpdate(page); + await expectInstallInProgress(page); + }); +}); + +test.describe("Desktop daemon management", () => { + test("disabling built-in daemon management shows confirm dialog with correct copy", async ({ + page, + }) => { + const serverId = getSeededServerId(); + await injectDesktopBridge(page, { + serverId, + manageBuiltInDaemon: true, + confirmShouldAccept: false, + }); + await gotoAppShell(page); + await openDesktopSettings(page, serverId); + + const dialogArgs = await interceptDaemonManagementConfirmDialog(page); + expectDaemonManagementConfirmDialog(dialogArgs); + + await expectDaemonManagementEnabled(page); + }); + + test("cancelling the confirm dialog leaves the daemon management toggle on", async ({ page }) => { + const serverId = getSeededServerId(); + await injectDesktopBridge(page, { + serverId, + manageBuiltInDaemon: true, + confirmShouldAccept: false, + }); + await gotoAppShell(page); + await openDesktopSettings(page, serverId); + + await expectDaemonManagementEnabled(page); + await toggleDaemonManagement(page, "disable"); + await expectDaemonManagementEnabled(page); + }); + + test("confirming the dialog disables built-in daemon management", async ({ page }) => { + const serverId = getSeededServerId(); + await injectDesktopBridge(page, { + serverId, + manageBuiltInDaemon: true, + confirmShouldAccept: true, + }); + await gotoAppShell(page); + await openDesktopSettings(page, serverId); + + await toggleDaemonManagement(page, "disable"); + + await expectDaemonManagementDisabled(page); + }); + + test("daemon status panel renders version, PID, and log path from the real daemon", async ({ + page, + }) => { + const serverId = getSeededServerId(); + const realState = await loadRealDaemonState(); + await injectDesktopBridge(page, { + serverId, + manageBuiltInDaemon: false, + daemonPid: realState.pid, + daemonVersion: realState.version, + daemonLogPath: realState.logPath, + }); + await gotoAppShell(page); + await openDesktopSettings(page, serverId); + + await expectDaemonStatusVersion(page, realState.version); + await expectDaemonStatusPid(page, realState.pid); + await expectDaemonStatusLogPath(page, realState.logPath); + }); + + test("stopping and restarting the daemon updates the PID", async ({ page }) => { + const serverId = getSeededServerId(); + const realState = await loadRealDaemonState(); + await injectDesktopBridge(page, { + serverId, + manageBuiltInDaemon: true, + daemonPid: realState.pid, + daemonVersion: realState.version, + daemonLogPath: realState.logPath, + confirmShouldAccept: true, + }); + await gotoAppShell(page); + await openDesktopSettings(page, serverId); + + await expectDaemonStatusPid(page, realState.pid); + + await toggleDaemonManagement(page, "disable"); + await expectDaemonManagementDisabled(page); + await expectDaemonStatusPid(page, null); + + await toggleDaemonManagement(page, "enable"); + await expectDaemonManagementEnabled(page); + const newPid = realState.pid !== null ? realState.pid + 1000 : 11000; + await expectDaemonStatusPid(page, newPid); + }); +}); diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index ca192fbba..bde14234a 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -705,6 +705,7 @@ export default async function globalSetup() { process.env.E2E_SERVER_ID = offer.serverId; process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64; process.env.E2E_METRO_PORT = String(metroPort); + process.env.E2E_PASEO_HOME = paseoHome; console.log( `[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`, ); diff --git a/packages/app/e2e/helpers/desktop-updates.ts b/packages/app/e2e/helpers/desktop-updates.ts new file mode 100644 index 000000000..e57c7b19b --- /dev/null +++ b/packages/app/e2e/helpers/desktop-updates.ts @@ -0,0 +1,285 @@ +import { readFileSync } from "node:fs"; +import { expect, type Page } from "@playwright/test"; +import { openSettings } from "./app"; +import { openSettingsHost } from "./settings"; + +interface DaemonApiStatus { + version: string; + serverId: string; + hostname: string; +} + +interface PidFileContent { + pid: number; + desktopManaged: boolean; +} + +export interface RealDaemonState { + version: string; + pid: number | null; + logPath: string; +} + +/** + * Reads live state from the running E2E test daemon: version from the HTTP + * status endpoint, PID from the paseo.pid lock file, log path from the + * E2E_PASEO_HOME directory. Call this in Node test code (not in the browser). + */ +export async function loadRealDaemonState(): Promise { + const port = process.env.E2E_DAEMON_PORT; + const paseoHome = process.env.E2E_PASEO_HOME; + if (!port) throw new Error("E2E_DAEMON_PORT not set — globalSetup must run first"); + if (!paseoHome) throw new Error("E2E_PASEO_HOME not set — globalSetup must run first"); + + const resp = await fetch(`http://127.0.0.1:${port}/api/status`); + const data = (await resp.json()) as DaemonApiStatus; + + let pid: number | null = null; + try { + const raw = readFileSync(`${paseoHome}/paseo.pid`, "utf8"); + pid = (JSON.parse(raw) as PidFileContent).pid ?? null; + } catch (err) { + // PID file may not be present yet on a very fresh daemon start + console.warn("[desktop-updates] paseo.pid not found:", err); + } + + return { version: data.version, pid, logPath: `${paseoHome}/daemon.log` }; +} + +export interface DesktopBridgeConfig { + serverId: string; + updateAvailable?: boolean; + latestVersion?: string; + slowInstall?: boolean; + /** Initial PID reported by desktop_daemon_status. Defaults to null. */ + daemonPid?: number | null; + daemonVersion?: string | null; + daemonLogPath?: string; + /** Initial manageBuiltInDaemon setting. Defaults to false. */ + manageBuiltInDaemon?: boolean; + /** + * Controls what dialog.ask returns when the daemon management confirm dialog + * fires. True = confirm (proceed with the action), false = cancel. Defaults to + * false so tests that only assert copy don't inadvertently trigger state changes. + */ + confirmShouldAccept?: boolean; +} + +export interface ConfirmDialogCall { + message: string; + title: string | undefined; +} + +/** + * Injects window.paseoDesktop before app load so all Electron-gated code + * activates. The update-check IPC is mocked at the boundary so the real + * auto-updater never fires. Daemon start/stop commands are stateful: the mock + * tracks running state and assigns a fresh PID on each start, letting tests + * observe PID changes without touching the real E2E daemon process. + * dialog.ask captures call arguments on window.__capturedDialogCall so tests + * can assert dialog copy without depending on window.confirm concatenation. + */ +export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfig): Promise { + await page.addInitScript((cfg) => { + // Mutable state shared across IPC calls within this page + let manageDaemon = cfg.manageBuiltInDaemon ?? false; + let daemonRunning = true; + let currentPid: number | null = cfg.daemonPid ?? null; + let startCount = 0; + + function buildDaemonStatus() { + return { + serverId: cfg.serverId, + status: daemonRunning ? "running" : "stopped", + listen: null, + hostname: null, + pid: currentPid, + home: "", + version: cfg.daemonVersion ?? null, + desktopManaged: manageDaemon, + error: null, + }; + } + + (window as unknown as { paseoDesktop: unknown }).paseoDesktop = { + platform: "darwin", + invoke: async (command: string, args?: Record) => { + if (command === "check_app_update") { + return cfg.updateAvailable + ? { + hasUpdate: true, + readyToInstall: true, + currentVersion: "1.0.0", + latestVersion: cfg.latestVersion ?? "1.2.3", + body: null, + date: null, + } + : { + hasUpdate: false, + readyToInstall: false, + currentVersion: "1.0.0", + latestVersion: null, + body: null, + date: null, + }; + } + + if (command === "install_app_update") { + if (cfg.slowInstall) { + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + return { + installed: true, + version: cfg.latestVersion ?? "1.2.3", + message: "App update installed. Restart required.", + }; + } + + if (command === "desktop_daemon_status") { + return buildDaemonStatus(); + } + + if (command === "desktop_daemon_logs") { + return { logPath: cfg.daemonLogPath ?? "", contents: "" }; + } + + if (command === "get_desktop_settings") { + return { + releaseChannel: "stable", + daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true }, + }; + } + + if (command === "patch_desktop_settings") { + const patchDaemon = + args?.daemon && typeof args.daemon === "object" + ? (args.daemon as Record) + : {}; + if (typeof patchDaemon.manageBuiltInDaemon === "boolean") { + manageDaemon = patchDaemon.manageBuiltInDaemon; + } + return { + releaseChannel: "stable", + daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true }, + }; + } + + if (command === "stop_desktop_daemon") { + daemonRunning = false; + currentPid = null; + return buildDaemonStatus(); + } + + if (command === "start_desktop_daemon") { + startCount += 1; + daemonRunning = true; + // First start (bootstrap) returns the configured PID; subsequent starts + // (after a stop) get a fresh PID so tests can observe the change. + currentPid = (cfg.daemonPid ?? 10000) + (startCount - 1) * 1000; + return buildDaemonStatus(); + } + + return null; + }, + dialog: { + ask: async (message: string, options?: Record) => { + (window as unknown as Record).__capturedDialogCall = { + message, + title: options?.title, + }; + return cfg.confirmShouldAccept ?? false; + }, + }, + getPendingOpenProject: async () => null, + events: { on: async () => () => undefined }, + }; + }, config); +} + +export async function openDesktopSettings(page: Page, serverId: string): Promise { + await openSettings(page); + await openSettingsHost(page, serverId); + await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({ + timeout: 15_000, + }); +} + +export async function expectUpdateBanner(page: Page, version: string): Promise { + const callout = page.getByTestId("update-callout"); + await expect(callout).toBeVisible({ timeout: 15_000 }); + await expect(callout).toContainText(`v${version.replace(/^v/i, "")}`); +} + +export async function clickInstallUpdate(page: Page): Promise { + await page.getByRole("button", { name: "Install & restart" }).click(); +} + +export async function expectInstallInProgress(page: Page): Promise { + await expect(page.getByRole("button", { name: "Installing..." })).toBeVisible(); +} + +/** + * Clicks the daemon management switch and waits for dialog.ask to fire in the + * mock, then returns the captured call args (message + title). The mock auto- + * dismisses via confirmShouldAccept=false so callers can assert copy without + * worrying about state changes. + */ +export async function interceptDaemonManagementConfirmDialog( + page: Page, +): Promise { + await page.getByRole("switch", { name: "Manage built-in daemon" }).click(); + await page.waitForFunction( + () => !!(window as unknown as Record).__capturedDialogCall, + { timeout: 5_000 }, + ); + return page.evaluate( + () => (window as unknown as Record).__capturedDialogCall as ConfirmDialogCall, + ); +} + +export async function toggleDaemonManagement( + page: Page, + _action: "enable" | "disable", +): Promise { + await page.getByRole("switch", { name: "Manage built-in daemon" }).click(); +} + +export function expectDaemonManagementConfirmDialog(args: ConfirmDialogCall): void { + expect(args.title).toBe("Pause built-in daemon"); + expect(args.message).toContain("stop the built-in daemon immediately"); +} + +export async function expectDaemonManagementEnabled(page: Page): Promise { + await expect(page.getByRole("switch", { name: "Manage built-in daemon" })).toBeChecked(); +} + +export async function expectDaemonManagementDisabled(page: Page): Promise { + await expect(page.getByRole("switch", { name: "Manage built-in daemon" })).not.toBeChecked(); +} + +/** + * Asserts the daemon status card shows the given PID. Pass null to assert + * the cleared state (shown as "PID —" when the daemon is stopped). + */ +export async function expectDaemonStatusPid(page: Page, pid: number | null): Promise { + const expected = pid !== null ? `PID ${pid}` : "PID —"; + await expect( + page.getByTestId("host-page-daemon-lifecycle-card").getByText(expected), + ).toBeVisible(); +} + +export async function expectDaemonStatusLogPath(page: Page, logPath: string): Promise { + await expect( + page.getByTestId("host-page-daemon-lifecycle-card").getByText(logPath), + ).toBeVisible(); +} + +/** + * Asserts the host page identity badge shows the given version string. + * The badge is populated from the live WebSocket session's serverInfo.version. + */ +export async function expectDaemonStatusVersion(page: Page, version: string): Promise { + await expect( + page.getByTestId("host-page-identity").getByText(version, { exact: false }), + ).toBeVisible({ timeout: 15_000 }); +}