Refactor daemon connection probe tests (#886)

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