import { spawn, type ChildProcess } from "node:child_process"; import { getEventListeners } from "node:events"; import { access } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { OFFICIAL_TOOL_CONTRACTS } from "../src/contracts/toolContracts.js"; import type { HopperStartupDiagnostic } from "../src/domain/hopperStartupFailure.js"; import { projectAnalysisError } from "../src/domain/errors.js"; import { ok } from "../src/domain/result.js"; import type { BridgeLauncher, BridgeSession, } from "../src/hopper/BridgeLauncher.js"; import { HopperClient } from "../src/hopper/HopperClient.js"; import { LINUX_PRIVATE_DISPLAY_DIAGNOSTIC_PREFIX } from "../src/hopper/LinuxPrivateDisplayDiagnostic.js"; import { providerCleanupFailure } from "../src/hopper/HopperDiagnostics.js"; import { cleanupOwnedProcessGroup } from "../src/process/ProcessOwnership.js"; import { spawnOwnedProviderProcess } from "../src/process/ProviderProcess.js"; const fixturePath = fileURLToPath( new URL("./fixtures/fakeHopper.mjs", import.meta.url), ); class FixtureLauncher implements BridgeLauncher { socketPaths: string[] = []; directories: string[] = []; runIds: string[] = []; processes: ChildProcess[] = []; constructor(readonly tokenOverride?: string) {} launch(session: BridgeSession) { this.socketPaths.push(session.socketPath); this.directories.push(session.directory); this.runIds.push(session.runId); const child = spawn( process.execPath, [ fixturePath, session.socketPath, this.tokenOverride ?? session.token, session.runId, ], { stdio: ["ignore", "ignore", "pipe"], }, ); this.processes.push(child); return Promise.resolve( ok({ process: child, ownsProcessLifetime: true, }), ); } } class SilentLauncher implements BridgeLauncher { launch() { return Promise.resolve( ok({ process: spawn( process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: ["ignore", "ignore", "pipe"], }, ), ownsProcessLifetime: true, }), ); } } class OwnedFixtureLauncher implements BridgeLauncher { ownership: | { readonly runId: string; readonly leaderPid: number; readonly processGroupId: number; } | undefined; async launch(session: BridgeSession) { const started = await spawnOwnedProviderProcess({ command: process.execPath, arguments: [ fixturePath, session.socketPath, session.token, session.runId, "cleanup-required", ], runId: session.runId, expectedCommand: null, }); this.ownership = started.ownership; return ok({ process: started.process, ownsProcessLifetime: true, ownership: started.ownership, shutdownByCleanup: true, cleanup: () => cleanupOwnedProcessGroup(started.ownership), }); } } class UnconfirmedFixtureLauncher implements BridgeLauncher { launch(session: BridgeSession) { return Promise.resolve( ok({ process: spawn( process.execPath, [ fixturePath, session.socketPath, session.token, session.runId, "unconfirmed", ], { stdio: ["ignore", "ignore", "pipe"] }, ), ownsProcessLifetime: false, }), ); } } class CancelThenFixtureLauncher implements BridgeLauncher { #launches = 0; launch(session: BridgeSession) { this.#launches += 1; return this.#launches === 1 ? new SilentLauncher().launch() : new FixtureLauncher().launch(session); } } class ExitingLauncher implements BridgeLauncher { constructor(readonly code: number) {} launch() { return Promise.resolve( ok({ process: spawn(process.execPath, ["-e", `process.exit(${this.code})`], { stdio: ["ignore", "ignore", "pipe"], }), ownsProcessLifetime: true, }), ); } } class DiagnosticExitingLauncher implements BridgeLauncher { launch() { const diagnostic: HopperStartupDiagnostic = { schema_version: 1, component: "hopper_private_display", operation: "launch", status: "error", failure_code: "x11_socket_directory_unusable", reason: "socket_directory_read_only", socket_directory: "/tmp/.X11-unix", socket_directory_mode: "0777", mount_read_only: true, effective_socket_directory_mode: "0777", effective_mount_read_only: true, wsl: true, strategy: "direct", fallback_reason: null, xvfb_stderr_bytes: 100, xvfb_stderr_truncated: false, }; const line = `${LINUX_PRIVATE_DISPLAY_DIAGNOSTIC_PREFIX}${JSON.stringify(diagnostic)}\n`; return Promise.resolve( ok({ process: spawn( process.execPath, [ "-e", `process.stderr.write(${JSON.stringify(line)}, () => process.exit(80))`, ], { stdio: ["ignore", "ignore", "pipe"] }, ), ownsProcessLifetime: true, }), ); } } class LateSilentLauncher implements BridgeLauncher { readonly directories: string[] = []; readonly processes: ChildProcess[] = []; async launch(session: BridgeSession) { this.directories.push(session.directory); const launched = await new SilentLauncher().launch(); if (launched.ok) this.processes.push(launched.value.process); await new Promise((resolve) => setTimeout(resolve, 50)); return launched; } } const clients: HopperClient[] = []; const startClient = async () => { const client = new HopperClient({ launcher: new FixtureLauncher(), requestTimeoutMs: 100, startupTimeoutMs: 1_000, }); clients.push(client); await expect(client.start()).resolves.toEqual({ ok: true, value: { name: "REA Hopper bridge", version: "1.0.0" }, }); return client; }; afterEach(async () => { await Promise.all(clients.splice(0).map((client) => client.close())); }); describe("HopperClient", () => { it("sanitizes unexpected owned-cleanup exceptions", () => { expect( providerCleanupFailure( new Error("secret-run-token 22222222-2222-4222-8222-222222222222"), ), ).toEqual({ cleaned: false, reason: "owned provider cleanup failed", }); }); it("uses the caller-assigned provider run identity", async () => { const launcher = new FixtureLauncher(); const client = new HopperClient({ launcher, runId: "11111111-1111-4111-8111-111111111111", startupTimeoutMs: 1_000, }); clients.push(client); expect((await client.start()).ok).toBe(true); expect(launcher.runIds).toEqual(["11111111-1111-4111-8111-111111111111"]); }); it("keeps the native socket path below macOS sockaddr_un limits", async () => { const launcher = new FixtureLauncher(); const client = new HopperClient({ launcher, startupTimeoutMs: 1_000 }); clients.push(client); const started = await client.start(); expect(started.ok).toBe(true); expect(Buffer.byteLength(launcher.socketPaths[0] ?? "")).toBeLessThan(104); }); it("serializes concurrent calls until the active Hopper reply arrives", async () => { const client = await startClient(); const slow = client.callTool("echo", { label: "slow", delay: 30 }); const fast = client.callTool("echo", { label: "fast", delay: 1 }); let fastSettled = false; void fast.then(() => { fastSettled = true; }); await new Promise((resolve) => setTimeout(resolve, 10)); expect(fastSettled).toBe(false); await expect(Promise.all([slow, fast])).resolves.toEqual([ { ok: true, value: { label: "slow", delay: 30 } }, { ok: true, value: { label: "fast", delay: 1 } }, ]); }); it("routes every established operation through the authenticated bridge", async () => { const client = await startClient(); const results = await Promise.all( OFFICIAL_TOOL_CONTRACTS.map(({ name }) => client.callTool(name, {})), ); expect(results).toHaveLength(34); expect(results.every((result) => result.ok)).toBe(true); }); it("rejects a bridge session with the wrong capability token", async () => { const client = new HopperClient({ launcher: new FixtureLauncher("wrong-token"), startupTimeoutMs: 1_000, }); clients.push(client); const result = await client.start(); expect(result.ok).toBe(false); if (!result.ok) expect(result.error._tag).toBe("HopperRemoteError"); }); it.each([ ["malformed", "HopperProtocolError"], ["wrong_id", "HopperProtocolError"], ["remote_error", "HopperRemoteError"], ["hang", "HopperTimeoutError"], ["exit", "HopperProcessError"], ])("projects %s as %s", async (method, expectedTag) => { const client = await startClient(); const result = await client.callTool(method); expect(result.ok).toBe(false); if (!result.ok) expect(result.error._tag).toBe(expectedTag); }); it("preserves a sanitized bridge exception diagnostic", async () => { const client = await startClient(); const result = await client.callTool("remote_error"); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toMatchObject({ _tag: "HopperRemoteError", diagnosticType: "bridge_exception", }); }); it("cancels and ignores late responses without corrupting the session", async () => { const client = await startClient(); const controller = new AbortController(); const pending = client.callTool("hang", {}, { signal: controller.signal }); controller.abort(); const cancelled = await pending; expect(cancelled.ok).toBe(false); if (!cancelled.ok) expect(cancelled.error._tag).toBe("HopperCancelledError"); const timedOut = await client.callTool( "echo", { delay: 30 }, { timeoutMs: 5 }, ); expect(timedOut.ok).toBe(false); await new Promise((resolve) => setTimeout(resolve, 40)); await expect(client.callTool("echo", { value: "alive" })).resolves.toEqual({ ok: true, value: { value: "alive" }, }); }); it("retains timed-out bridge activity until the late reply releases the queue", async () => { const client = await startClient(); const progress: string[] = []; const result = await client.callTool( "echo", { delay: 60 }, { timeoutMs: 5, progress: { report: (update) => { progress.push(update.message); return Promise.resolve(); }, }, }, ); expect(result).toMatchObject({ ok: false, error: { _tag: "HopperTimeoutError" }, }); expect(client.requestActivity()).toMatchObject({ operation: "echo", callerState: "timed_out", timeoutMs: 5, }); expect(progress).toEqual( expect.arrayContaining([ expect.stringContaining("started on Hopper's serial bridge"), ]), ); await new Promise((resolve) => setTimeout(resolve, 70)); expect(client.requestActivity()).toBeNull(); await expect(client.callTool("echo", { alive: true })).resolves.toEqual({ ok: true, value: { alive: true }, }); }); it("cancels bridge startup without waiting for the startup timeout", async () => { const client = new HopperClient({ launcher: new CancelThenFixtureLauncher(), startupTimeoutMs: 10_000, }); clients.push(client); const controller = new AbortController(); const startedAt = Date.now(); const pending = client.callTool("echo", {}, { signal: controller.signal }); setTimeout(() => { controller.abort(); }, 10); const result = await pending; expect(result.ok).toBe(false); if (!result.ok) expect(result.error._tag).toBe("HopperCancelledError"); expect(Date.now() - startedAt).toBeLessThan(500); await expect(client.start()).resolves.toEqual({ ok: true, value: { name: "REA Hopper bridge", version: "1.0.0" }, }); await expect( client.callTool("echo", { value: "retried" }), ).resolves.toEqual({ ok: true, value: { value: "retried" }, }); }); it("applies one deadline to launcher, socket, and health startup phases", async () => { const launcher = new LateSilentLauncher(); const client = new HopperClient({ launcher, startupTimeoutMs: 20 }); clients.push(client); const result = await client.start(); expect(result).toMatchObject({ ok: false, error: { _tag: "HopperTimeoutError", timeoutMs: 20 }, }); expect(launcher.directories).toHaveLength(1); await expect(access(launcher.directories[0] ?? "")).rejects.toMatchObject({ code: "ENOENT", }); const process = launcher.processes[0]; expect( process !== undefined && (process.exitCode !== null || process.signalCode !== null), ).toBe(true); }); it.each([70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80])( "reports Linux adapter exit %i during bridge startup", async (exitCode) => { const client = new HopperClient({ launcher: new ExitingLauncher(exitCode), startupTimeoutMs: 10_000, }); clients.push(client); const result = await client.start(); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toMatchObject({ _tag: "HopperProcessError", exitCode, }); }, ); it("preserves the bounded private-display diagnostic from an adapter exit", async () => { const client = new HopperClient({ launcher: new DiagnosticExitingLauncher(), startupTimeoutMs: 10_000, }); clients.push(client); const result = await client.start(); expect(result).toMatchObject({ ok: false, error: { _tag: "HopperProcessError", exitCode: 80, failureCode: "x11_socket_directory_unusable", diagnostic: { socket_directory: "/tmp/.X11-unix", socket_directory_mode: "0777", mount_read_only: true, wsl: true, strategy: "direct", }, }, }); }); it("allows a short-lived launcher to hand off bridge startup", async () => { const client = new HopperClient({ launcher: new ExitingLauncher(0), startupTimeoutMs: 100, }); clients.push(client); const result = await client.start(); expect(result.ok).toBe(false); if (!result.ok) expect(result.error._tag).toBe("HopperTimeoutError"); }); it("ignores more than 1,024 late responses without corrupting the session", async () => { const client = await startClient(); const timedOut = await Promise.all( Array.from({ length: 1_025 }, (_, index) => client.callTool("echo", { index, delay: 150 }, { timeoutMs: 5 }), ), ); expect(timedOut.every((result) => !result.ok)).toBe(true); await new Promise((resolve) => setTimeout(resolve, 200)); await expect(client.callTool("echo", { value: "alive" })).resolves.toEqual({ ok: true, value: { value: "alive" }, }); }); it("makes concurrent close callers await the same shutdown", async () => { const client = await startClient(); let firstSettled = false; const first = client.close().then(() => { firstSettled = true; }); await client.close(); expect(firstSettled).toBe(true); await first; }); it("reports unconfirmed unowned document cleanup as a typed close failure", async () => { const client = new HopperClient({ launcher: new UnconfirmedFixtureLauncher(), startupTimeoutMs: 1_000, }); clients.push(client); expect((await client.start()).ok).toBe(true); const closed = await client.closeWithOutcome(); expect(closed).toMatchObject({ ok: false, error: { _tag: "ProviderAdapterError", cleanupIncomplete: true, cleanupResources: ["hopper-document"], }, }); if (!closed.ok) expect(projectAnalysisError(closed.error)).toMatchObject({ code: "cleanup_incomplete", details: { provider_id: "hopper", operation: "close_binary", cleanup: "incomplete", resources: ["hopper-document"], }, }); }); it("kills only its owned Hopper group and emits sanitized shutdown coordinates", async () => { const unrelated = spawn( process.execPath, ["-e", "process.title = 'Hopper'; setInterval(() => undefined, 1000)"], { detached: true, stdio: "ignore" }, ); await new Promise((resolve, reject) => { unrelated.once("spawn", resolve); unrelated.once("error", reject); }); const launcher = new OwnedFixtureLauncher(); const diagnostics: unknown[] = []; const runId = "22222222-2222-4222-8222-222222222222"; const client = new HopperClient({ launcher, runId, startupTimeoutMs: 1_000, onDiagnostic: (event) => diagnostics.push(event), }); clients.push(client); try { await expect(client.start()).resolves.toMatchObject({ ok: true }); await client.close(); const unrelatedPid = unrelated.pid; if (unrelatedPid === undefined) throw new Error("Unrelated Hopper fixture has no PID"); expect(() => process.kill(unrelatedPid, 0)).not.toThrow(); expect(diagnostics).toContainEqual({ type: "owned-shutdown", status: "verified-cleanup", launcher_pid: launcher.ownership?.leaderPid, process_group_id: launcher.ownership?.processGroupId, cleanup_signaled: true, reason: null, }); expect(JSON.stringify(diagnostics)).not.toContain(runId); } finally { await stopUnrelatedFixture(unrelated); } }); it("makes sequential double-close leave no runtime or process listeners", async () => { const launcher = new FixtureLauncher(); const client = new HopperClient({ launcher, startupTimeoutMs: 1_000 }); clients.push(client); await expect(client.start()).resolves.toMatchObject({ ok: true }); await client.close(); await client.close(); const directory = launcher.directories[0] ?? ""; const child = launcher.processes[0]; await expect(access(directory)).rejects.toMatchObject({ code: "ENOENT" }); expect(child).toBeDefined(); if (child === undefined) throw new Error("Fixture process was not captured"); expect(child.exitCode !== null || child.signalCode !== null).toBe(true); expect(getEventListeners(child, "exit")).toHaveLength(0); expect(getEventListeners(child, "close")).toHaveLength(0); expect(getEventListeners(child, "error")).toHaveLength(0); expect(child.stderr).not.toBeNull(); if (child.stderr === null) throw new Error("Fixture stderr is unavailable"); expect(getEventListeners(child.stderr, "data")).toHaveLength(0); }); it("does not finish startup after an immediate close", async () => { const client = new HopperClient({ launcher: new FixtureLauncher(), startupTimeoutMs: 1_000, }); clients.push(client); const starting = client.start(); await client.close(); const result = await starting; expect(result.ok).toBe(false); if (!result.ok) expect(result.error._tag).toBe("HopperCancelledError"); }); it("starts a fresh session after close completes", async () => { const client = await startClient(); await client.close(); await expect(client.start()).resolves.toEqual({ ok: true, value: { name: "REA Hopper bridge", version: "1.0.0" }, }); }); it("serializes a restart requested while close is in progress", async () => { const client = await startClient(); const closing = client.close(); const restarting = client.start(); await closing; await expect(restarting).resolves.toEqual({ ok: true, value: { name: "REA Hopper bridge", version: "1.0.0" }, }); await expect(client.callTool("echo", { value: "fresh" })).resolves.toEqual({ ok: true, value: { value: "fresh" }, }); }); }); const stopUnrelatedFixture = async (child: ChildProcess): Promise => { if (child.exitCode !== null || child.signalCode !== null) return; const exited = new Promise((resolve) => { child.once("exit", () => resolve()); }); child.kill("SIGKILL"); await exited; };