From 746dc13538e440541f26abe5901add5ac407067e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 9 Sep 2026 14:53:22 +0200 Subject: [PATCH] test(daemon): align lifecycle fixtures with ownership and targeting --- .../cli/src/commands/agent/delete.test.ts | 4 +- .../cli/src/commands/agent/import.test.ts | 1 + packages/cli/src/commands/agent/run.test.ts | 17 ++++- .../src/commands/daemon/lifecycle.e2e.test.ts | 2 +- .../cli/src/commands/daemon/reload.test.ts | 7 +- .../cli/src/commands/plugin/index.test.ts | 16 +++-- .../cli/src/commands/schedule/shared.test.ts | 44 +++++++----- .../cli/src/commands/worktree/archive.test.ts | 23 ++++-- .../daemon-lifecycle-renderer.electron.mjs | 22 ++++-- packages/desktop/e2e/daemon-lifecycle.e2e.mjs | 2 +- packages/desktop/e2e/support/runtime.ts | 28 ++++++-- packages/desktop/e2e/updates.spec.ts | 72 ++++++++++++++++++- .../desktop/src/daemon/daemon-manager.test.ts | 22 +++--- packages/server/src/server/paseo-home.test.ts | 16 ++--- .../src/server/persisted-config.test.ts | 8 ++- packages/server/src/server/pid-lock.test.ts | 10 ++- 16 files changed, 220 insertions(+), 74 deletions(-) diff --git a/packages/cli/src/commands/agent/delete.test.ts b/packages/cli/src/commands/agent/delete.test.ts index 56aa83dc65..60d5cec50f 100644 --- a/packages/cli/src/commands/agent/delete.test.ts +++ b/packages/cli/src/commands/agent/delete.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "vitest"; +const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; + import { runDeleteCommand } from "./delete.js"; const agent = { @@ -27,7 +29,7 @@ vi.mock("../../utils/client.js", () => ({ describe("runDeleteCommand", () => { it("force-deletes a running agent when graceful cancellation is refused", async () => { - const result = await runDeleteCommand(agent.id, {}, {} as never); + const result = await runDeleteCommand(agent.id, { daemonTarget }, {} as never); expect(cancelAgent).toHaveBeenCalledWith(agent.id); expect(deleteAgent).toHaveBeenCalledWith(agent.id); diff --git a/packages/cli/src/commands/agent/import.test.ts b/packages/cli/src/commands/agent/import.test.ts index 48a678de4a..761b2a0f1e 100644 --- a/packages/cli/src/commands/agent/import.test.ts +++ b/packages/cli/src/commands/agent/import.test.ts @@ -43,6 +43,7 @@ describe("resolveImportCwd", () => { const result = await runImportCommand( "pi-session-1", { + daemonTarget: { kind: "endpoint", host: "example.test:12345" }, provider: "pi", cwd: "/tmp/project", }, diff --git a/packages/cli/src/commands/agent/run.test.ts b/packages/cli/src/commands/agent/run.test.ts index 755638d4d3..8f707c5e0c 100644 --- a/packages/cli/src/commands/agent/run.test.ts +++ b/packages/cli/src/commands/agent/run.test.ts @@ -6,6 +6,8 @@ import { type AgentRunOptions, } from "./run"; +const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; + describe("managed agent caller context", () => { it("propagates a trimmed PASEO_AGENT_ID", () => { expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " parent-agent " })).toBe("parent-agent"); @@ -65,8 +67,13 @@ describe("runRunCommand option validation", () => { } }); - async function expectInvalidOptions(options: AgentRunOptions, messageMatch: RegExp) { - await expect(runRunCommand("do something", options, {} as never)).rejects.toMatchObject({ + async function expectInvalidOptions( + options: Omit, + messageMatch: RegExp, + ) { + await expect( + runRunCommand("do something", { ...options, daemonTarget }, {} as never), + ).rejects.toMatchObject({ code: "INVALID_OPTIONS", message: expect.stringMatching(messageMatch), }); @@ -84,7 +91,11 @@ describe("runRunCommand option validation", () => { // must clear validation. It still fails later (provider resolution), which // is enough to prove the new guard did not reject it. await expect( - runRunCommand("do something", { newWorkspace: "worktree", provider: undefined }, {} as never), + runRunCommand( + "do something", + { newWorkspace: "worktree", provider: undefined, daemonTarget }, + {} as never, + ), ).rejects.not.toMatchObject({ code: "INVALID_OPTIONS" }); }); diff --git a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts index 63fcc8ff75..ea6cfc48f9 100644 --- a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts +++ b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts @@ -22,7 +22,7 @@ async function port() { } async function fixture() { - const root = await mkdtemp(path.join(tmpdir(), "paseo-lifecycle-")); + const root = await mkdtemp(path.join(tmpdir(), "paseo lifecycle ")); const env = { ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))), HOME: root, diff --git a/packages/cli/src/commands/daemon/reload.test.ts b/packages/cli/src/commands/daemon/reload.test.ts index 518d02c799..a97400406e 100644 --- a/packages/cli/src/commands/daemon/reload.test.ts +++ b/packages/cli/src/commands/daemon/reload.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "vitest"; import { render } from "../../output/render.js"; import { daemonReloadSchema, type DaemonReloadResult } from "./reload.js"; +const restartCommand = 'paseo daemon restart --home "/selected home"'; + function result(data: DaemonReloadResult) { return { type: "single" as const, data, schema: daemonReloadSchema }; } @@ -11,6 +13,7 @@ describe("daemon reload output", () => { expect( render( result({ + restartCommand, appliedPaths: ["daemon.browserTools.enabled"], restartRequiredPaths: ["daemon.listen"], overrideControlledPaths: ["app.baseUrl"], @@ -23,7 +26,7 @@ describe("daemon reload output", () => { "Warning: These changes require a daemon restart:", " daemon.listen", "", - "Run: paseo daemon restart", + `Run: ${restartCommand}`, "", "Warning: These settings are controlled by daemon launch overrides:", " app.baseUrl", @@ -34,6 +37,7 @@ describe("daemon reload output", () => { test("human output omits restart guidance for a live-only reload", () => { const output = render( result({ + restartCommand, appliedPaths: ["daemon.browserTools.enabled"], restartRequiredPaths: [], overrideControlledPaths: [], @@ -45,6 +49,7 @@ describe("daemon reload output", () => { test("structured output preserves the daemon result", () => { const data = { + restartCommand, appliedPaths: ["daemon.browserTools.enabled"], restartRequiredPaths: ["daemon.listen"], overrideControlledPaths: [], diff --git a/packages/cli/src/commands/plugin/index.test.ts b/packages/cli/src/commands/plugin/index.test.ts index 02c6c846ce..957a0072ae 100644 --- a/packages/cli/src/commands/plugin/index.test.ts +++ b/packages/cli/src/commands/plugin/index.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; + const listPlugins = vi.fn(async () => [ { id: "git-plugin", @@ -68,7 +70,9 @@ describe("plugin management commands", () => { }); it("requires host support before attempting a management RPC", async () => { - await expect(runPluginListCommand(undefined, {}, {} as never)).rejects.toMatchObject({ + await expect( + runPluginListCommand(undefined, { daemonTarget }, {} as never), + ).rejects.toMatchObject({ code: "DAEMON_UPDATE_REQUIRED", message: "Update the host to use plugin management.", }); @@ -89,7 +93,7 @@ describe("plugin management commands", () => { it("lists runtime state and the installed commit without an upstream commit", async () => { features.pluginManagement = true; - const result = await runPluginListCommand(undefined, {}, {} as never); + const result = await runPluginListCommand(undefined, { daemonTarget }, {} as never); const output = render(result, { noColor: true }); expect(output).toContain("SOURCE"); @@ -102,13 +106,15 @@ describe("plugin management commands", () => { it("filters the shared ls and status command by plugin ID", async () => { features.pluginManagement = true; - const result = await runPluginListCommand("legacy-plugin", {}, {} as never); + const result = await runPluginListCommand("legacy-plugin", { daemonTarget }, {} as never); expect(result.data.map((plugin) => plugin.id)).toEqual(["legacy-plugin"]); }); it("requires plugin log support before attempting the RPC", async () => { - await expect(runPluginLogsCommand("example", {}, {} as never)).rejects.toMatchObject({ + await expect( + runPluginLogsCommand("example", { daemonTarget }, {} as never), + ).rejects.toMatchObject({ code: "DAEMON_UPDATE_REQUIRED", message: "Update the host to view plugin logs.", }); @@ -117,7 +123,7 @@ describe("plugin management commands", () => { it("returns readable and JSON plugin log output", async () => { features.pluginLogs = true; - const result = await runPluginLogsCommand("example", {}, {} as never); + const result = await runPluginLogsCommand("example", { daemonTarget }, {} as never); expect(getPluginLogs).toHaveBeenCalledWith("example"); expect(render(result, { noColor: true })).toContain("ready"); diff --git a/packages/cli/src/commands/schedule/shared.test.ts b/packages/cli/src/commands/schedule/shared.test.ts index 0777134b0e..bac537e4fe 100644 --- a/packages/cli/src/commands/schedule/shared.test.ts +++ b/packages/cli/src/commands/schedule/shared.test.ts @@ -1,4 +1,6 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test, vi } from "vitest"; + +import { selectDaemonTarget } from "../../utils/daemon-target.js"; import { compileEveryPresetToCron, @@ -7,32 +9,25 @@ import { } from "./shared.js"; const baseOptions = { + daemonTarget: selectDaemonTarget({ home: process.cwd() }, {}), prompt: "do the thing", every: "5m", provider: "claude", }; const baseCron = { + daemonTarget: selectDaemonTarget({ home: process.cwd() }, {}), prompt: "do the thing", cron: "0 9 * * *", provider: "claude", }; describe("parseScheduleCreateInput cwd/host validation", () => { - beforeEach(() => { - vi.spyOn(process, "cwd").mockReturnValue("/local/project"); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllEnvs(); - }); - test("no host, no cwd → defaults to process.cwd()", () => { const input = parseScheduleCreateInput(baseOptions); expect(input.target).toEqual({ type: "new-agent", - config: { provider: "claude", cwd: "/local/project" }, + config: { provider: "claude", cwd: process.cwd() }, }); }); @@ -47,7 +42,7 @@ describe("parseScheduleCreateInput cwd/host validation", () => { test("host with cwd → uses provided cwd", () => { const input = parseScheduleCreateInput({ ...baseOptions, - host: "dev:6767", + daemonTarget: selectDaemonTarget({ host: "dev:12345" }, {}), cwd: "/remote/project", }); expect(input.target).toEqual({ @@ -57,7 +52,12 @@ describe("parseScheduleCreateInput cwd/host validation", () => { }); test("host without cwd → throws MISSING_CWD", () => { - expect(() => parseScheduleCreateInput({ ...baseOptions, host: "dev:6767" })).toThrow( + expect(() => + parseScheduleCreateInput({ + ...baseOptions, + daemonTarget: selectDaemonTarget({ host: "dev:12345" }, {}), + }), + ).toThrow( expect.objectContaining({ code: "MISSING_CWD", message: expect.stringContaining("--cwd is required when --host is specified"), @@ -66,9 +66,9 @@ describe("parseScheduleCreateInput cwd/host validation", () => { }); test("PASEO_HOST without cwd → throws MISSING_CWD", () => { - vi.stubEnv("PASEO_HOST", "dev:6767"); + const daemonTarget = selectDaemonTarget({}, { PASEO_HOST: "dev:12345" }); - expect(() => parseScheduleCreateInput(baseOptions)).toThrow( + expect(() => parseScheduleCreateInput({ ...baseOptions, daemonTarget })).toThrow( expect.objectContaining({ code: "MISSING_CWD", message: expect.stringContaining("--cwd is required"), @@ -76,9 +76,21 @@ describe("parseScheduleCreateInput cwd/host validation", () => { ); }); + test("explicit home beats PASEO_HOST and defaults cwd locally", () => { + const daemonTarget = selectDaemonTarget({ home: process.cwd() }, { PASEO_HOST: "dev:12345" }); + expect(parseScheduleCreateInput({ ...baseOptions, daemonTarget }).target).toEqual({ + type: "new-agent", + config: { provider: "claude", cwd: process.cwd() }, + }); + }); + test("host with whitespace-only cwd → throws MISSING_CWD", () => { expect(() => - parseScheduleCreateInput({ ...baseOptions, host: "dev:6767", cwd: " " }), + parseScheduleCreateInput({ + ...baseOptions, + daemonTarget: selectDaemonTarget({ host: "dev:12345" }, {}), + cwd: " ", + }), ).toThrow(expect.objectContaining({ code: "MISSING_CWD" })); }); }); diff --git a/packages/cli/src/commands/worktree/archive.test.ts b/packages/cli/src/commands/worktree/archive.test.ts index 5314be144d..f591aaa6a7 100644 --- a/packages/cli/src/commands/worktree/archive.test.ts +++ b/packages/cli/src/commands/worktree/archive.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { runArchiveCommandWithDeps } from "./archive.js"; +const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; + function createFakeDaemonClient( overrides: Partial< Pick @@ -60,9 +62,12 @@ describe("runArchiveCommand", () => { const result = await runArchiveCommandWithDeps( "feature", - {}, + { daemonTarget }, { - connectToDaemon: async () => fakeClient, + connectToDaemon: async (options) => { + expect(options.target).toEqual(daemonTarget); + return fakeClient; + }, }, ); @@ -111,9 +116,12 @@ describe("runArchiveCommand", () => { await runArchiveCommandWithDeps( "feature-x", - {}, + { daemonTarget }, { - connectToDaemon: async () => fakeClient, + connectToDaemon: async (options) => { + expect(options.target).toEqual(daemonTarget); + return fakeClient; + }, }, ); @@ -134,9 +142,12 @@ describe("runArchiveCommand", () => { await expect( runArchiveCommandWithDeps( "missing", - {}, + { daemonTarget }, { - connectToDaemon: async () => fakeClient, + connectToDaemon: async (options) => { + expect(options.target).toEqual(daemonTarget); + return fakeClient; + }, }, ), ).rejects.toMatchObject({ diff --git a/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs b/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs index f399db8811..d514481992 100644 --- a/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs +++ b/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs @@ -78,7 +78,7 @@ export async function verifyAttachedDaemonControls({ repo, root, env, home, port .scrollIntoViewIfNeeded(); await page.screenshot({ path: path.join(root, "attached-daemon.png") }); - async function acceptNativeDialog(title, artifact) { + async function respondToNativeDialog(title, artifact, key) { let windowId; await expect .poll( @@ -106,17 +106,31 @@ export async function verifyAttachedDaemonControls({ repo, root, env, home, port return sources[0].thumbnail.toPNG().toString("base64"); }); await writeFile(path.join(root, artifact), Buffer.from(screenshot, "base64")); - execFileSync("xdotool", ["key", "--window", windowId, "Return"]); + // Escape destroys the dialog on key-down. Send through the private display + // focus so key-up does not target an already-destroyed X11 window. + execFileSync("xdotool", ["windowfocus", "--sync", windowId, "key", key]); } const management = page.getByRole("switch", { name: "Manage built-in daemon" }); await management.click(); - await acceptNativeDialog("Pause built-in daemon", "pause-attached-confirmation.png"); + await respondToNativeDialog( + "Pause built-in daemon", + "pause-attached-confirmation.png", + "Return", + ); await expect(management).not.toBeChecked(); expect((await readDaemonInstance(home)).pid).toBe(instance.pid); await page.getByRole("button", { name: "Stop daemon", exact: true }).click(); - await acceptNativeDialog("Stop local daemon", "stop-attached-confirmation.png"); + await respondToNativeDialog("Stop local daemon", "stop-attached-cancelled.png", "Escape"); + await expect(page.getByRole("button", { name: "Stop daemon", exact: true })).toBeEnabled(); + expect(await readDaemonInstance(home)).toMatchObject({ + pid: instance.pid, + startedAt: instance.startedAt, + }); + + await page.getByRole("button", { name: "Stop daemon", exact: true }).click(); + await respondToNativeDialog("Stop local daemon", "stop-attached-confirmation.png", "Return"); await expect.poll(() => readDaemonInstance(home), { timeout: 20_000 }).toBeNull(); await expect(page.getByRole("button", { name: "Stop daemon", exact: true })).toBeHidden(); await page.screenshot({ path: path.join(root, "attached-daemon-stopped.png") }); diff --git a/packages/desktop/e2e/daemon-lifecycle.e2e.mjs b/packages/desktop/e2e/daemon-lifecycle.e2e.mjs index f75acbf3d2..2ef29e1589 100644 --- a/packages/desktop/e2e/daemon-lifecycle.e2e.mjs +++ b/packages/desktop/e2e/daemon-lifecycle.e2e.mjs @@ -15,7 +15,7 @@ import { } from "@getpaseo/server"; const repo = fileURLToPath(new URL("../../..", import.meta.url)); -const root = await mkdtemp(path.join(tmpdir(), "paseo-desktop-lifecycle-")); +const root = await mkdtemp(path.join(tmpdir(), "paseo desktop lifecycle ")); const home = path.join(root, "daemon"); const env = Object.fromEntries( Object.entries(process.env).filter( diff --git a/packages/desktop/e2e/support/runtime.ts b/packages/desktop/e2e/support/runtime.ts index 14b770b2fd..9032e5c56e 100644 --- a/packages/desktop/e2e/support/runtime.ts +++ b/packages/desktop/e2e/support/runtime.ts @@ -62,6 +62,9 @@ export interface DesktopRuntimeConfig { slowInstall?: boolean; /** Initial PID reported by desktop_daemon_status. Defaults to null. */ daemonPid?: number | null; + daemonHome?: string; + /** Current-session ownership, independent of the legacy management flag. */ + ownedByDesktop?: boolean; daemonVersion?: string | null; daemonLogPath?: string; /** Initial manageBuiltInDaemon setting. Defaults to false. */ @@ -139,7 +142,7 @@ export async function installDesktopRuntime( let manageDaemon = cfg.manageBuiltInDaemon ?? false; let daemonRunning = true; let currentPid: number | null = cfg.daemonPid ?? null; - let startCount = 0; + let ownedByDesktop = cfg.ownedByDesktop ?? false; let manualUpdateAdmitted = false; window.__desktopDaemonStartRequested = false; @@ -150,7 +153,9 @@ export async function installDesktopRuntime( listen: cfg.daemonListen ?? "127.0.0.1:6767", hostname: null, pid: currentPid, - home: "", + home: cfg.daemonHome ?? "", + startedAt: currentPid === null ? null : "2026-09-01T00:00:00.000Z", + ownedByDesktop, version: cfg.daemonVersion ?? null, desktopManaged: manageDaemon, error: null, @@ -162,11 +167,11 @@ export async function installDesktopRuntime( if (cfg.hangDaemonStart) { return new Promise(() => undefined); } - startCount += 1; + if (!daemonRunning) { + currentPid = (cfg.daemonPid ?? 10000) + 1000; + ownedByDesktop = true; + } 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(); } @@ -268,6 +273,7 @@ export async function installDesktopRuntime( } if (command === "stop_desktop_daemon") { + ownedByDesktop = false; daemonRunning = false; currentPid = null; return buildDaemonStatus(); @@ -396,6 +402,12 @@ export async function interceptDaemonManagementConfirmDialog( return page.evaluate(() => window.__capturedDialogCall!); } +export async function interceptDaemonStopConfirmDialog(page: Page): Promise { + await page.getByRole("button", { name: "Stop daemon", exact: true }).click(); + await page.waitForFunction(() => !!window.__capturedDialogCall); + return page.evaluate(() => window.__capturedDialogCall!); +} + export async function toggleDaemonManagement( page: Page, _action: "enable" | "disable", @@ -405,7 +417,9 @@ export async function toggleDaemonManagement( export function expectDaemonManagementConfirmDialog(args: ConfirmDialogCall): void { expect(args.title).toBe("Pause built-in daemon"); - expect(args.message).toContain("stop the built-in daemon immediately"); + expect(args.message).toBe( + "This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.", + ); } export async function expectDaemonManagementEnabled(page: Page): Promise { diff --git a/packages/desktop/e2e/updates.spec.ts b/packages/desktop/e2e/updates.spec.ts index 9a2153163e..361002edac 100644 --- a/packages/desktop/e2e/updates.spec.ts +++ b/packages/desktop/e2e/updates.spec.ts @@ -18,6 +18,7 @@ import { clickInstallUpdate, expectInstallInProgress, interceptDaemonManagementConfirmDialog, + interceptDaemonStopConfirmDialog, toggleDaemonManagement, expectDaemonManagementConfirmDialog, expectDaemonManagementEnabled, @@ -27,8 +28,8 @@ import { expectDaemonStatusVersion, } from "./support/runtime"; -// No Playwright Electron runner exists; we simulate the desktop bridge via -// addInitScript so Electron-gated UI activates without a real Electron process. +// These renderer cases use the Desktop bridge fixture. Actual Electron ownership +// and native confirmation journeys live in daemon-lifecycle.e2e.mjs. test.describe("Desktop updates", () => { test("a desktop-managed daemon explains why its update action is disabled", async ({ page, @@ -111,6 +112,7 @@ test.describe("Desktop daemon management", () => { await installDesktopRuntime(page, { serverId, manageBuiltInDaemon: true, + ownedByDesktop: true, confirmShouldAccept: false, }); await gotoAppShell(page); @@ -127,6 +129,7 @@ test.describe("Desktop daemon management", () => { await installDesktopRuntime(page, { serverId, manageBuiltInDaemon: true, + ownedByDesktop: true, confirmShouldAccept: false, }); await gotoAppShell(page); @@ -142,6 +145,7 @@ test.describe("Desktop daemon management", () => { await installDesktopRuntime(page, { serverId, manageBuiltInDaemon: true, + ownedByDesktop: true, confirmShouldAccept: true, }); await gotoAppShell(page); @@ -178,6 +182,7 @@ test.describe("Desktop daemon management", () => { await installDesktopRuntime(page, { serverId, manageBuiltInDaemon: true, + ownedByDesktop: true, daemonPid: realState.pid, daemonVersion: realState.version, daemonLogPath: realState.logPath, @@ -197,4 +202,67 @@ test.describe("Desktop daemon management", () => { const newPid = realState.pid !== null ? realState.pid + 1000 : 11000; await expectDaemonStatusPid(page, newPid); }); + + test("pausing management preserves an attached legacy desktop-managed daemon", async ({ + page, + }) => { + const serverId = getServerId(); + const realState = await loadRealDaemonState(); + await installDesktopRuntime(page, { + serverId, + manageBuiltInDaemon: true, + ownedByDesktop: false, + daemonPid: realState.pid, + confirmShouldAccept: true, + }); + await gotoAppShell(page); + await openDesktopSettings(page, serverId); + + const dialog = await interceptDaemonManagementConfirmDialog(page); + expect(dialog).toEqual({ + title: "Pause built-in daemon", + message: "Pause automatic daemon management? The attached daemon will keep running.", + }); + await expectDaemonManagementDisabled(page); + await expectDaemonStatusPid(page, realState.pid); + + await toggleDaemonManagement(page, "enable"); + await expectDaemonManagementEnabled(page); + await expectDaemonStatusPid(page, realState.pid); + }); + + for (const ownedByDesktop of [false, true]) { + for (const confirmShouldAccept of [false, true]) { + test(`${confirmShouldAccept ? "confirming" : "cancelling"} Stop identifies the ${ownedByDesktop ? "owned" : "attached"} daemon`, async ({ + page, + }) => { + const serverId = getServerId(); + const realState = await loadRealDaemonState(); + const daemonHome = process.env.E2E_PASEO_HOME!; + await installDesktopRuntime(page, { + serverId, + daemonPid: realState.pid, + daemonHome, + ownedByDesktop, + confirmShouldAccept, + }); + await gotoAppShell(page); + await openDesktopSettings(page, serverId); + + const dialog = await interceptDaemonStopConfirmDialog(page); + expect(dialog).toEqual({ + title: "Stop local daemon?", + message: [ + ownedByDesktop + ? "This daemon was launched by this Desktop session." + : "This daemon was not launched by this Desktop session.", + `Home: ${daemonHome}`, + `Supervisor PID: ${realState.pid}`, + "Running agent work will be interrupted.", + ].join("\n"), + }); + await expectDaemonStatusPid(page, confirmShouldAccept ? null : realState.pid); + }); + } + } }); diff --git a/packages/desktop/src/daemon/daemon-manager.test.ts b/packages/desktop/src/daemon/daemon-manager.test.ts index f48be30d92..1bd3beed33 100644 --- a/packages/desktop/src/daemon/daemon-manager.test.ts +++ b/packages/desktop/src/daemon/daemon-manager.test.ts @@ -1,11 +1,13 @@ -import { rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings"; import { createDaemonCommandHandlers } from "./daemon-manager"; const mocks = vi.hoisted(() => ({ - paseoHome: "/tmp/paseo-desktop-daemon-manager-test-home", + paseoHome: "", settings: { releaseChannel: "stable", daemon: { @@ -23,13 +25,13 @@ const mocks = vi.hoisted(() => ({ spawnProcess: vi.fn(), logInfo: vi.fn(), logError: vi.fn(), - appLogPath: "/tmp/paseo-desktop-daemon-manager-test-main.log", + appLogPath: "", getElectronLogFile: vi.fn(), })); vi.mock("electron", () => ({ app: { - getPath: vi.fn(() => "/tmp/paseo-user-data"), + getPath: vi.fn(() => mocks.paseoHome), getVersion: vi.fn(() => "1.2.3"), isPackaged: true, }, @@ -65,7 +67,7 @@ vi.mock("../settings/desktop-settings-electron.js", () => ({ vi.mock("./runtime-paths.js", () => ({ createNodeEntrypointInvocation: mocks.createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint: vi.fn(() => ({ - entryPath: "/tmp/daemon.js", + entryPath: path.join(mocks.paseoHome, "daemon.js"), execArgv: [], })), })); @@ -76,7 +78,12 @@ vi.mock("./cli/external.js", () => ({ })); describe("daemon-manager commands", () => { + let fixtureRoot: string; + beforeEach(() => { + fixtureRoot = mkdtempSync(path.join(tmpdir(), "paseo daemon manager ")); + mocks.paseoHome = path.join(fixtureRoot, "home"); + mocks.appLogPath = path.join(fixtureRoot, "main.log"); mocks.settings = DEFAULT_DESKTOP_SETTINGS; mocks.runExternalCliJsonCommand.mockReset(); mocks.runExternalCliTextCommand.mockReset(); @@ -87,13 +94,10 @@ describe("daemon-manager commands", () => { mocks.logError.mockReset(); mocks.getElectronLogFile.mockReset(); mocks.getElectronLogFile.mockReturnValue({ path: mocks.appLogPath }); - rmSync(mocks.paseoHome, { recursive: true, force: true }); - rmSync(mocks.appLogPath, { force: true }); }); afterEach(() => { - rmSync(mocks.paseoHome, { recursive: true, force: true }); - rmSync(mocks.appLogPath, { force: true }); + rmSync(fixtureRoot, { recursive: true, force: true }); }); it("returns the Electron main-process log tail from electron-log", () => { diff --git a/packages/server/src/server/paseo-home.test.ts b/packages/server/src/server/paseo-home.test.ts index 99a6c30d25..d8623d230b 100644 --- a/packages/server/src/server/paseo-home.test.ts +++ b/packages/server/src/server/paseo-home.test.ts @@ -1,24 +1,16 @@ -import { mkdtempSync, rmSync, statSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, test } from "vitest"; import { resolvePaseoHome } from "./paseo-home.js"; -import { PRIVATE_DIRECTORY_MODE } from "./private-files.js"; - -const MODE_MASK = 0o777; - -function modeOf(filePath: string): number { - return statSync(filePath).mode & MODE_MASK; -} - -describe.skipIf(process.platform === "win32")("resolvePaseoHome permissions", () => { - test("creates PASEO_HOME with private permissions", () => { +describe("resolvePaseoHome", () => { + test("resolves PASEO_HOME without creating it", () => { const parent = mkdtempSync(path.join(tmpdir(), "paseo-home-parent-")); const paseoHome = path.join(parent, "home"); try { expect(resolvePaseoHome({ PASEO_HOME: paseoHome })).toBe(paseoHome); - expect(modeOf(paseoHome)).toBe(PRIVATE_DIRECTORY_MODE); + expect(existsSync(paseoHome)).toBe(false); } finally { rmSync(parent, { recursive: true, force: true }); } diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 8ea1c5c25d..2ab987eebf 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -8,7 +8,7 @@ import { PersistedConfigSchema, savePersistedConfig, } from "./persisted-config.js"; -import { PRIVATE_FILE_MODE } from "./private-files.js"; +import { PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE } from "./private-files.js"; const MODE_MASK = 0o777; const PERMISSIVE_FILE_MODE = 0o644; @@ -764,7 +764,8 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions }); test("saves config.json with private permissions", () => { - const home = createTempHome(); + const parent = createTempHome(); + const home = path.join(parent, "home"); try { savePersistedConfig(home, { providers: { @@ -774,9 +775,10 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions }, }); + expect(modeOf(home)).toBe(PRIVATE_DIRECTORY_MODE); expect(modeOf(path.join(home, "config.json"))).toBe(PRIVATE_FILE_MODE); } finally { - rmSync(home, { recursive: true, force: true }); + rmSync(parent, { recursive: true, force: true }); } }); }); diff --git a/packages/server/src/server/pid-lock.test.ts b/packages/server/src/server/pid-lock.test.ts index 22d617f4c3..457e1e7edc 100644 --- a/packages/server/src/server/pid-lock.test.ts +++ b/packages/server/src/server/pid-lock.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, open, rm, utimes, writeFile } from "node:fs/promises"; +import { mkdtemp, open, rm, stat, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; @@ -15,7 +15,8 @@ import { describe("pid-lock ownership", () => { test("writes and releases lock for explicit owner pid", async () => { - const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-owner-")); + const parent = await mkdtemp(join(tmpdir(), "paseo-pid-lock-owner-")); + const paseoHome = join(parent, "home"); const ownerPid = process.pid + 10_000; try { @@ -27,6 +28,9 @@ describe("pid-lock ownership", () => { ) => Promise )(paseoHome, null, { ownerPid }); + if (process.platform !== "win32") { + expect((await stat(paseoHome)).mode & 0o777).toBe(0o700); + } const lock = await getPidLockInfo(paseoHome); expect(lock?.pid).toBe(ownerPid); expect(lock?.listen).toBeNull(); @@ -55,7 +59,7 @@ describe("pid-lock ownership", () => { const lockAfterOwnerRelease = await getPidLockInfo(paseoHome); expect(lockAfterOwnerRelease).toBeNull(); } finally { - await rm(paseoHome, { recursive: true, force: true }); + await rm(parent, { recursive: true, force: true }); } });