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: ["*"],