From f95c09b6f2cd6dda90077dc62f42812c23a7f9a9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 20:18:50 +0000 Subject: [PATCH] test: add diagnostic and port format helper coverage --- src/infra/diagnostic-events.test.ts | 121 ++++++++++++++++++++++++++++ src/infra/infra-store.test.ts | 53 ------------ src/infra/ports-format.test.ts | 87 ++++++++++++++++++++ src/infra/ports.test.ts | 82 +------------------ 4 files changed, 209 insertions(+), 134 deletions(-) create mode 100644 src/infra/diagnostic-events.test.ts create mode 100644 src/infra/ports-format.test.ts diff --git a/src/infra/diagnostic-events.test.ts b/src/infra/diagnostic-events.test.ts new file mode 100644 index 00000000000..d2b2af1d04a --- /dev/null +++ b/src/infra/diagnostic-events.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + emitDiagnosticEvent, + isDiagnosticsEnabled, + onDiagnosticEvent, + resetDiagnosticEventsForTest, +} from "./diagnostic-events.js"; + +describe("diagnostic-events", () => { + beforeEach(() => { + resetDiagnosticEventsForTest(); + }); + + afterEach(() => { + resetDiagnosticEventsForTest(); + vi.restoreAllMocks(); + }); + + it("emits monotonic seq and timestamps to subscribers", () => { + vi.spyOn(Date, "now").mockReturnValueOnce(111).mockReturnValueOnce(222); + const events: Array<{ seq: number; ts: number; type: string }> = []; + const stop = onDiagnosticEvent((event) => { + events.push({ seq: event.seq, ts: event.ts, type: event.type }); + }); + + emitDiagnosticEvent({ + type: "model.usage", + usage: { total: 1 }, + }); + emitDiagnosticEvent({ + type: "session.state", + state: "processing", + }); + stop(); + + expect(events).toEqual([ + { seq: 1, ts: 111, type: "model.usage" }, + { seq: 2, ts: 222, type: "session.state" }, + ]); + }); + + it("isolates listener failures and logs them", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const seen: string[] = []; + onDiagnosticEvent(() => { + throw new Error("boom"); + }); + onDiagnosticEvent((event) => { + seen.push(event.type); + }); + + emitDiagnosticEvent({ + type: "message.queued", + source: "telegram", + }); + + expect(seen).toEqual(["message.queued"]); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("listener error type=message.queued seq=1: Error: boom"), + ); + }); + + it("supports unsubscribe and full reset", () => { + const seen: string[] = []; + const stop = onDiagnosticEvent((event) => { + seen.push(event.type); + }); + + emitDiagnosticEvent({ + type: "webhook.received", + channel: "telegram", + }); + stop(); + emitDiagnosticEvent({ + type: "webhook.processed", + channel: "telegram", + }); + + expect(seen).toEqual(["webhook.received"]); + + resetDiagnosticEventsForTest(); + emitDiagnosticEvent({ + type: "webhook.error", + channel: "telegram", + error: "failed", + }); + expect(seen).toEqual(["webhook.received"]); + }); + + it("drops recursive emissions after the guard threshold", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + let calls = 0; + onDiagnosticEvent(() => { + calls += 1; + emitDiagnosticEvent({ + type: "queue.lane.enqueue", + lane: "main", + queueSize: calls, + }); + }); + + emitDiagnosticEvent({ + type: "queue.lane.enqueue", + lane: "main", + queueSize: 0, + }); + + expect(calls).toBe(101); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + "recursion guard tripped at depth=101, dropping type=queue.lane.enqueue", + ), + ); + }); + + it("requires an explicit true diagnostics flag", () => { + expect(isDiagnosticsEnabled()).toBe(false); + expect(isDiagnosticsEnabled({ diagnostics: { enabled: false } } as never)).toBe(false); + expect(isDiagnosticsEnabled({ diagnostics: { enabled: true } } as never)).toBe(true); + }); +}); diff --git a/src/infra/infra-store.test.ts b/src/infra/infra-store.test.ts index 1b7e649db4d..dfa6b1715c4 100644 --- a/src/infra/infra-store.test.ts +++ b/src/infra/infra-store.test.ts @@ -2,11 +2,6 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { withTempDir } from "../test-utils/temp-dir.js"; -import { - emitDiagnosticEvent, - onDiagnosticEvent, - resetDiagnosticEventsForTest, -} from "./diagnostic-events.js"; import { readSessionStoreJson5 } from "./state-migrations.fs.js"; describe("infra store", () => { @@ -38,52 +33,4 @@ describe("infra store", () => { }); }); }); - - describe("diagnostic-events", () => { - it("emits monotonic seq", async () => { - resetDiagnosticEventsForTest(); - const seqs: number[] = []; - const stop = onDiagnosticEvent((evt) => seqs.push(evt.seq)); - - emitDiagnosticEvent({ - type: "model.usage", - usage: { total: 1 }, - }); - emitDiagnosticEvent({ - type: "model.usage", - usage: { total: 2 }, - }); - - stop(); - - expect(seqs).toEqual([1, 2]); - }); - - it("emits message-flow events", async () => { - resetDiagnosticEventsForTest(); - const types: string[] = []; - const stop = onDiagnosticEvent((evt) => types.push(evt.type)); - - emitDiagnosticEvent({ - type: "webhook.received", - channel: "telegram", - updateType: "telegram-post", - }); - emitDiagnosticEvent({ - type: "message.queued", - channel: "telegram", - source: "telegram", - queueDepth: 1, - }); - emitDiagnosticEvent({ - type: "session.state", - state: "processing", - reason: "run_started", - }); - - stop(); - - expect(types).toEqual(["webhook.received", "message.queued", "session.state"]); - }); - }); }); diff --git a/src/infra/ports-format.test.ts b/src/infra/ports-format.test.ts new file mode 100644 index 00000000000..c532de63970 --- /dev/null +++ b/src/infra/ports-format.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { + buildPortHints, + classifyPortListener, + formatPortDiagnostics, + formatPortListener, +} from "./ports-format.js"; + +describe("ports-format", () => { + it("classifies listeners across gateway, ssh, and unknown command lines", () => { + const cases = [ + { + listener: { commandLine: "ssh -N -L 18789:127.0.0.1:18789 user@host" }, + expected: "ssh", + }, + { + listener: { command: "ssh" }, + expected: "ssh", + }, + { + listener: { commandLine: "node /Users/me/Projects/openclaw/dist/entry.js gateway" }, + expected: "gateway", + }, + { + listener: { commandLine: "python -m http.server 18789" }, + expected: "unknown", + }, + ] as const; + + for (const testCase of cases) { + expect( + classifyPortListener(testCase.listener, 18789), + JSON.stringify(testCase.listener), + ).toBe(testCase.expected); + } + }); + + it("builds ordered hints for mixed listener kinds and multiplicity", () => { + expect( + buildPortHints( + [ + { commandLine: "node dist/index.js openclaw gateway" }, + { commandLine: "ssh -N -L 18789:127.0.0.1:18789" }, + { commandLine: "python -m http.server 18789" }, + ], + 18789, + ), + ).toEqual([ + expect.stringContaining("Gateway already running locally."), + "SSH tunnel already bound to this port. Close the tunnel or use a different local port in -L.", + "Another process is listening on this port.", + expect.stringContaining("Multiple listeners detected"), + ]); + expect(buildPortHints([], 18789)).toEqual([]); + }); + + it("formats listeners with pid, user, command, and address fallbacks", () => { + expect( + formatPortListener({ pid: 123, user: "alice", commandLine: "ssh -N", address: "::1" }), + ).toBe("pid 123 alice: ssh -N (::1)"); + expect(formatPortListener({ command: "ssh", address: "127.0.0.1:18789" })).toBe( + "pid ?: ssh (127.0.0.1:18789)", + ); + expect(formatPortListener({})).toBe("pid ?: unknown"); + }); + + it("formats free and busy port diagnostics", () => { + expect( + formatPortDiagnostics({ + port: 18789, + status: "free", + listeners: [], + hints: [], + }), + ).toEqual(["Port 18789 is free."]); + + const lines = formatPortDiagnostics({ + port: 18789, + status: "busy", + listeners: [{ pid: 123, user: "alice", commandLine: "ssh -N -L 18789:127.0.0.1:18789" }], + hints: buildPortHints([{ pid: 123, commandLine: "ssh -N -L 18789:127.0.0.1:18789" }], 18789), + }); + expect(lines[0]).toContain("Port 18789 is already in use"); + expect(lines).toContain("- pid 123 alice: ssh -N -L 18789:127.0.0.1:18789"); + expect(lines.some((line) => line.includes("SSH tunnel"))).toBe(true); + }); +}); diff --git a/src/infra/ports.test.ts b/src/infra/ports.test.ts index 9059d5e0c0f..090ccb128b9 100644 --- a/src/infra/ports.test.ts +++ b/src/infra/ports.test.ts @@ -7,16 +7,8 @@ const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn()); vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), })); -import { formatPortListener } from "./ports-format.js"; import { inspectPortUsage } from "./ports-inspect.js"; -import { - buildPortHints, - classifyPortListener, - ensurePortAvailable, - formatPortDiagnostics, - handlePortError, - PortInUseError, -} from "./ports.js"; +import { ensurePortAvailable, handlePortError, PortInUseError } from "./ports.js"; const describeUnix = process.platform === "win32" ? describe.skip : describe; @@ -62,78 +54,6 @@ describe("ports helpers", () => { const messages = runtime.error.mock.calls.map((call) => stripAnsi(String(call[0] ?? ""))); expect(messages.join("\n")).toContain("another OpenClaw instance is already running"); }); - - it("classifies port listeners across gateway, ssh, and unknown cases", () => { - const cases = [ - { - listener: { commandLine: "ssh -N -L 18789:127.0.0.1:18789 user@host" }, - expected: "ssh", - }, - { - listener: { command: "ssh" }, - expected: "ssh", - }, - { - listener: { commandLine: "node /Users/me/Projects/openclaw/dist/entry.js gateway" }, - expected: "gateway", - }, - { - listener: { commandLine: "python -m http.server 18789" }, - expected: "unknown", - }, - ] as const; - - for (const testCase of cases) { - expect( - classifyPortListener(testCase.listener, 18789), - JSON.stringify(testCase.listener), - ).toBe(testCase.expected); - } - }); - - it("builds ordered hints for mixed listener kinds and multiple listeners", () => { - expect( - buildPortHints( - [ - { commandLine: "node dist/index.js openclaw gateway" }, - { commandLine: "ssh -N -L 18789:127.0.0.1:18789" }, - { commandLine: "python -m http.server 18789" }, - ], - 18789, - ), - ).toEqual([ - expect.stringContaining("Gateway already running locally."), - "SSH tunnel already bound to this port. Close the tunnel or use a different local port in -L.", - "Another process is listening on this port.", - expect.stringContaining("Multiple listeners detected"), - ]); - expect(buildPortHints([], 18789)).toEqual([]); - }); - - it("formats port listeners and diagnostics for free and busy ports", () => { - expect(formatPortListener({ command: "ssh", address: "127.0.0.1:18789" })).toBe( - "pid ?: ssh (127.0.0.1:18789)", - ); - - expect( - formatPortDiagnostics({ - port: 18789, - status: "free", - listeners: [], - hints: [], - }), - ).toEqual(["Port 18789 is free."]); - - const lines = formatPortDiagnostics({ - port: 18789, - status: "busy", - listeners: [{ pid: 123, user: "alice", commandLine: "ssh -N -L 18789:127.0.0.1:18789" }], - hints: buildPortHints([{ pid: 123, commandLine: "ssh -N -L 18789:127.0.0.1:18789" }], 18789), - }); - expect(lines[0]).toContain("Port 18789 is already in use"); - expect(lines).toContain("- pid 123 alice: ssh -N -L 18789:127.0.0.1:18789"); - expect(lines.some((line) => line.includes("SSH tunnel"))).toBe(true); - }); }); describeUnix("inspectPortUsage", () => {