test(daemon): align lifecycle fixtures with ownership and targeting

This commit is contained in:
Mohamed Boudra
2026-09-09 14:53:22 +02:00
parent 51484caf4d
commit 746dc13538
16 changed files with 220 additions and 74 deletions
@@ -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",
},
+14 -3
View File
@@ -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: [],
+11 -5
View File
@@ -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(
+21 -7
View File
@@ -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> {
+70 -2
View File
@@ -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", () => {
+4 -12
View File
@@ -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 });
}
});
});
+7 -3
View File
@@ -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 });
}
});