fix(discovery): lazy-load bonjour; add tests

This commit is contained in:
Peter Steinberger
2025-12-13 03:55:32 +00:00
parent 47b4d245aa
commit 4b608117a2
5 changed files with 273 additions and 5 deletions

View File

@@ -41,7 +41,7 @@ describe("gateway SIGTERM", () => {
child = null;
});
it("exits 0 on SIGTERM", { timeout: 15_000 }, async () => {
it("exits 0 on SIGTERM", { timeout: 30_000 }, async () => {
const port = await getFreePort();
const out: string[] = [];
const err: string[] = [];
@@ -70,7 +70,7 @@ describe("gateway SIGTERM", () => {
await waitForText(
out,
new RegExp(`gateway listening on ws://127\\.0\\.0\\.1:${port}\\b`),
10_000,
20_000,
);
proc.kill("SIGTERM");

View File

@@ -4,6 +4,7 @@ const sendCommand = vi.fn();
const statusCommand = vi.fn();
const loginWeb = vi.fn();
const startWebChatServer = vi.fn(async () => ({ port: 18788 }));
const callGateway = vi.fn();
const runtime = {
log: vi.fn(),
@@ -19,6 +20,9 @@ vi.mock("../runtime.js", () => ({ defaultRuntime: runtime }));
vi.mock("../provider-web.js", () => ({
loginWeb,
}));
vi.mock("../gateway/call.js", () => ({
callGateway,
}));
vi.mock("../webchat/server.js", () => ({
startWebChatServer,
getWebChatServer: () => null,
@@ -57,4 +61,34 @@ describe("cli program", () => {
JSON.stringify({ port: 18788, basePath: "/", host: "127.0.0.1" }),
);
});
it("runs nodes list and calls node.pair.list", async () => {
callGateway.mockResolvedValue({ pending: [], paired: [] });
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(["nodes", "list"], { from: "user" });
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "node.pair.list",
}),
);
expect(runtime.log).toHaveBeenCalledWith("Pending: 0 · Paired: 0");
});
it("runs nodes approve and calls node.pair.approve", async () => {
callGateway.mockResolvedValue({
requestId: "r1",
node: { nodeId: "n1", token: "t1" },
});
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(["nodes", "approve", "r1"], { from: "user" });
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "node.pair.approve",
params: { requestId: "r1" },
}),
);
expect(runtime.log).toHaveBeenCalled();
});
});

View File

@@ -181,6 +181,149 @@ async function connectOk(
}
describe("gateway server", () => {
test("supports gateway-owned node pairing methods and events", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const requestedP = onceMessage<{
type: "event";
event: string;
payload?: unknown;
}>(ws, (o) => o.type === "event" && o.event === "node.pair.requested");
ws.send(
JSON.stringify({
type: "req",
id: "pair-req-1",
method: "node.pair.request",
params: { nodeId: "n1", displayName: "Iris" },
}),
);
const res1 = await onceMessage<{
type: "res";
ok: boolean;
payload?: unknown;
}>(ws, (o) => o.type === "res" && o.id === "pair-req-1");
expect(res1.ok).toBe(true);
const req1 = (res1.payload as { request?: { requestId?: unknown } } | null)
?.request;
const requestId = String(req1?.requestId ?? "");
expect(requestId.length).toBeGreaterThan(0);
const evt1 = await requestedP;
expect(evt1.event).toBe("node.pair.requested");
expect((evt1.payload as { requestId?: unknown } | null)?.requestId).toBe(
requestId,
);
// Second request for same node should return the existing pending request
// without emitting a second requested event.
ws.send(
JSON.stringify({
type: "req",
id: "pair-req-2",
method: "node.pair.request",
params: { nodeId: "n1", displayName: "Iris" },
}),
);
const res2 = await onceMessage<{
type: "res";
ok: boolean;
payload?: unknown;
}>(ws, (o) => o.type === "res" && o.id === "pair-req-2");
expect(res2.ok).toBe(true);
await expect(
onceMessage(
ws,
(o) => o.type === "event" && o.event === "node.pair.requested",
200,
),
).rejects.toThrow();
const resolvedP = onceMessage<{
type: "event";
event: string;
payload?: unknown;
}>(ws, (o) => o.type === "event" && o.event === "node.pair.resolved");
ws.send(
JSON.stringify({
type: "req",
id: "pair-approve-1",
method: "node.pair.approve",
params: { requestId },
}),
);
const approveRes = await onceMessage<{
type: "res";
ok: boolean;
payload?: unknown;
}>(ws, (o) => o.type === "res" && o.id === "pair-approve-1");
expect(approveRes.ok).toBe(true);
const token = String(
(approveRes.payload as { node?: { token?: unknown } } | null)?.node
?.token ?? "",
);
expect(token.length).toBeGreaterThan(0);
const evt2 = await resolvedP;
expect((evt2.payload as { requestId?: unknown } | null)?.requestId).toBe(
requestId,
);
expect((evt2.payload as { decision?: unknown } | null)?.decision).toBe(
"approved",
);
ws.send(
JSON.stringify({
type: "req",
id: "pair-verify-1",
method: "node.pair.verify",
params: { nodeId: "n1", token },
}),
);
const verifyRes = await onceMessage<{
type: "res";
ok: boolean;
payload?: unknown;
}>(ws, (o) => o.type === "res" && o.id === "pair-verify-1");
expect(verifyRes.ok).toBe(true);
expect((verifyRes.payload as { ok?: unknown } | null)?.ok).toBe(true);
ws.send(
JSON.stringify({
type: "req",
id: "pair-list-1",
method: "node.pair.list",
params: {},
}),
);
const listRes = await onceMessage<{
type: "res";
ok: boolean;
payload?: unknown;
}>(ws, (o) => o.type === "res" && o.id === "pair-list-1");
expect(listRes.ok).toBe(true);
const paired = (listRes.payload as { paired?: unknown } | null)?.paired;
expect(Array.isArray(paired)).toBe(true);
expect(
(paired as Array<{ nodeId?: unknown }>).some((n) => n.nodeId === "n1"),
).toBe(true);
ws.close();
await server.close();
await fs.rm(homeDir, { recursive: true, force: true });
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
});
test("supports cron.add and cron.list", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-cron-"));
testCronStorePath = path.join(dir, "cron.json");

87
src/infra/bonjour.test.ts Normal file
View File

@@ -0,0 +1,87 @@
import os from "node:os";
import { afterEach, describe, expect, it, vi } from "vitest";
const createService = vi.fn();
const shutdown = vi.fn();
vi.mock("@homebridge/ciao", () => {
return {
Protocol: { TCP: "tcp" },
getResponder: () => ({
createService,
shutdown,
}),
};
});
const { startGatewayBonjourAdvertiser } = await import("./bonjour.js");
describe("gateway bonjour advertiser", () => {
const prevEnv = { ...process.env };
afterEach(() => {
for (const key of Object.keys(process.env)) {
if (!(key in prevEnv)) delete process.env[key];
}
for (const [key, value] of Object.entries(prevEnv)) {
process.env[key] = value;
}
createService.mockReset();
shutdown.mockReset();
vi.restoreAllMocks();
});
it("does not block on advertise and publishes expected txt keys", async () => {
// Allow advertiser to run in unit tests.
delete process.env.VITEST;
process.env.NODE_ENV = "development";
vi.spyOn(os, "hostname").mockReturnValue("test-host");
const destroy = vi.fn().mockResolvedValue(undefined);
const advertise = vi.fn().mockImplementation(
async () =>
await new Promise<void>((resolve) => {
setTimeout(resolve, 250);
}),
);
createService.mockReturnValue({ advertise, destroy });
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
bridgePort: 18790,
tailnetDns: "host.tailnet.ts.net",
});
expect(createService).toHaveBeenCalledTimes(2);
const [masterCall, bridgeCall] = createService.mock.calls as Array<
[Record<string, unknown>]
>;
expect(masterCall?.[0]?.type).toBe("clawdis-master");
expect(masterCall?.[0]?.port).toBe(2222);
expect((masterCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe(
"test-host.local",
);
expect((masterCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe(
"2222",
);
expect(bridgeCall?.[0]?.type).toBe("clawdis-bridge");
expect(bridgeCall?.[0]?.port).toBe(18790);
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.bridgePort).toBe(
"18790",
);
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.transport).toBe(
"bridge",
);
// We don't await `advertise()`, but it should still be called for each service.
expect(advertise).toHaveBeenCalledTimes(2);
await started.stop();
expect(destroy).toHaveBeenCalledTimes(2);
expect(shutdown).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,7 +1,5 @@
import os from "node:os";
import { type CiaoService, getResponder, Protocol } from "@homebridge/ciao";
export type GatewayBonjourAdvertiser = {
stop: () => Promise<void>;
};
@@ -26,6 +24,11 @@ function safeServiceName(name: string) {
return trimmed.length > 0 ? trimmed : "Clawdis";
}
type BonjourService = {
advertise: () => Promise<void>;
destroy: () => Promise<void>;
};
export async function startGatewayBonjourAdvertiser(
opts: GatewayBonjourAdvertiseOpts,
): Promise<GatewayBonjourAdvertiser> {
@@ -33,6 +36,7 @@ export async function startGatewayBonjourAdvertiser(
return { stop: async () => {} };
}
const { getResponder, Protocol } = await import("@homebridge/ciao");
const responder = getResponder();
const hostname = os.hostname().replace(/\.local$/i, "");
@@ -53,7 +57,7 @@ export async function startGatewayBonjourAdvertiser(
txtBase.tailnetDns = opts.tailnetDns.trim();
}
const services: CiaoService[] = [];
const services: BonjourService[] = [];
// Master beacon: used for discovery (auto-fill SSH/direct targets).
// We advertise a TCP service so clients can resolve the host; the port itself is informational.