fix(discovery): lazy-load bonjour; add tests
This commit is contained in:
@@ -41,7 +41,7 @@ describe("gateway SIGTERM", () => {
|
|||||||
child = null;
|
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 port = await getFreePort();
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
const err: string[] = [];
|
const err: string[] = [];
|
||||||
@@ -70,7 +70,7 @@ describe("gateway SIGTERM", () => {
|
|||||||
await waitForText(
|
await waitForText(
|
||||||
out,
|
out,
|
||||||
new RegExp(`gateway listening on ws://127\\.0\\.0\\.1:${port}\\b`),
|
new RegExp(`gateway listening on ws://127\\.0\\.0\\.1:${port}\\b`),
|
||||||
10_000,
|
20_000,
|
||||||
);
|
);
|
||||||
|
|
||||||
proc.kill("SIGTERM");
|
proc.kill("SIGTERM");
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const sendCommand = vi.fn();
|
|||||||
const statusCommand = vi.fn();
|
const statusCommand = vi.fn();
|
||||||
const loginWeb = vi.fn();
|
const loginWeb = vi.fn();
|
||||||
const startWebChatServer = vi.fn(async () => ({ port: 18788 }));
|
const startWebChatServer = vi.fn(async () => ({ port: 18788 }));
|
||||||
|
const callGateway = vi.fn();
|
||||||
|
|
||||||
const runtime = {
|
const runtime = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
@@ -19,6 +20,9 @@ vi.mock("../runtime.js", () => ({ defaultRuntime: runtime }));
|
|||||||
vi.mock("../provider-web.js", () => ({
|
vi.mock("../provider-web.js", () => ({
|
||||||
loginWeb,
|
loginWeb,
|
||||||
}));
|
}));
|
||||||
|
vi.mock("../gateway/call.js", () => ({
|
||||||
|
callGateway,
|
||||||
|
}));
|
||||||
vi.mock("../webchat/server.js", () => ({
|
vi.mock("../webchat/server.js", () => ({
|
||||||
startWebChatServer,
|
startWebChatServer,
|
||||||
getWebChatServer: () => null,
|
getWebChatServer: () => null,
|
||||||
@@ -57,4 +61,34 @@ describe("cli program", () => {
|
|||||||
JSON.stringify({ port: 18788, basePath: "/", host: "127.0.0.1" }),
|
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", () => {
|
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 () => {
|
test("supports cron.add and cron.list", async () => {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-cron-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-cron-"));
|
||||||
testCronStorePath = path.join(dir, "cron.json");
|
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 os from "node:os";
|
||||||
|
|
||||||
import { type CiaoService, getResponder, Protocol } from "@homebridge/ciao";
|
|
||||||
|
|
||||||
export type GatewayBonjourAdvertiser = {
|
export type GatewayBonjourAdvertiser = {
|
||||||
stop: () => Promise<void>;
|
stop: () => Promise<void>;
|
||||||
};
|
};
|
||||||
@@ -26,6 +24,11 @@ function safeServiceName(name: string) {
|
|||||||
return trimmed.length > 0 ? trimmed : "Clawdis";
|
return trimmed.length > 0 ? trimmed : "Clawdis";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BonjourService = {
|
||||||
|
advertise: () => Promise<void>;
|
||||||
|
destroy: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
export async function startGatewayBonjourAdvertiser(
|
export async function startGatewayBonjourAdvertiser(
|
||||||
opts: GatewayBonjourAdvertiseOpts,
|
opts: GatewayBonjourAdvertiseOpts,
|
||||||
): Promise<GatewayBonjourAdvertiser> {
|
): Promise<GatewayBonjourAdvertiser> {
|
||||||
@@ -33,6 +36,7 @@ export async function startGatewayBonjourAdvertiser(
|
|||||||
return { stop: async () => {} };
|
return { stop: async () => {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { getResponder, Protocol } = await import("@homebridge/ciao");
|
||||||
const responder = getResponder();
|
const responder = getResponder();
|
||||||
|
|
||||||
const hostname = os.hostname().replace(/\.local$/i, "");
|
const hostname = os.hostname().replace(/\.local$/i, "");
|
||||||
@@ -53,7 +57,7 @@ export async function startGatewayBonjourAdvertiser(
|
|||||||
txtBase.tailnetDns = opts.tailnetDns.trim();
|
txtBase.tailnetDns = opts.tailnetDns.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const services: CiaoService[] = [];
|
const services: BonjourService[] = [];
|
||||||
|
|
||||||
// Master beacon: used for discovery (auto-fill SSH/direct targets).
|
// 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.
|
// We advertise a TCP service so clients can resolve the host; the port itself is informational.
|
||||||
|
|||||||
Reference in New Issue
Block a user