refactor(cli): inject local daemon launch runtime (#874)
* refactor(cli): inject local daemon launch runtime * test(app/e2e): target mobile sidebar toggle state
This commit is contained in:
@@ -21,12 +21,12 @@ export async function expectWorkspaceListed(page: Page, name: string): Promise<v
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function openMobileAgentSidebar(page: Page): Promise<void> {
|
export async function openMobileAgentSidebar(page: Page): Promise<void> {
|
||||||
await page.getByTestId("menu-button").click();
|
await page.getByRole("button", { name: "Open menu" }).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
// force=true: the overlay covers the button when the mobile sidebar is open.
|
// force=true: the overlay covers the button when the mobile sidebar is open.
|
||||||
export async function closeMobileAgentSidebar(page: Page): Promise<void> {
|
export async function closeMobileAgentSidebar(page: Page): Promise<void> {
|
||||||
await page.getByTestId("menu-button").click({ force: true });
|
await page.getByRole("button", { name: "Close menu" }).click({ force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.
|
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.
|
||||||
|
|||||||
@@ -1,32 +1,67 @@
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
import {
|
||||||
spawnSync: vi.fn(),
|
type DaemonLaunchRuntime,
|
||||||
spawnProcess: vi.fn(),
|
type DetachedDaemonProcess,
|
||||||
}));
|
startLocalDaemonDetached,
|
||||||
|
startLocalDaemonForeground,
|
||||||
|
} from "./local-daemon.js";
|
||||||
|
|
||||||
vi.mock("node:child_process", async () => {
|
type RecordedDaemonLaunch =
|
||||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
| {
|
||||||
return {
|
mode: "detached";
|
||||||
...actual,
|
command: string;
|
||||||
spawnSync: mocks.spawnSync,
|
args: string[];
|
||||||
};
|
options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2];
|
||||||
});
|
}
|
||||||
|
| {
|
||||||
|
mode: "foreground";
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2];
|
||||||
|
};
|
||||||
|
|
||||||
vi.mock("@getpaseo/server", async () => {
|
class FakeDaemonProcess extends EventEmitter implements DetachedDaemonProcess {
|
||||||
const actual = await vi.importActual<typeof import("@getpaseo/server")>("@getpaseo/server");
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
loadConfig: () => ({ listen: "127.0.0.1:6767" }),
|
|
||||||
resolvePaseoHome: (env: NodeJS.ProcessEnv) => env.PASEO_HOME ?? "/tmp/paseo",
|
|
||||||
spawnProcess: mocks.spawnProcess,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
class FakeChildProcess extends EventEmitter {
|
|
||||||
pid = 4242;
|
pid = 4242;
|
||||||
unref = vi.fn();
|
wasUnreferenced = false;
|
||||||
|
|
||||||
|
unref(): void {
|
||||||
|
this.wasUnreferenced = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeDaemonRuntime implements DaemonLaunchRuntime {
|
||||||
|
readonly recordedLaunches: RecordedDaemonLaunch[] = [];
|
||||||
|
readonly daemonProcess = new FakeDaemonProcess();
|
||||||
|
foregroundStatus = 0;
|
||||||
|
runnerEntry = "/repo/packages/server/scripts/supervisor-entrypoint.ts";
|
||||||
|
|
||||||
|
resolveRunnerEntry(): string {
|
||||||
|
return this.runnerEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveHome(env: NodeJS.ProcessEnv): string {
|
||||||
|
return env.PASEO_HOME ?? "/tmp/paseo";
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnDetached(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2],
|
||||||
|
): DetachedDaemonProcess {
|
||||||
|
this.recordedLaunches.push({ mode: "detached", command, args, options });
|
||||||
|
return this.daemonProcess;
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnForeground(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2],
|
||||||
|
) {
|
||||||
|
this.recordedLaunches.push({ mode: "foreground", command, args, options });
|
||||||
|
return { status: this.foregroundStatus, error: undefined };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectSupervisorLaunch(argv: string[]): void {
|
function expectSupervisorLaunch(argv: string[]): void {
|
||||||
@@ -41,59 +76,59 @@ function expectSupervisorLaunch(argv: string[]): void {
|
|||||||
describe("local daemon launch supervision", () => {
|
describe("local daemon launch supervision", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
mocks.spawnSync.mockReset();
|
|
||||||
mocks.spawnProcess.mockReset();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("foreground start spawns supervisor-entrypoint instead of server/index", async () => {
|
test("foreground start spawns supervisor-entrypoint instead of server/index", async () => {
|
||||||
mocks.spawnSync.mockReturnValue({ status: 0, error: undefined });
|
const runtime = new FakeDaemonRuntime();
|
||||||
|
|
||||||
const { startLocalDaemonForeground } = await import("./local-daemon.js");
|
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false }, runtime);
|
||||||
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false });
|
|
||||||
|
|
||||||
expect(status).toBe(0);
|
expect(status).toBe(0);
|
||||||
expect(mocks.spawnSync).toHaveBeenCalledOnce();
|
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
|
||||||
const [command, argv] = mocks.spawnSync.mock.calls[0] as [string, string[]];
|
const launch = runtime.recordedLaunches[0];
|
||||||
expect(command).toBe(process.execPath);
|
expect(launch?.mode).toBe("foreground");
|
||||||
expectSupervisorLaunch(argv);
|
expect(launch?.command).toBe(process.execPath);
|
||||||
expect(argv).toContain("--no-relay");
|
expectSupervisorLaunch(launch?.args ?? []);
|
||||||
|
expect(launch?.args).toContain("--no-relay");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("detached start spawns supervisor-entrypoint instead of server/index", async () => {
|
test("detached start spawns supervisor-entrypoint instead of server/index", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const child = new FakeChildProcess();
|
const runtime = new FakeDaemonRuntime();
|
||||||
mocks.spawnProcess.mockReturnValue(child);
|
|
||||||
|
|
||||||
const { startLocalDaemonDetached } = await import("./local-daemon.js");
|
const resultPromise = startLocalDaemonDetached(
|
||||||
const resultPromise = startLocalDaemonDetached({ home: "/tmp/paseo-test", mcp: false });
|
{ home: "/tmp/paseo-test", mcp: false },
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
await vi.advanceTimersByTimeAsync(1200);
|
await vi.advanceTimersByTimeAsync(1200);
|
||||||
const result = await resultPromise;
|
const result = await resultPromise;
|
||||||
|
|
||||||
expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" });
|
expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" });
|
||||||
expect(child.unref).toHaveBeenCalledOnce();
|
expect(runtime.daemonProcess.wasUnreferenced).toBe(true);
|
||||||
expect(mocks.spawnProcess).toHaveBeenCalledOnce();
|
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["detached"]);
|
||||||
const [command, argv] = mocks.spawnProcess.mock.calls[0] as [string, string[]];
|
const launch = runtime.recordedLaunches[0];
|
||||||
expect(command).toBe(process.execPath);
|
expect(launch?.mode).toBe("detached");
|
||||||
expectSupervisorLaunch(argv);
|
expect(launch?.command).toBe(process.execPath);
|
||||||
expect(argv).toContain("--no-mcp");
|
expectSupervisorLaunch(launch?.args ?? []);
|
||||||
|
expect(launch?.args).toContain("--no-mcp");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("relay TLS flag is passed to the supervised daemon", async () => {
|
test("relay TLS flag is passed to the supervised daemon", async () => {
|
||||||
mocks.spawnSync.mockReturnValue({ status: 0, error: undefined });
|
const runtime = new FakeDaemonRuntime();
|
||||||
|
|
||||||
const { startLocalDaemonForeground } = await import("./local-daemon.js");
|
const status = startLocalDaemonForeground(
|
||||||
const status = startLocalDaemonForeground({
|
{
|
||||||
home: "/tmp/paseo-test",
|
home: "/tmp/paseo-test",
|
||||||
relayUseTls: true,
|
relayUseTls: true,
|
||||||
});
|
},
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
|
||||||
expect(status).toBe(0);
|
expect(status).toBe(0);
|
||||||
const [, argv, options] = mocks.spawnSync.mock.calls[0] as [
|
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
|
||||||
string,
|
const launch = runtime.recordedLaunches[0];
|
||||||
string[],
|
expect(launch?.mode).toBe("foreground");
|
||||||
{ env?: NodeJS.ProcessEnv },
|
expect(launch?.args).toContain("--relay-use-tls");
|
||||||
];
|
expect(launch?.options?.env?.PASEO_RELAY_USE_TLS).toBe("true");
|
||||||
expect(argv).toContain("--relay-use-tls");
|
|
||||||
expect(options.env?.PASEO_RELAY_USE_TLS).toBe("true");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync, type ChildProcess } from "node:child_process";
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -68,6 +68,28 @@ interface ProcessExitDetails {
|
|||||||
|
|
||||||
type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & ProcessExitDetails);
|
type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & ProcessExitDetails);
|
||||||
|
|
||||||
|
export interface DetachedDaemonProcess extends Pick<ChildProcess, "once" | "pid" | "unref"> {}
|
||||||
|
|
||||||
|
export interface ForegroundDaemonProcessResult {
|
||||||
|
status: number | null;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DaemonLaunchRuntime {
|
||||||
|
resolveRunnerEntry(): string;
|
||||||
|
resolveHome(env: NodeJS.ProcessEnv): string;
|
||||||
|
spawnDetached(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options: Parameters<typeof spawnProcess>[2],
|
||||||
|
): DetachedDaemonProcess;
|
||||||
|
spawnForeground(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options: Parameters<typeof spawnSync>[2],
|
||||||
|
): ForegroundDaemonProcessResult;
|
||||||
|
}
|
||||||
|
|
||||||
const DETACHED_STARTUP_GRACE_MS = 1200;
|
const DETACHED_STARTUP_GRACE_MS = 1200;
|
||||||
const PID_POLL_INTERVAL_MS = 100;
|
const PID_POLL_INTERVAL_MS = 100;
|
||||||
const DAEMON_LOG_FILENAME = "daemon.log";
|
const DAEMON_LOG_FILENAME = "daemon.log";
|
||||||
@@ -78,6 +100,13 @@ export const DEFAULT_KILL_TIMEOUT_MS = 3_000;
|
|||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
const defaultDaemonLaunchRuntime: DaemonLaunchRuntime = {
|
||||||
|
resolveRunnerEntry: resolveDaemonRunnerEntry,
|
||||||
|
resolveHome: resolvePaseoHome,
|
||||||
|
spawnDetached: spawnProcess,
|
||||||
|
spawnForeground: spawnSync,
|
||||||
|
};
|
||||||
|
|
||||||
const startupReady = (): DetachedStartupResult => ({ exitedEarly: false });
|
const startupReady = (): DetachedStartupResult => ({ exitedEarly: false });
|
||||||
|
|
||||||
const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({
|
const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({
|
||||||
@@ -395,17 +424,18 @@ export function tailDaemonLog(home?: string, lines = 30): string | null {
|
|||||||
|
|
||||||
export async function startLocalDaemonDetached(
|
export async function startLocalDaemonDetached(
|
||||||
options: DaemonStartOptions,
|
options: DaemonStartOptions,
|
||||||
|
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
|
||||||
): Promise<DetachedStartResult> {
|
): Promise<DetachedStartResult> {
|
||||||
if (options.listen && options.port) {
|
if (options.listen && options.port) {
|
||||||
throw new Error("Cannot use --listen and --port together");
|
throw new Error("Cannot use --listen and --port together");
|
||||||
}
|
}
|
||||||
|
|
||||||
const daemonRunnerEntry = resolveDaemonRunnerEntry();
|
const daemonRunnerEntry = runtime.resolveRunnerEntry();
|
||||||
const childEnv = buildChildEnv(options);
|
const childEnv = buildChildEnv(options);
|
||||||
|
|
||||||
const paseoHome = resolvePaseoHome(childEnv);
|
const paseoHome = runtime.resolveHome(childEnv);
|
||||||
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME);
|
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME);
|
||||||
const child = spawnProcess(
|
const child = runtime.spawnDetached(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
|
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
|
||||||
{
|
{
|
||||||
@@ -461,14 +491,17 @@ export async function startLocalDaemonDetached(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startLocalDaemonForeground(options: DaemonStartOptions): number {
|
export function startLocalDaemonForeground(
|
||||||
|
options: DaemonStartOptions,
|
||||||
|
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
|
||||||
|
): number {
|
||||||
if (options.listen && options.port) {
|
if (options.listen && options.port) {
|
||||||
throw new Error("Cannot use --listen and --port together");
|
throw new Error("Cannot use --listen and --port together");
|
||||||
}
|
}
|
||||||
|
|
||||||
const daemonRunnerEntry = resolveDaemonRunnerEntry();
|
const daemonRunnerEntry = runtime.resolveRunnerEntry();
|
||||||
const childEnv = buildChildEnv(options);
|
const childEnv = buildChildEnv(options);
|
||||||
const result = spawnSync(
|
const result = runtime.spawnForeground(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
|
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user