384 lines
12 KiB
TypeScript
384 lines
12 KiB
TypeScript
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 { ok } from "../src/domain/result.js";
|
|
import type {
|
|
BridgeLauncher,
|
|
BridgeSession,
|
|
} from "../src/hopper/BridgeLauncher.js";
|
|
import { HopperClient } from "../src/hopper/HopperClient.js";
|
|
|
|
const fixturePath = fileURLToPath(
|
|
new URL("./fixtures/fakeHopper.mjs", import.meta.url),
|
|
);
|
|
|
|
class FixtureLauncher implements BridgeLauncher {
|
|
socketPaths: string[] = [];
|
|
directories: string[] = [];
|
|
processes: ChildProcess[] = [];
|
|
|
|
constructor(readonly tokenOverride?: string) {}
|
|
|
|
launch(session: BridgeSession) {
|
|
this.socketPaths.push(session.socketPath);
|
|
this.directories.push(session.directory);
|
|
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 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 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("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("parses fragmented health and correlates concurrent responses", async () => {
|
|
const client = await startClient();
|
|
const slow = client.callTool("echo", { label: "slow", delay: 30 });
|
|
const fast = client.callTool("echo", { label: "fast", delay: 1 });
|
|
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(33);
|
|
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("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])(
|
|
"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("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("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" },
|
|
});
|
|
});
|
|
});
|