diff --git a/packages/app/src/utils/test-daemon-connection.test.ts b/packages/app/src/utils/test-daemon-connection.test.ts index eb727d67f..b148e5bc1 100644 --- a/packages/app/src/utils/test-daemon-connection.test.ts +++ b/packages/app/src/utils/test-daemon-connection.test.ts @@ -1,154 +1,142 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { DaemonClientConfig } from "@server/client/daemon-client"; +import type { DaemonConnectionDependencies, DaemonProbeClient } from "./test-daemon-connection"; -const daemonClientMock = vi.hoisted(() => { - const createdConfigs: Array<{ clientId?: string; url?: string; password?: string }> = []; - let nextConnectError: Error | null = null; - let nextLastError: string | null = null; +class FakeDaemonClient implements DaemonProbeClient { + readonly lastError: string | null; - class MockDaemonClient { - public lastError: string | null = nextLastError; - private lastServerInfo = { - status: "server_info" as const, - serverId: "srv_probe_test", - hostname: "probe-host" as string | null, - version: "0.0.0", - }; + constructor( + private readonly probe: FakeDaemonProbe, + readonly config: DaemonClientConfig, + ) { + this.lastError = probe.nextLastError; + } - constructor(config: { clientId?: string; url?: string; password?: string }) { - createdConfigs.push(config); - } - - subscribeConnectionStatus(): () => void { - return () => undefined; - } - - on(): () => void { - return () => undefined; - } - - async connect(): Promise { - if (nextConnectError) { - throw nextConnectError; - } - return; - } - - getLastServerInfoMessage() { - return this.lastServerInfo; - } - - async ping(): Promise<{ rttMs: number }> { - return { rttMs: 42 }; - } - - async close(): Promise { - return; + async connect(): Promise { + if (this.probe.nextConnectError) { + throw this.probe.nextConnectError; } } - return { - MockDaemonClient, - createdConfigs, - setNextConnectFailure: (error: Error, lastError: string | null) => { - nextConnectError = error; - nextLastError = lastError; + getLastServerInfoMessage() { + return { + serverId: "srv_probe_test", + hostname: "probe-host", + }; + } + + async close(): Promise { + this.probe.closedClients.push(this); + } +} + +class FakeDaemonProbe { + createdClients: FakeDaemonClient[] = []; + closedClients: FakeDaemonClient[] = []; + clientIdsRequested = 0; + nextConnectError: Error | null = null; + nextLastError: string | null = null; + + readonly deps: DaemonConnectionDependencies = { + getClientId: async () => { + this.clientIdsRequested += 1; + return "cid_shared_probe_test"; }, - reset: () => { - createdConfigs.length = 0; - nextConnectError = null; - nextLastError = null; + resolveAppVersion: () => null, + createLocalTransportFactory: () => null, + buildLocalTransportUrl: ({ transportType, transportPath }) => + `paseo+local://${transportType}?path=${encodeURIComponent(transportPath)}`, + createClient: (config) => { + const client = new FakeDaemonClient(this, config); + this.createdClients.push(client); + return client; }, }; -}); -const clientIdMock = vi.hoisted(() => ({ - getOrCreateClientId: vi.fn(async () => "cid_shared_probe_test"), -})); + failNextConnection(error: Error, lastError: string | null): void { + this.nextConnectError = error; + this.nextLastError = lastError; + } -vi.mock("@server/client/daemon-client", () => ({ - DaemonClient: daemonClientMock.MockDaemonClient, -})); - -vi.mock("./client-id", () => ({ - getOrCreateClientId: clientIdMock.getOrCreateClientId, -})); - -vi.mock("@/desktop/daemon/desktop-daemon-transport", () => ({ - createDesktopLocalDaemonTransportFactory: vi.fn(() => null), - buildLocalDaemonTransportUrl: vi.fn( - ({ - transportType, - transportPath, - }: { - transportType: "socket" | "pipe"; - transportPath: string; - }) => `paseo+local://${transportType}?path=${encodeURIComponent(transportPath)}`, - ), -})); + createdConfigs(): DaemonClientConfig[] { + return this.createdClients.map((client) => client.config); + } +} describe("test-daemon-connection connectToDaemon", () => { + let probe: FakeDaemonProbe; + beforeEach(() => { - daemonClientMock.reset(); - clientIdMock.getOrCreateClientId.mockClear(); vi.stubGlobal("__DEV__", false); + probe = new FakeDaemonProbe(); }); it("reuses the app clientId for direct connections", async () => { - const mod = await import("./test-daemon-connection"); - - const first = await mod.connectToDaemon({ - id: "direct:lan:6767", - type: "directTcp", - endpoint: "lan:6767", - }); + const { connectToDaemon } = await import("./test-daemon-connection"); + const first = await connectToDaemon( + { + id: "direct:lan:6767", + type: "directTcp", + endpoint: "lan:6767", + }, + undefined, + probe.deps, + ); await first.client.close(); - const second = await mod.connectToDaemon({ - id: "direct:lan:6767", - type: "directTcp", - endpoint: "lan:6767", - }); + const second = await connectToDaemon( + { + id: "direct:lan:6767", + type: "directTcp", + endpoint: "lan:6767", + }, + undefined, + probe.deps, + ); await second.client.close(); - const [firstConfig, secondConfig] = daemonClientMock.createdConfigs; + const [firstConfig, secondConfig] = probe.createdConfigs(); expect(firstConfig?.clientId).toBe("cid_shared_probe_test"); expect(secondConfig?.clientId).toBe("cid_shared_probe_test"); - expect(clientIdMock.getOrCreateClientId).toHaveBeenCalledTimes(2); + expect(probe.clientIdsRequested).toBe(2); }); it("encodes the local socket target into the client config", async () => { - const mod = await import("./test-daemon-connection"); - - const result = await mod.connectToDaemon({ - id: "socket:/tmp/paseo.sock", - type: "directSocket", - path: "/tmp/paseo.sock", - }); + const { connectToDaemon } = await import("./test-daemon-connection"); + const result = await connectToDaemon( + { + id: "socket:/tmp/paseo.sock", + type: "directSocket", + path: "/tmp/paseo.sock", + }, + undefined, + probe.deps, + ); await result.client.close(); - expect(daemonClientMock.createdConfigs[0]?.url).toBe( - "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock", - ); + expect(probe.createdConfigs()[0]?.url).toBe("paseo+local://socket?path=%2Ftmp%2Fpaseo.sock"); }); it("passes direct TCP connection passwords into the client config", async () => { - const mod = await import("./test-daemon-connection"); - - const result = await mod.connectToDaemon({ - id: "direct:lan:6767", - type: "directTcp", - endpoint: "lan:6767", - password: "shared-secret", - }); + const { connectToDaemon } = await import("./test-daemon-connection"); + const result = await connectToDaemon( + { + id: "direct:lan:6767", + type: "directTcp", + endpoint: "lan:6767", + password: "shared-secret", + }, + undefined, + probe.deps, + ); await result.client.close(); - expect(daemonClientMock.createdConfigs[0]?.password).toBe("shared-secret"); + expect(probe.createdConfigs()[0]?.password).toBe("shared-secret"); }); it("uses relay TLS from the stored connection", async () => { - const mod = await import("./test-daemon-connection"); - - const tlsResult = await mod.connectToDaemon( + const { connectToDaemon } = await import("./test-daemon-connection"); + const tlsResult = await connectToDaemon( { id: "relay:wss:[::1]:443", type: "relay", @@ -157,10 +145,11 @@ describe("test-daemon-connection connectToDaemon", () => { daemonPublicKeyB64: "pubkey", }, { serverId: "srv_probe_test" }, + probe.deps, ); await tlsResult.client.close(); - const plainResult = await mod.connectToDaemon( + const plainResult = await connectToDaemon( { id: "relay:relay.paseo.sh:443", type: "relay", @@ -169,43 +158,52 @@ describe("test-daemon-connection connectToDaemon", () => { daemonPublicKeyB64: "pubkey", }, { serverId: "srv_probe_test" }, + probe.deps, ); await plainResult.client.close(); - expect(daemonClientMock.createdConfigs[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/); - expect(daemonClientMock.createdConfigs[1]?.url).toMatch(/^ws:\/\/relay\.paseo\.sh:443\/ws\?/); + expect(probe.createdConfigs()[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/); + expect(probe.createdConfigs()[1]?.url).toMatch(/^ws:\/\/relay\.paseo\.sh:443\/ws\?/); }); it("surfaces auth rejection as an incorrect password", async () => { - const mod = await import("./test-daemon-connection"); - daemonClientMock.setNextConnectFailure( + const { connectToDaemon } = await import("./test-daemon-connection"); + probe.failNextConnection( new Error("Transport closed (code 4001)"), "Transport closed (code 4001)", ); await expect( - mod.connectToDaemon({ - id: "direct:lan:6767", - type: "directTcp", - endpoint: "lan:6767", - password: "wrong-secret", - }), + connectToDaemon( + { + id: "direct:lan:6767", + type: "directTcp", + endpoint: "lan:6767", + password: "wrong-secret", + }, + undefined, + probe.deps, + ), ).rejects.toMatchObject({ message: "Incorrect password", }); }); it("keeps generic transport failures generic when a password was supplied", async () => { - const mod = await import("./test-daemon-connection"); - daemonClientMock.setNextConnectFailure(new Error("Transport error"), "Transport error"); + const { connectToDaemon } = await import("./test-daemon-connection"); + probe.failNextConnection(new Error("Transport error"), "Transport error"); await expect( - mod.connectToDaemon({ - id: "direct:lan:6767", - type: "directTcp", - endpoint: "lan:6767", - password: "shared-secret", - }), + connectToDaemon( + { + id: "direct:lan:6767", + type: "directTcp", + endpoint: "lan:6767", + password: "shared-secret", + }, + undefined, + probe.deps, + ), ).rejects.toMatchObject({ message: "Transport error", }); diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts index ed0677e27..11d732ffa 100644 --- a/packages/app/src/utils/test-daemon-connection.ts +++ b/packages/app/src/utils/test-daemon-connection.ts @@ -13,6 +13,34 @@ import { createDesktopLocalDaemonTransportFactory, } from "@/desktop/daemon/desktop-daemon-transport"; +export interface DaemonProbeClient { + readonly lastError: string | null; + connect(): Promise; + close(): Promise; + getLastServerInfoMessage(): { serverId: string; hostname: string | null } | null; +} + +interface LocalTransportUrlInput { + transportType: "socket" | "pipe"; + transportPath: string; +} + +export interface DaemonConnectionDependencies { + getClientId(): Promise; + resolveAppVersion(): string | null; + createLocalTransportFactory(): DaemonClientConfig["transportFactory"] | null; + buildLocalTransportUrl(input: LocalTransportUrlInput): string; + createClient(config: DaemonClientConfig): TClient; +} + +const defaultDaemonConnectionDependencies: DaemonConnectionDependencies = { + getClientId: getOrCreateClientId, + resolveAppVersion, + createLocalTransportFactory: createDesktopLocalDaemonTransportFactory, + buildLocalTransportUrl: buildLocalDaemonTransportUrl, + createClient: (config) => new DaemonClient(config), +}; + function normalizeNonEmptyString(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); @@ -69,13 +97,17 @@ export class DaemonConnectionTestError extends Error { export async function buildClientConfig( connection: HostConnection, serverId?: string, + deps: Pick< + DaemonConnectionDependencies, + "getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl" + > = defaultDaemonConnectionDependencies, ): Promise { - const clientId = await getOrCreateClientId(); - const localTransportFactory = createDesktopLocalDaemonTransportFactory(); + const clientId = await deps.getClientId(); + const localTransportFactory = deps.createLocalTransportFactory(); const base = { clientId, clientType: "mobile" as const, - appVersion: resolveAppVersion() ?? undefined, + appVersion: deps.resolveAppVersion() ?? undefined, suppressSendErrors: true, reconnect: { enabled: false }, ...((connection.type === "directSocket" || connection.type === "directPipe") && @@ -87,7 +119,7 @@ export async function buildClientConfig( if (connection.type === "directSocket" || connection.type === "directPipe") { return { ...base, - url: buildLocalDaemonTransportUrl({ + url: deps.buildLocalTransportUrl({ transportType: connection.type === "directSocket" ? "socket" : "pipe", transportPath: connection.path, }), @@ -120,10 +152,23 @@ export async function buildClientConfig( export function connectAndProbe( config: DaemonClientConfig, timeoutMs: number, -): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> { - const client = new DaemonClient(config); +): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>; +export function connectAndProbe( + config: DaemonClientConfig, + timeoutMs: number, + deps: Pick, "createClient">, +): Promise<{ client: TClient; serverId: string; hostname: string | null }>; +export function connectAndProbe( + config: DaemonClientConfig, + timeoutMs: number, + deps: Pick< + DaemonConnectionDependencies, + "createClient" + > = defaultDaemonConnectionDependencies, +): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> { + const client = deps.createClient(config); - return new Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>( + return new Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }>( (resolve, reject) => { const timer = setTimeout(() => { void client.close().catch(() => undefined); @@ -183,10 +228,20 @@ function resolveTimeout(connection: HostConnection, options?: ProbeOptions): num return connection.type === "relay" ? 10_000 : 6_000; } +export function connectToDaemon( + connection: HostConnection, + options?: ProbeOptions, +): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>; +export function connectToDaemon( + connection: HostConnection, + options: ProbeOptions | undefined, + deps: DaemonConnectionDependencies, +): Promise<{ client: TClient; serverId: string; hostname: string | null }>; export async function connectToDaemon( connection: HostConnection, options?: ProbeOptions, -): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> { - const config = await buildClientConfig(connection, options?.serverId); - return connectAndProbe(config, resolveTimeout(connection, options)); + deps: DaemonConnectionDependencies = defaultDaemonConnectionDependencies, +): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> { + const config = await buildClientConfig(connection, options?.serverId, deps); + return connectAndProbe(config, resolveTimeout(connection, options), deps); }