diff --git a/src/auto-reply/command-reply.ts b/src/auto-reply/command-reply.ts index f5db80b58..a37ffb9da 100644 --- a/src/auto-reply/command-reply.ts +++ b/src/auto-reply/command-reply.ts @@ -611,7 +611,9 @@ export async function runCommandReply( streamedAny = true; }; - const preferRpc = process.env.CLAWDIS_USE_PI_RPC === "1"; + // Default to RPC (it is testable/offline and avoids spawning long-lived CLI processes). + // Set `CLAWDIS_USE_PI_RPC=0` to force the JSON fallback path. + const preferRpc = process.env.CLAWDIS_USE_PI_RPC !== "0"; const run = async () => { const runId = params.runId ?? crypto.randomUUID(); diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index d6fa26539..32d0561cd 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -62,6 +62,7 @@ describe("directive parsing", () => { {}, { inbound: { + allowFrom: ["*"], reply: { mode: "command", command: ["pi", "{{Body}}"], diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index db9d98caa..cfae79c36 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -16,6 +16,7 @@ vi.mock("../web/session.js", () => webMocks); const baseCfg = { inbound: { + allowFrom: ["*"], reply: { mode: "command" as const, command: ["echo", "{{Body}}"], @@ -88,6 +89,7 @@ describe("trigger handling", () => { {}, { inbound: { + allowFrom: ["*"], reply: { mode: "command", command: ["echo", "{{Body}}"], @@ -104,6 +106,8 @@ describe("trigger handling", () => { }); it("ignores think directives that only appear in the context wrapper", async () => { + const prevPreferRpc = process.env.CLAWDIS_USE_PI_RPC; + process.env.CLAWDIS_USE_PI_RPC = "1"; const rpcMock = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({ stdout: '{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}}', @@ -135,9 +139,17 @@ describe("trigger handling", () => { const prompt = rpcMock.mock.calls[0]?.[0]?.prompt ?? ""; expect(prompt).toContain("Give me the status"); expect(prompt).not.toContain("/thinking high"); + + if (prevPreferRpc === undefined) { + delete process.env.CLAWDIS_USE_PI_RPC; + } else { + process.env.CLAWDIS_USE_PI_RPC = prevPreferRpc; + } }); it("does not emit directive acks for heartbeats with /think", async () => { + const prevPreferRpc = process.env.CLAWDIS_USE_PI_RPC; + process.env.CLAWDIS_USE_PI_RPC = "1"; const rpcMock = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({ stdout: '{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}}', @@ -170,5 +182,11 @@ describe("trigger handling", () => { expect(text).toBe("ok"); expect(text).not.toMatch(/Thinking level set/i); expect(rpcMock).toHaveBeenCalledOnce(); + + if (prevPreferRpc === undefined) { + delete process.env.CLAWDIS_USE_PI_RPC; + } else { + process.env.CLAWDIS_USE_PI_RPC = prevPreferRpc; + } }); }); diff --git a/src/cli/program.force.test.ts b/src/cli/program.force.test.ts index f988993c1..0f85d08ee 100644 --- a/src/cli/program.force.test.ts +++ b/src/cli/program.force.test.ts @@ -17,7 +17,7 @@ import { listPortListeners, type PortProcess, parseLsofOutput, -} from "./program.js"; +} from "./ports.js"; describe("gateway --force helpers", () => { let originalKill: typeof process.kill; diff --git a/src/commands/health.test.ts b/src/commands/health.test.ts index 105dafde6..6a3f860e2 100644 --- a/src/commands/health.test.ts +++ b/src/commands/health.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { HealthSummary } from "./health.js"; import { healthCommand } from "./health.js"; const runtime = { @@ -8,110 +9,58 @@ const runtime = { exit: vi.fn(), }; -vi.mock("../config/config.js", () => ({ - loadConfig: () => ({ web: {}, inbound: {} }), -})); - -vi.mock("../config/sessions.js", () => ({ - resolveStorePath: vi.fn(() => "/tmp/sessions.json"), - loadSessionStore: vi.fn(() => ({ - "+1555": { updatedAt: Date.now() - 60_000 }, - })), -})); - -const waitForWaConnection = vi.fn(); -const webAuthExists = vi.fn(); -const fetchMock = vi.fn(); - -vi.stubGlobal("fetch", fetchMock); - -vi.mock("../web/session.js", () => ({ - createWaSocket: vi.fn(async () => ({ - ws: { close: vi.fn() }, - ev: { on: vi.fn() }, - })), - waitForWaConnection: (...args: unknown[]) => waitForWaConnection(...args), - webAuthExists: (...args: unknown[]) => webAuthExists(...args), - getStatusCode: vi.fn(() => 440), - getWebAuthAgeMs: () => 5000, - logWebSelfId: vi.fn(), -})); - -vi.mock("../web/reconnect.js", () => ({ - resolveHeartbeatSeconds: () => 60, +const callGatewayMock = vi.fn(); +vi.mock("../gateway/call.js", () => ({ + callGateway: (...args: unknown[]) => callGatewayMock(...args), })); describe("healthCommand", () => { beforeEach(() => { vi.clearAllMocks(); - delete process.env.TELEGRAM_BOT_TOKEN; - fetchMock.mockReset(); }); - it("outputs JSON when linked and connect succeeds", async () => { - webAuthExists.mockResolvedValue(true); - waitForWaConnection.mockResolvedValue(undefined); - process.env.TELEGRAM_BOT_TOKEN = "123:abc"; - fetchMock - .mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ ok: true, result: { id: 1, username: "bot" } }), - }) - .mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ ok: true, result: { url: "https://hook" } }), - }); + it("outputs JSON from gateway", async () => { + const snapshot: HealthSummary = { + ts: Date.now(), + durationMs: 5, + web: { + linked: true, + authAgeMs: 5000, + connect: { ok: true, elapsedMs: 10 }, + }, + telegram: { configured: true, probe: { ok: true, elapsedMs: 1 } }, + heartbeatSeconds: 60, + sessions: { + path: "/tmp/sessions.json", + count: 1, + recent: [{ key: "+1555", updatedAt: Date.now(), age: 0 }], + }, + }; + callGatewayMock.mockResolvedValueOnce(snapshot); await healthCommand({ json: true, timeoutMs: 5000 }, runtime as never); expect(runtime.exit).not.toHaveBeenCalled(); - const logged = runtime.log.mock.calls[0][0] as string; - const parsed = JSON.parse(logged); + const logged = runtime.log.mock.calls[0]?.[0] as string; + const parsed = JSON.parse(logged) as HealthSummary; expect(parsed.web.linked).toBe(true); - expect(parsed.web.connect.ok).toBe(true); expect(parsed.telegram.configured).toBe(true); - expect(parsed.telegram.probe.ok).toBe(true); expect(parsed.sessions.count).toBe(1); }); - it("exits non-zero when not linked", async () => { - webAuthExists.mockResolvedValue(false); - await healthCommand({ json: true }, runtime as never); - expect(runtime.exit).toHaveBeenCalledWith(1); - }); + it("prints text summary when not json", async () => { + callGatewayMock.mockResolvedValueOnce({ + ts: Date.now(), + durationMs: 5, + web: { linked: false, authAgeMs: null }, + telegram: { configured: false }, + heartbeatSeconds: 60, + sessions: { path: "/tmp/sessions.json", count: 0, recent: [] }, + } satisfies HealthSummary); - it("exits non-zero when connect fails", async () => { - webAuthExists.mockResolvedValue(true); - waitForWaConnection.mockRejectedValueOnce({ output: { statusCode: 440 } }); + await healthCommand({ json: false }, runtime as never); - await healthCommand({ json: true }, runtime as never); - - expect(runtime.exit).toHaveBeenCalledWith(1); - const logged = runtime.log.mock.calls[0][0] as string; - const parsed = JSON.parse(logged); - expect(parsed.web.connect.ok).toBe(false); - expect(parsed.web.connect.status).toBe(440); - }); - - it("exits non-zero when telegram probe fails", async () => { - webAuthExists.mockResolvedValue(true); - waitForWaConnection.mockResolvedValue(undefined); - process.env.TELEGRAM_BOT_TOKEN = "123:abc"; - fetchMock.mockResolvedValue({ - ok: false, - status: 401, - json: async () => ({ ok: false, description: "unauthorized" }), - }); - - await healthCommand({ json: true }, runtime as never); - - expect(runtime.exit).toHaveBeenCalledWith(1); - const logged = runtime.log.mock.calls[0][0] as string; - const parsed = JSON.parse(logged); - expect(parsed.telegram.configured).toBe(true); - expect(parsed.telegram.probe.ok).toBe(false); - expect(parsed.telegram.probe.status).toBe(401); + expect(runtime.exit).not.toHaveBeenCalled(); + expect(runtime.log).toHaveBeenCalled(); }); }); diff --git a/src/web/outbound.test.ts b/src/web/outbound.test.ts index a4028c6c1..d9bfd9b40 100644 --- a/src/web/outbound.test.ts +++ b/src/web/outbound.test.ts @@ -1,22 +1,7 @@ -import type { AnyMessageContent } from "@whiskeysockets/baileys"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resetLogger, setLoggerOverride } from "../logging.js"; - -vi.mock("./session.js", () => { - const { EventEmitter } = require("node:events"); - const ev = new EventEmitter(); - const sock = { - ev, - ws: { close: vi.fn() }, - sendPresenceUpdate: vi.fn().mockResolvedValue(undefined), - sendMessage: vi.fn().mockResolvedValue({ key: { id: "msg123" } }), - }; - return { - createWaSocket: vi.fn().mockResolvedValue(sock), - waitForWaConnection: vi.fn().mockResolvedValue(undefined), - }; -}); +import { setActiveWebListener } from "./active-listener.js"; const loadWebMediaMock = vi.fn(); vi.mock("./media.js", () => ({ @@ -25,23 +10,34 @@ vi.mock("./media.js", () => ({ import { sendMessageWhatsApp } from "./outbound.js"; -const { createWaSocket } = await import("./session.js"); - describe("web outbound", () => { + const sendComposingTo = vi.fn(async () => {}); + const sendMessage = vi.fn(async () => ({ messageId: "msg123" })); + beforeEach(() => { vi.clearAllMocks(); + setActiveWebListener({ sendComposingTo, sendMessage }); }); afterEach(() => { resetLogger(); setLoggerOverride(null); + setActiveWebListener(null); }); - it("sends message via web and closes socket", async () => { - await sendMessageWhatsApp("+1555", "hi", { verbose: false }); - const sock = await createWaSocket(); - expect(sock.sendMessage).toHaveBeenCalled(); - expect(sock.ws.close).toHaveBeenCalled(); + it("sends message via active listener", async () => { + const result = await sendMessageWhatsApp("+1555", "hi", { verbose: false }); + expect(result).toEqual({ + messageId: "msg123", + toJid: "1555@s.whatsapp.net", + }); + expect(sendComposingTo).toHaveBeenCalledWith("+1555"); + expect(sendMessage).toHaveBeenCalledWith( + "+1555", + "hi", + undefined, + undefined, + ); }); it("maps audio to PTT with opus mime when ogg", async () => { @@ -55,16 +51,12 @@ describe("web outbound", () => { verbose: false, mediaUrl: "/tmp/voice.ogg", }); - const sock = await createWaSocket(); - const [, payload] = sock.sendMessage.mock.calls.at(-1) as [ - string, - AnyMessageContent, - ]; - expect(payload).toMatchObject({ - audio: buf, - ptt: true, - mimetype: "audio/ogg; codecs=opus", - }); + expect(sendMessage).toHaveBeenLastCalledWith( + "+1555", + "voice note", + buf, + "audio/ogg; codecs=opus", + ); }); it("maps video with caption", async () => { @@ -78,16 +70,12 @@ describe("web outbound", () => { verbose: false, mediaUrl: "/tmp/video.mp4", }); - const sock = await createWaSocket(); - const [, payload] = sock.sendMessage.mock.calls.at(-1) as [ - string, - AnyMessageContent, - ]; - expect(payload).toMatchObject({ - video: buf, - caption: "clip", - mimetype: "video/mp4", - }); + expect(sendMessage).toHaveBeenLastCalledWith( + "+1555", + "clip", + buf, + "video/mp4", + ); }); it("maps image with caption", async () => { @@ -101,16 +89,12 @@ describe("web outbound", () => { verbose: false, mediaUrl: "/tmp/pic.jpg", }); - const sock = await createWaSocket(); - const [, payload] = sock.sendMessage.mock.calls.at(-1) as [ - string, - AnyMessageContent, - ]; - expect(payload).toMatchObject({ - image: buf, - caption: "pic", - mimetype: "image/jpeg", - }); + expect(sendMessage).toHaveBeenLastCalledWith( + "+1555", + "pic", + buf, + "image/jpeg", + ); }); it("maps other kinds to document with filename", async () => { @@ -125,16 +109,11 @@ describe("web outbound", () => { verbose: false, mediaUrl: "/tmp/file.pdf", }); - const sock = await createWaSocket(); - const [, payload] = sock.sendMessage.mock.calls.at(-1) as [ - string, - AnyMessageContent, - ]; - expect(payload).toMatchObject({ - document: buf, - fileName: "file.pdf", - caption: "doc", - mimetype: "application/pdf", - }); + expect(sendMessage).toHaveBeenLastCalledWith( + "+1555", + "doc", + buf, + "application/pdf", + ); }); });