mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
test(daemon): align lifecycle fixtures with ownership and targeting
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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<AgentRunOptions, "daemonTarget">,
|
||||
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" });
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<DaemonClient, "getPaseoWorktreeList" | "archivePaseoWorktree" | "close">
|
||||
@@ -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({
|
||||
|
||||
@@ -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") });
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<ConfirmDialogCall> {
|
||||
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<void> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void>
|
||||
)(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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user