import os from "node:os"; import { afterEach, describe, expect, it, vi } from "vitest"; const createService = vi.fn(); const shutdown = vi.fn(); const registerUnhandledRejectionHandler = vi.fn(); const logWarn = vi.fn(); const logDebug = vi.fn(); const getLoggerInfo = vi.fn(); const asString = (value: unknown, fallback: string) => typeof value === "string" && value.trim() ? value : fallback; vi.mock("../logger.js", () => { return { logWarn: (message: string) => logWarn(message), logDebug: (message: string) => logDebug(message), logInfo: vi.fn(), logError: vi.fn(), logSuccess: vi.fn(), }; }); vi.mock("../logging.js", () => { return { getLogger: () => ({ info: (...args: unknown[]) => getLoggerInfo(...args) }), }; }); vi.mock("@homebridge/ciao", () => { return { Protocol: { TCP: "tcp" }, getResponder: () => ({ createService, shutdown, }), }; }); vi.mock("./unhandled-rejections.js", () => { return { registerUnhandledRejectionHandler: ( handler: (reason: unknown) => boolean, ) => registerUnhandledRejectionHandler(handler), }; }); const { startGatewayBonjourAdvertiser } = await import("./bonjour.js"); describe("gateway bonjour advertiser", () => { type ServiceCall = { name?: unknown; hostname?: unknown; domain?: unknown; txt?: unknown; }; 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(); registerUnhandledRejectionHandler.mockReset(); logWarn.mockReset(); logDebug.mockReset(); getLoggerInfo.mockReset(); vi.useRealTimers(); 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((resolve) => { setTimeout(resolve, 250); }), ); createService.mockImplementation((options: Record) => { return { advertise, destroy, serviceState: "announced", on: vi.fn(), getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`, getHostname: () => asString(options.hostname, "unknown"), getPort: () => Number(options.port ?? -1), }; }); const started = await startGatewayBonjourAdvertiser({ gatewayPort: 18789, sshPort: 2222, bridgePort: 18790, tailnetDns: "host.tailnet.ts.net", cliPath: "/opt/homebrew/bin/clawdbot", }); expect(createService).toHaveBeenCalledTimes(1); const [bridgeCall] = createService.mock.calls as Array< [Record] >; expect(bridgeCall?.[0]?.type).toBe("clawdbot-bridge"); expect(bridgeCall?.[0]?.port).toBe(18790); expect(bridgeCall?.[0]?.domain).toBe("local"); expect(bridgeCall?.[0]?.hostname).toBe("test-host"); expect((bridgeCall?.[0]?.txt as Record)?.lanHost).toBe( "test-host.local", ); expect((bridgeCall?.[0]?.txt as Record)?.bridgePort).toBe( "18790", ); expect((bridgeCall?.[0]?.txt as Record)?.sshPort).toBe( "2222", ); expect((bridgeCall?.[0]?.txt as Record)?.cliPath).toBe( "/opt/homebrew/bin/clawdbot", ); expect((bridgeCall?.[0]?.txt as Record)?.transport).toBe( "bridge", ); // We don't await `advertise()`, but it should still be called for each service. expect(advertise).toHaveBeenCalledTimes(1); await started.stop(); expect(destroy).toHaveBeenCalledTimes(1); expect(shutdown).toHaveBeenCalledTimes(1); }); it("attaches conflict listeners for services", 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().mockResolvedValue(undefined); const onCalls: Array<{ event: string }> = []; createService.mockImplementation((options: Record) => { const on = vi.fn((event: string) => { onCalls.push({ event }); }); return { advertise, destroy, serviceState: "announced", on, getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`, getHostname: () => asString(options.hostname, "unknown"), getPort: () => Number(options.port ?? -1), }; }); const started = await startGatewayBonjourAdvertiser({ gatewayPort: 18789, sshPort: 2222, bridgePort: 18790, }); // 1 service × 2 listeners expect(onCalls.map((c) => c.event)).toEqual([ "name-change", "hostname-change", ]); await started.stop(); }); it("cleans up unhandled rejection handler after shutdown", 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().mockResolvedValue(undefined); const order: string[] = []; shutdown.mockImplementation(async () => { order.push("shutdown"); }); createService.mockImplementation((options: Record) => { return { advertise, destroy, serviceState: "announced", on: vi.fn(), getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`, getHostname: () => asString(options.hostname, "unknown"), getPort: () => Number(options.port ?? -1), }; }); const cleanup = vi.fn(() => { order.push("cleanup"); }); registerUnhandledRejectionHandler.mockImplementation(() => cleanup); const started = await startGatewayBonjourAdvertiser({ gatewayPort: 18789, sshPort: 2222, bridgePort: 18790, }); await started.stop(); expect(registerUnhandledRejectionHandler).toHaveBeenCalledTimes(1); expect(cleanup).toHaveBeenCalledTimes(1); expect(order).toEqual(["shutdown", "cleanup"]); }); it("logs advertise failures and retries via watchdog", async () => { // Allow advertiser to run in unit tests. delete process.env.VITEST; process.env.NODE_ENV = "development"; vi.useFakeTimers(); vi.spyOn(os, "hostname").mockReturnValue("test-host"); const destroy = vi.fn().mockResolvedValue(undefined); const advertise = vi .fn() .mockRejectedValueOnce(new Error("boom")) // initial advertise fails .mockResolvedValue(undefined); // watchdog retry succeeds createService.mockImplementation((options: Record) => { return { advertise, destroy, serviceState: "unannounced", on: vi.fn(), getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`, getHostname: () => asString(options.hostname, "unknown"), getPort: () => Number(options.port ?? -1), }; }); const started = await startGatewayBonjourAdvertiser({ gatewayPort: 18789, sshPort: 2222, bridgePort: 18790, }); // initial advertise attempt happens immediately expect(advertise).toHaveBeenCalledTimes(1); // allow promise rejection handler to run await Promise.resolve(); expect(logWarn).toHaveBeenCalledWith( expect.stringContaining("advertise failed"), ); // watchdog should attempt re-advertise at the 60s interval tick await vi.advanceTimersByTimeAsync(60_000); expect(advertise).toHaveBeenCalledTimes(2); await started.stop(); await vi.advanceTimersByTimeAsync(120_000); expect(advertise).toHaveBeenCalledTimes(2); }); it("handles advertise throwing synchronously", 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(() => { throw new Error("sync-fail"); }); createService.mockImplementation((options: Record) => { return { advertise, destroy, serviceState: "unannounced", on: vi.fn(), getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`, getHostname: () => asString(options.hostname, "unknown"), getPort: () => Number(options.port ?? -1), }; }); const started = await startGatewayBonjourAdvertiser({ gatewayPort: 18789, sshPort: 2222, bridgePort: 18790, }); expect(advertise).toHaveBeenCalledTimes(1); expect(logWarn).toHaveBeenCalledWith( expect.stringContaining("advertise threw"), ); await started.stop(); }); it("normalizes hostnames with domains for service names", async () => { // Allow advertiser to run in unit tests. delete process.env.VITEST; process.env.NODE_ENV = "development"; vi.spyOn(os, "hostname").mockReturnValue("Mac.localdomain"); const destroy = vi.fn().mockResolvedValue(undefined); const advertise = vi.fn().mockResolvedValue(undefined); createService.mockImplementation((options: Record) => { return { advertise, destroy, serviceState: "announced", on: vi.fn(), getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`, getHostname: () => asString(options.hostname, "unknown"), getPort: () => Number(options.port ?? -1), }; }); const started = await startGatewayBonjourAdvertiser({ gatewayPort: 18789, sshPort: 2222, bridgePort: 18790, }); const [bridgeCall] = createService.mock.calls as Array<[ServiceCall]>; expect(bridgeCall?.[0]?.name).toBe("Mac (Clawdbot)"); expect(bridgeCall?.[0]?.domain).toBe("local"); expect(bridgeCall?.[0]?.hostname).toBe("Mac"); expect((bridgeCall?.[0]?.txt as Record)?.lanHost).toBe( "Mac.local", ); await started.stop(); }); });