OpenClawBot / src /infra /bonjour.test.ts
darkfire514's picture
Upload 2526 files
fb4d8fe verified
import os from "node:os";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as logging from "../logging.js";
const mocks = vi.hoisted(() => ({
createService: vi.fn(),
shutdown: vi.fn(),
registerUnhandledRejectionHandler: vi.fn(),
logWarn: vi.fn(),
logDebug: vi.fn(),
}));
const { createService, shutdown, registerUnhandledRejectionHandler, logWarn, logDebug } = mocks;
const asString = (value: unknown, fallback: string) =>
typeof value === "string" && value.trim() ? value : fallback;
vi.mock("../logger.js", async () => {
const actual = await vi.importActual<typeof import("../logger.js")>("../logger.js");
return {
...actual,
logWarn: (message: string) => logWarn(message),
logDebug: (message: string) => logDebug(message),
logInfo: vi.fn(),
logError: vi.fn(),
logSuccess: vi.fn(),
};
});
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 };
beforeEach(() => {
vi.spyOn(logging, "getLogger").mockReturnValue({
info: (...args: unknown[]) => getLoggerInfo(...args),
});
});
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();
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");
process.env.OPENCLAW_MDNS_HOSTNAME = "test-host";
process.env.OPENCLAW_MDNS_HOSTNAME = "test-host";
const destroy = vi.fn().mockResolvedValue(undefined);
const advertise = vi.fn().mockImplementation(
async () =>
await new Promise<void>((resolve) => {
setTimeout(resolve, 250);
}),
);
createService.mockImplementation((options: Record<string, unknown>) => {
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,
tailnetDns: "host.tailnet.ts.net",
cliPath: "/opt/homebrew/bin/openclaw",
});
expect(createService).toHaveBeenCalledTimes(1);
const [gatewayCall] = createService.mock.calls as Array<[Record<string, unknown>]>;
expect(gatewayCall?.[0]?.type).toBe("openclaw-gw");
const gatewayType = asString(gatewayCall?.[0]?.type, "");
expect(gatewayType.length).toBeLessThanOrEqual(15);
expect(gatewayCall?.[0]?.port).toBe(18789);
expect(gatewayCall?.[0]?.domain).toBe("local");
expect(gatewayCall?.[0]?.hostname).toBe("test-host");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("test-host.local");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.gatewayPort).toBe("18789");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe("2222");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.cliPath).toBe(
"/opt/homebrew/bin/openclaw",
);
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.transport).toBe("gateway");
// 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("omits cliPath and sshPort in minimal mode", 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);
createService.mockImplementation((options: Record<string, unknown>) => {
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,
cliPath: "/opt/homebrew/bin/openclaw",
minimal: true,
});
const [gatewayCall] = createService.mock.calls as Array<[Record<string, unknown>]>;
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.sshPort).toBeUndefined();
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.cliPath).toBeUndefined();
await started.stop();
});
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");
process.env.OPENCLAW_MDNS_HOSTNAME = "test-host";
const destroy = vi.fn().mockResolvedValue(undefined);
const advertise = vi.fn().mockResolvedValue(undefined);
const onCalls: Array<{ event: string }> = [];
createService.mockImplementation((options: Record<string, unknown>) => {
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,
});
// 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");
process.env.OPENCLAW_MDNS_HOSTNAME = "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<string, unknown>) => {
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,
});
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");
process.env.OPENCLAW_MDNS_HOSTNAME = "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<string, unknown>) => {
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,
});
// 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");
process.env.OPENCLAW_MDNS_HOSTNAME = "test-host";
const destroy = vi.fn().mockResolvedValue(undefined);
const advertise = vi.fn(() => {
throw new Error("sync-fail");
});
createService.mockImplementation((options: Record<string, unknown>) => {
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,
});
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<string, unknown>) => {
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,
});
const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>;
expect(gatewayCall?.[0]?.name).toBe("openclaw (OpenClaw)");
expect(gatewayCall?.[0]?.domain).toBe("local");
expect(gatewayCall?.[0]?.hostname).toBe("openclaw");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("openclaw.local");
await started.stop();
});
});