From b85b44aea15fe1ef50357aa4b8c9b65c25a9bcee Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:40:41 +0100 Subject: [PATCH] Show a host's new version after its daemon restarts (#5351) * Show a host's new version after its daemon restarts The app wrote a host's server info into the session store only when a client was first mounted, and a reconnect reuses the same client, so after a daemon upgrade or restart the host page and version checks kept the old version until the app restarted. The host runtime now projects the client's current server info into the session store on every connection change, keeping the last known value while disconnected. The React mount effect that duplicated this write is removed. Closes #5236 Co-authored-by: Leonid <127580858+bashrusakh@users.noreply.github.com> * Keep a caller-owned test daemon home across startup retries A failed start removed the home even with cleanup disabled, so a retry on a taken port came back with a new serverId. The host restart spec now drives a restartable host daemon helper that owns its port, home, and teardown. * Remove only the test daemon dirs a failed start created A failed attempt now removes the temp home and static dirs it created and leaves caller-supplied ones, so cleanup: false no longer leaks per retry. * Use function declarations in the test daemon helpers * test(app): cover host versions across daemon disconnect and reconnect The host settings badge retains its last known version during a transport failure; the help menu should show the replacement version after reconnect without changing another host. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Leonid <127580858+bashrusakh@users.noreply.github.com> Co-authored-by: Mohamed Boudra --- .../host-version-after-daemon-restart.spec.ts | 111 +++++++++++ .../support/helpers/isolated-host-daemon.ts | 11 +- .../support/helpers/versioned-host-daemon.ts | 176 ++++++++++++++++++ packages/app/src/contexts/session-context.tsx | 30 +-- packages/app/src/runtime/host-runtime.test.ts | 44 ++++- packages/app/src/runtime/host-runtime.ts | 8 + packages/app/src/stores/session-store.ts | 13 ++ .../server/test-utils/paseo-daemon.test.ts | 61 ++++++ .../src/server/test-utils/paseo-daemon.ts | 39 ++-- .../src/server/test-utils/versioned-daemon.ts | 22 ++- 10 files changed, 468 insertions(+), 47 deletions(-) create mode 100644 packages/app/e2e/browser/host-version-after-daemon-restart.spec.ts create mode 100644 packages/app/e2e/support/helpers/versioned-host-daemon.ts create mode 100644 packages/server/src/server/test-utils/paseo-daemon.test.ts diff --git a/packages/app/e2e/browser/host-version-after-daemon-restart.spec.ts b/packages/app/e2e/browser/host-version-after-daemon-restart.spec.ts new file mode 100644 index 0000000000..2ee1b4c229 --- /dev/null +++ b/packages/app/e2e/browser/host-version-after-daemon-restart.spec.ts @@ -0,0 +1,111 @@ +import type { Page } from "@playwright/test"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell, openSettings } from "../support/helpers/app"; +import { addConnectedHostAndReload } from "../support/helpers/hosts"; +import { openHostSection, selectSettingsHost } from "../support/helpers/settings"; +import { getServerId } from "../support/helpers/server-id"; +import { + startRestartableHostDaemon, + type RestartableHostDaemon, +} from "../support/helpers/versioned-host-daemon"; + +const PREVIOUS_VERSION = "0.8.0"; +const UPDATED_VERSION = "0.9.1"; +const HOST_LABEL = "Version restart QA"; +const NO_RELOAD_MARKER = "before-restart"; + +interface ReloadMarkerWindow { + __paseoE2eHostVersionMarker?: string; +} + +test.describe.configure({ timeout: 120_000 }); + +let hostDaemon: RestartableHostDaemon | null = null; + +test.afterEach(async () => { + await hostDaemon?.dispose(); + hostDaemon = null; +}); + +test("host page shows the restarted daemon's version without reloading", async ({ page }) => { + const host = await startRestartableHostDaemon(PREVIOUS_VERSION); + hostDaemon = host; + const otherHostVersion = + await test.step("the connected host reports its current daemon version", async () => { + await gotoAppShell(page); + await addConnectedHostAndReload(page, { + serverId: host.serverId, + label: HOST_LABEL, + port: host.port, + }); + const version = await readHelpHostVersion(page, getServerId()); + await expectHelpHostVersion(page, host.serverId, PREVIOUS_VERSION); + await openHostPage(page, host); + await expect(hostIdentity(page)).toContainText("Online"); + await expect(hostVersionBadge(page, PREVIOUS_VERSION)).toBeVisible(); + await markPageForNoReloadCheck(page, NO_RELOAD_MARKER); + return version; + }); + + await test.step("the last known version remains on the host page when the host stops", async () => { + await host.stop(); + await expect(hostIdentity(page)).toContainText("Error"); + await expect(hostVersionBadge(page, PREVIOUS_VERSION)).toBeVisible(); + }); + + await test.step("the host daemon restarts on the new version", async () => { + await host.restartWithVersion(UPDATED_VERSION); + }); + + await test.step("the host page badge follows the new version", async () => { + await expect(hostVersionBadge(page, UPDATED_VERSION)).toBeVisible({ timeout: 30_000 }); + await expect(hostVersionBadge(page, PREVIOUS_VERSION)).toHaveCount(0); + await page.getByRole("button", { name: "Back" }).click(); + await expectHelpHostVersion(page, host.serverId, UPDATED_VERSION); + expect(await readHelpHostVersion(page, getServerId())).toBe(otherHostVersion); + await expectNoReloadSinceMarker(page); + }); +}); + +async function readHelpHostVersion(page: Page, serverId: string): Promise { + await page.getByTestId("sidebar-help").click(); + const row = page.getByTestId(`sidebar-help-host-version-${serverId}`); + await expect(row).toBeVisible(); + const text = await row.innerText(); + await page.keyboard.press("Escape"); + return text; +} + +async function expectHelpHostVersion(page: Page, serverId: string, version: string): Promise { + const text = await readHelpHostVersion(page, serverId); + expect(text).toContain(`v${version}`); +} + +async function openHostPage(page: Page, host: RestartableHostDaemon): Promise { + await openSettings(page); + await selectSettingsHost(page, host.serverId); + await openHostSection(page, host.serverId, "host"); +} + +function hostIdentity(page: Page) { + return page.getByTestId("host-page-identity"); +} + +function hostVersionBadge(page: Page, version: string) { + return hostIdentity(page).getByText(`v${version}`, { exact: true }); +} + +async function markPageForNoReloadCheck(page: Page, marker: string): Promise { + // The marker has to cross into the page as an argument: the callback runs in the browser, so a + // captured Node-side constant is not defined there. + await page.evaluate((value) => { + (window as typeof window & ReloadMarkerWindow).__paseoE2eHostVersionMarker = value; + }, marker); +} + +async function expectNoReloadSinceMarker(page: Page): Promise { + const marker = await page.evaluate( + () => (window as typeof window & ReloadMarkerWindow).__paseoE2eHostVersionMarker ?? null, + ); + expect(marker).toBe(NO_RELOAD_MARKER); +} diff --git a/packages/app/e2e/support/helpers/isolated-host-daemon.ts b/packages/app/e2e/support/helpers/isolated-host-daemon.ts index 1467f3d572..01fe3a70c1 100644 --- a/packages/app/e2e/support/helpers/isolated-host-daemon.ts +++ b/packages/app/e2e/support/helpers/isolated-host-daemon.ts @@ -41,6 +41,13 @@ async function getAvailablePort(): Promise { }); } +export async function getAvailableHostDaemonPort(): Promise { + const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0); + let port = await getAvailablePort(); + while (port === 6767 || port === 6768 || port === primaryPort) port = await getAvailablePort(); + return port; +} + async function waitForServer(port: number, child: ChildProcess): Promise { const deadline = Date.now() + 90_000; let lastError: unknown = null; @@ -81,9 +88,7 @@ export async function startIsolatedHostDaemon( serverId: string, options: IsolatedHostDaemonOptions = {}, ): Promise { - const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0); - let port = await getAvailablePort(); - while (port === 6767 || port === 6768 || port === primaryPort) port = await getAvailablePort(); + const port = await getAvailableHostDaemonPort(); const metroPort = process.env.E2E_METRO_PORT; if (!metroPort) throw new Error("E2E_METRO_PORT is required to start an isolated host daemon"); diff --git a/packages/app/e2e/support/helpers/versioned-host-daemon.ts b/packages/app/e2e/support/helpers/versioned-host-daemon.ts new file mode 100644 index 0000000000..ee281a322f --- /dev/null +++ b/packages/app/e2e/support/helpers/versioned-host-daemon.ts @@ -0,0 +1,176 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import net from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { getAvailableHostDaemonPort } from "./isolated-host-daemon"; +import { killProcessTree, spawnTsx } from "./spawn-node"; + +const VERSIONED_DAEMON_ENTRYPOINT = path.resolve( + __dirname, + "../../../../server/src/server/test-utils/versioned-daemon.ts", +); + +const READY_TIMEOUT_MS = 30_000; + +// A host daemon that can be replaced by a daemon reporting another version under the same +// endpoint and serverId, the way an upgraded host comes back. +export interface RestartableHostDaemon { + serverId: string; + port: number; + stop(): Promise; + restartWithVersion(version: string): Promise; + dispose(): Promise; +} + +export async function startRestartableHostDaemon(version: string): Promise { + const paseoHomeRoot = await mkdtemp(path.join(tmpdir(), "paseo-e2e-restartable-host-")); + const port = await getAvailableHostDaemonPort(); + let current: VersionedHostDaemon | null = null; + async function dispose(): Promise { + await current?.stop(); + current = null; + await rm(paseoHomeRoot, { recursive: true, force: true }); + } + try { + current = await startVersionedHostDaemon({ version, port, paseoHomeRoot }); + } catch (error) { + await dispose(); + throw error; + } + const { serverId } = current; + + return { + serverId, + port, + async stop() { + await current?.stop(); + current = null; + await waitForPortReleased(port); + }, + async restartWithVersion(nextVersion) { + await current?.stop(); + current = null; + await waitForPortReleased(port); + current = await startVersionedHostDaemon({ version: nextVersion, port, paseoHomeRoot }); + if (current.serverId !== serverId) { + throw new Error( + `Restarted host daemon reported serverId ${current.serverId}, expected ${serverId}`, + ); + } + }, + dispose, + }; +} + +interface VersionedHostDaemon { + serverId: string; + port: number; + version: string; + stop(): Promise; +} + +interface VersionedHostDaemonOptions { + version: string; + port: number; + paseoHomeRoot: string; +} + +// Starts the versioned test daemon on an explicit port with a caller-owned PASEO_HOME. The caller +// removes the home root. +async function startVersionedHostDaemon( + options: VersionedHostDaemonOptions, +): Promise { + const child = spawnTsx( + VERSIONED_DAEMON_ENTRYPOINT, + [options.version, String(options.port), options.paseoHomeRoot], + { stdio: ["ignore", "pipe", "pipe", "ipc"] }, + ); + const ready = Promise.withResolvers<{ port: number; serverId: string }>(); + let output = ""; + child.stdout?.on("data", (chunk: Buffer) => { + output += chunk.toString("utf8"); + }); + child.stderr?.on("data", (chunk: Buffer) => { + output += chunk.toString("utf8"); + }); + child.once("error", ready.reject); + child.once("exit", () => { + ready.reject( + new Error(`Versioned daemon ${options.version} exited before reporting ready:\n${output}`), + ); + }); + child.once("message", (message) => { + if (!isReadyMessage(message)) { + ready.reject(new Error("Invalid versioned daemon startup message")); + return; + } + ready.resolve(message); + }); + const timeout = setTimeout( + () => + ready.reject( + new Error( + `Versioned daemon ${options.version} did not report ready within ${READY_TIMEOUT_MS}ms:\n${output}`, + ), + ), + READY_TIMEOUT_MS, + ); + + let stopped = false; + async function stop(): Promise { + if (stopped) return; + stopped = true; + await killProcessTree(child); + } + + try { + const readyMessage = await ready.promise; + if (readyMessage.port !== options.port) { + throw new Error( + `Versioned daemon ${options.version} bound port ${readyMessage.port}, expected ${options.port}`, + ); + } + return { + serverId: readyMessage.serverId, + port: readyMessage.port, + version: options.version, + stop, + }; + } catch (error) { + await stop(); + throw error; + } finally { + clearTimeout(timeout); + } +} + +// A replacement daemon can only bind the port the stopped daemon just released. +async function waitForPortReleased(port: number, timeoutMs = 15_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await isPortAvailable(port)) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`Port ${port} was not released within ${timeoutMs}ms`); +} + +function isReadyMessage(message: unknown): message is { port: number; serverId: string } { + return ( + typeof message === "object" && + message !== null && + "port" in message && + typeof message.port === "number" && + "serverId" in message && + typeof message.serverId === "string" + ); +} + +function isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => { + server.close(() => resolve(true)); + }); + }); +} diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index c1aa3b1f2b..6a2c42a617 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -32,6 +32,7 @@ import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts import type { AudioPlaybackSource } from "@/voice/audio-engine-types"; import { selectAgentTimelineState, + toDaemonServerInfo, useSessionStore, type SessionState, } from "@/stores/session-store"; @@ -318,24 +319,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider [serverId], ); - useEffect(() => { - const serverInfo = client.getLastServerInfoMessage(); - if (!serverInfo) { - return; - } - - updateSessionServerInfo(serverId, { - serverId: serverInfo.serverId, - hostname: serverInfo.hostname, - version: serverInfo.version, - ...(serverInfo.desktopManaged !== undefined - ? { desktopManaged: serverInfo.desktopManaged } - : {}), - ...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}), - ...(serverInfo.features ? { features: serverInfo.features } : {}), - }); - }, [client, serverId, updateSessionServerInfo]); - useEffect(() => { const unregister = voiceRuntime?.registerSession({ serverId, @@ -568,16 +551,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider if (message.type !== "status") return; const serverInfo = parseServerInfoStatusPayload(message.payload); if (serverInfo) { - updateSessionServerInfo(serverId, { - serverId: serverInfo.serverId, - hostname: serverInfo.hostname, - version: serverInfo.version, - ...(serverInfo.desktopManaged !== undefined - ? { desktopManaged: serverInfo.desktopManaged } - : {}), - ...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}), - ...(serverInfo.features ? { features: serverInfo.features } : {}), - }); + updateSessionServerInfo(serverId, toDaemonServerInfo(serverInfo)); return; } }); diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 691e468363..280eae793e 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -158,17 +158,28 @@ class FakeDaemonClient { } public ownedSubscriptions = true; + private daemonVersion: string | null = "0.8.0"; getLastServerInfoMessage(): ReturnType { + if (this.daemonVersion === null) return null; return { status: "server_info", serverId: "srv_test", hostname: "test", - version: "0.8.0", + version: this.daemonVersion, features: { ownedSubscriptions: this.ownedSubscriptions }, }; } + // Like the real client, the server info is cleared while disconnected and replaced by the + // restarted daemon's handshake before the client reports connected again. + daemonRestartsAs(version: string): void { + this.daemonVersion = null; + this.setConnectionState({ status: "disconnected", reason: "Connection lost" }); + this.daemonVersion = version; + this.setConnectionState({ status: "connected" }); + } + subscribeConnectionStatus(listener: (status: ConnectionState) => void): () => void { this.listeners.add(listener); listener(this.state); @@ -2129,6 +2140,37 @@ describe("HostRuntimeStore", () => { expect(useSessionStore.getState().sessions[host.serverId]).toBeUndefined(); }); + it("shows the restarted daemon's version after the same client reconnects", async () => { + const host = makeHost({ + serverId: "srv_restarted", + connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }], + }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async ({ host: hostProfile }) => ({ + client: fakeClient as unknown as DaemonClient, + serverId: hostProfile.serverId, + hostname: hostProfile.label ?? null, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + store.syncHosts([host]); + await waitForHostOnline(store, host.serverId); + expect(useSessionStore.getState().sessions[host.serverId]?.serverInfo?.version).toBe("0.8.0"); + + fakeClient.daemonRestartsAs("0.9.1"); + + expect(store.getSnapshot(host.serverId)?.client).toBe(fakeClient); + expect(useSessionStore.getState().sessions[host.serverId]?.serverInfo?.version).toBe("0.9.1"); + + store.syncHosts([]); + }); + it("drains snapshot and buffered running transitions exactly once", async () => { const host = makeHost({ serverId: "srv_legacy_transitions", diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 2cea05144d..7328e7e5b4 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -50,6 +50,7 @@ import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas"; import { useSessionStore, + toDaemonServerInfo, type Agent, type WorkspaceDescriptor, type ProjectDescriptor, @@ -2266,6 +2267,13 @@ export class HostRuntimeStore { const sessionStore = useSessionStore.getState(); sessionStore.initializeSession(serverId, snapshot.client, snapshot.clientGeneration); sessionStore.updateSessionClient(serverId, snapshot.client, snapshot.clientGeneration); + // A reconnect keeps the same client, so the daemon's handshake (a restart or upgrade can + // change its version and features) only reaches the store here. The client clears it while + // disconnected; keep the last known value until the next handshake. + const serverInfo = snapshot.client.getLastServerInfoMessage(); + if (serverInfo) { + sessionStore.updateSessionServerInfo(serverId, toDaemonServerInfo(serverInfo)); + } } private clearHostReplica(serverId: string): void { diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index dd5469df26..0d59b1c6b5 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -285,6 +285,19 @@ export interface DaemonServerInfo { features?: ServerInfoStatusPayload["features"]; } +export function toDaemonServerInfo(serverInfo: ServerInfoStatusPayload): DaemonServerInfo { + return { + serverId: serverInfo.serverId, + hostname: serverInfo.hostname ?? null, + version: serverInfo.version ?? null, + ...(serverInfo.desktopManaged !== undefined + ? { desktopManaged: serverInfo.desktopManaged } + : {}), + ...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}), + ...(serverInfo.features ? { features: serverInfo.features } : {}), + }; +} + export interface AgentTimelineCursorState { epoch: string; startSeq: number; diff --git a/packages/server/src/server/test-utils/paseo-daemon.test.ts b/packages/server/src/server/test-utils/paseo-daemon.test.ts new file mode 100644 index 0000000000..4ee417c221 --- /dev/null +++ b/packages/server/src/server/test-utils/paseo-daemon.test.ts @@ -0,0 +1,61 @@ +import { existsSync } from "node:fs"; +import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { createTestPaseoDaemon } from "./paseo-daemon.js"; + +describe("createTestPaseoDaemon", () => { + const cleanups: Array<() => Promise> = []; + + afterEach(async () => { + vi.unstubAllEnvs(); + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); + }); + + test("keeps a caller-owned home when startup on a taken port fails", async () => { + const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-owned-home-")); + cleanups.push(() => rm(paseoHomeRoot, { recursive: true, force: true })); + const ownedFile = path.join(paseoHomeRoot, "owned-by-caller"); + await writeFile(ownedFile, "keep"); + const takenPort = await occupyPort(cleanups); + + await expect( + createTestPaseoDaemon({ + listenPort: takenPort, + paseoHomeRoot, + staticDir: path.join(paseoHomeRoot, "static"), + cleanup: false, + mcpEnabled: false, + }), + ).rejects.toMatchObject({ code: "EADDRINUSE" }); + + expect(existsSync(ownedFile)).toBe(true); + }, 60_000); + + test("removes the directories it created when startup on a taken port fails", async () => { + const scratchTmp = await mkdtemp(path.join(os.tmpdir(), "paseo-daemon-scratch-")); + cleanups.push(() => rm(scratchTmp, { recursive: true, force: true })); + for (const name of ["TMPDIR", "TMP", "TEMP"]) vi.stubEnv(name, scratchTmp); + const takenPort = await occupyPort(cleanups); + + await expect( + createTestPaseoDaemon({ listenPort: takenPort, cleanup: false, mcpEnabled: false }), + ).rejects.toMatchObject({ code: "EADDRINUSE" }); + + expect(await readdir(scratchTmp)).toEqual([]); + }, 60_000); +}); + +async function occupyPort(cleanups: Array<() => Promise>): Promise { + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + cleanups.push(() => new Promise((resolve) => server.close(() => resolve()))); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Port holder did not bind a TCP port"); + } + return address.port; +} diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 1c038bf377..91b125328c 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -21,6 +21,7 @@ interface TestPaseoDaemonOptions { downloadTokenTtlMs?: number; corsAllowedOrigins?: string[]; listen?: string; + listenPort?: number; logger?: Parameters[1]; mcpEnabled?: boolean; mcpDebug?: boolean; @@ -99,7 +100,8 @@ export async function createTestPaseoDaemon( let lastError: unknown; for (let attempt = 0; attempt < maxAttempts; attempt += 1) { - const { config, paseoHomeRoot, paseoHome, staticDir } = await prepareTestDaemonConfig(options); + const { config, paseoHomeRoot, paseoHome, staticDir, createdDirs } = + await prepareTestDaemonConfig(options); const logger = options.logger ?? pino({ level: "silent" }); const daemon = await createPaseoDaemon(config, logger, { builtinPlugins: options.builtinPlugins ?? new BuiltinPluginLoader(undefined, []), @@ -120,10 +122,7 @@ export async function createTestPaseoDaemon( await daemon.agentManager.flush().catch(() => undefined); if (options.cleanup ?? true) { await new Promise((r) => setTimeout(r, 50)); - await Promise.all([ - rm(paseoHomeRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }), - rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }), - ]); + await removeDirs([paseoHomeRoot, staticDir]); } }; @@ -138,10 +137,8 @@ export async function createTestPaseoDaemon( } catch (error) { lastError = error; await daemon.stop().catch(() => undefined); - await Promise.all([ - rm(paseoHomeRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }), - rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }), - ]); + // A failed attempt removes only what it created: a caller-supplied home keeps its serverId. + await removeDirs(createdDirs); if ( (!isAddressInUseError(error) && !isStartupTimeoutError(error)) || @@ -160,19 +157,33 @@ interface PreparedTestDaemonConfig { paseoHomeRoot: string; paseoHome: string; staticDir: string; + createdDirs: string[]; +} + +async function removeDirs(dirs: string[]): Promise { + await Promise.all( + dirs.map((dir) => rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })), + ); +} + +async function createTempDir(prefix: string, createdDirs: string[]): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), prefix)); + createdDirs.push(dir); + return dir; } async function prepareTestDaemonConfig( options: TestPaseoDaemonOptions, ): Promise { - const paseoHomeRoot = - options.paseoHomeRoot ?? (await mkdtemp(path.join(os.tmpdir(), "paseo-home-"))); + const createdDirs: string[] = []; + const paseoHomeRoot = options.paseoHomeRoot ?? (await createTempDir("paseo-home-", createdDirs)); const paseoHome = path.join(paseoHomeRoot, ".paseo"); await mkdir(paseoHome, { recursive: true }); - const staticDir = options.staticDir ?? (await mkdtemp(path.join(os.tmpdir(), "paseo-static-"))); + const staticDir = options.staticDir ?? (await createTempDir("paseo-static-", createdDirs)); const listenHost = options.listen ?? "127.0.0.1"; + const listenPort = options.listenPort ?? 0; const config: PaseoDaemonConfig = { - listen: `${listenHost}:0`, + listen: `${listenHost}:${listenPort}`, paseoHome, daemonVersion: options.daemonVersion, desktopManaged: options.desktopManaged, @@ -207,7 +218,7 @@ async function prepareTestDaemonConfig( pluginsEnabled: options.pluginsEnabled, plugins: options.plugins, }; - return { config, paseoHomeRoot, paseoHome, staticDir }; + return { config, paseoHomeRoot, paseoHome, staticDir, createdDirs }; } function isAddressInUseError(error: unknown): boolean { diff --git a/packages/server/src/server/test-utils/versioned-daemon.ts b/packages/server/src/server/test-utils/versioned-daemon.ts index e1b7433034..f8763e0d6f 100644 --- a/packages/server/src/server/test-utils/versioned-daemon.ts +++ b/packages/server/src/server/test-utils/versioned-daemon.ts @@ -1,8 +1,28 @@ // Browser compatibility tests run a real daemon in a separate Node runtime. +// +// argv: [port] [paseoHomeRoot] +// A fixed port and a caller-owned home let a test stop this daemon and start a +// replacement under the same endpoint and serverId. +import path from "node:path"; import { createTestPaseoDaemon } from "./paseo-daemon.js"; +const [, , daemonVersion, portArg, paseoHomeRoot] = process.argv; +const listenPort = portArg ? Number(portArg) : 0; +if (!Number.isInteger(listenPort) || listenPort < 0) { + throw new Error(`Invalid versioned daemon port: ${portArg ?? ""}`); +} + const daemon = await createTestPaseoDaemon({ - daemonVersion: process.argv[2], + daemonVersion, + ...(listenPort > 0 ? { listenPort } : {}), + ...(paseoHomeRoot + ? { + paseoHomeRoot, + staticDir: path.join(paseoHomeRoot, "static"), + // The caller removes the home so a replacement daemon keeps the same serverId. + cleanup: false, + } + : {}), pluginsEnabled: true, mcpEnabled: false, corsAllowedOrigins: ["*"],