fix(discovery): lazy-load bonjour; add tests
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
87
src/infra/bonjour.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user