| import { EventEmitter } from "node:events"; |
| import fsSync from "node:fs"; |
| import path from "node:path"; |
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| import { resetLogger, setLoggerOverride } from "../logging.js"; |
| import { baileys, getLastSocket, resetBaileysMocks, resetLoadConfigMock } from "./test-helpers.js"; |
|
|
| const { createWaSocket, formatError, logWebSelfId, waitForWaConnection } = |
| await import("./session.js"); |
|
|
| describe("web session", () => { |
| beforeEach(() => { |
| vi.clearAllMocks(); |
| resetBaileysMocks(); |
| resetLoadConfigMock(); |
| }); |
|
|
| afterEach(() => { |
| resetLogger(); |
| setLoggerOverride(null); |
| vi.useRealTimers(); |
| }); |
|
|
| it("creates WA socket with QR handler", async () => { |
| await createWaSocket(true, false); |
| const makeWASocket = baileys.makeWASocket as ReturnType<typeof vi.fn>; |
| expect(makeWASocket).toHaveBeenCalledWith( |
| expect.objectContaining({ printQRInTerminal: false }), |
| ); |
| const passed = makeWASocket.mock.calls[0][0]; |
| const passedLogger = (passed as { logger?: { level?: string; trace?: unknown } }).logger; |
| expect(passedLogger?.level).toBe("silent"); |
| expect(typeof passedLogger?.trace).toBe("function"); |
| const sock = getLastSocket(); |
| const saveCreds = (await baileys.useMultiFileAuthState.mock.results[0].value).saveCreds; |
| |
| sock.ev.emit("creds.update", {}); |
| await new Promise<void>((resolve) => setImmediate(resolve)); |
| expect(saveCreds).toHaveBeenCalled(); |
| }); |
|
|
| it("waits for connection open", async () => { |
| const ev = new EventEmitter(); |
| const promise = waitForWaConnection({ ev } as unknown as ReturnType< |
| typeof baileys.makeWASocket |
| >); |
| ev.emit("connection.update", { connection: "open" }); |
| await expect(promise).resolves.toBeUndefined(); |
| }); |
|
|
| it("rejects when connection closes", async () => { |
| const ev = new EventEmitter(); |
| const promise = waitForWaConnection({ ev } as unknown as ReturnType< |
| typeof baileys.makeWASocket |
| >); |
| ev.emit("connection.update", { |
| connection: "close", |
| lastDisconnect: new Error("bye"), |
| }); |
| await expect(promise).rejects.toBeInstanceOf(Error); |
| }); |
|
|
| it("logWebSelfId prints cached E.164 when creds exist", () => { |
| const existsSpy = vi.spyOn(fsSync, "existsSync").mockImplementation((p) => { |
| if (typeof p !== "string") { |
| return false; |
| } |
| return p.endsWith("creds.json"); |
| }); |
| const readSpy = vi.spyOn(fsSync, "readFileSync").mockImplementation((p) => { |
| if (typeof p === "string" && p.endsWith("creds.json")) { |
| return JSON.stringify({ me: { id: "12345@s.whatsapp.net" } }); |
| } |
| throw new Error(`unexpected readFileSync path: ${String(p)}`); |
| }); |
| const runtime = { |
| log: vi.fn(), |
| error: vi.fn(), |
| exit: vi.fn(), |
| }; |
|
|
| logWebSelfId("/tmp/wa-creds", runtime as never, true); |
|
|
| expect(runtime.log).toHaveBeenCalledWith( |
| expect.stringContaining("Web Channel: +12345 (jid 12345@s.whatsapp.net)"), |
| ); |
| existsSpy.mockRestore(); |
| readSpy.mockRestore(); |
| }); |
|
|
| it("formatError prints Boom-like payload message", () => { |
| const err = { |
| error: { |
| isBoom: true, |
| output: { |
| statusCode: 408, |
| payload: { |
| statusCode: 408, |
| error: "Request Time-out", |
| message: "QR refs attempts ended", |
| }, |
| }, |
| }, |
| }; |
| expect(formatError(err)).toContain("status=408"); |
| expect(formatError(err)).toContain("Request Time-out"); |
| expect(formatError(err)).toContain("QR refs attempts ended"); |
| }); |
|
|
| it("does not clobber creds backup when creds.json is corrupted", async () => { |
| const credsSuffix = path.join(".openclaw", "credentials", "whatsapp", "default", "creds.json"); |
|
|
| const copySpy = vi.spyOn(fsSync, "copyFileSync").mockImplementation(() => {}); |
| const existsSpy = vi.spyOn(fsSync, "existsSync").mockImplementation((p) => { |
| if (typeof p !== "string") { |
| return false; |
| } |
| return p.endsWith(credsSuffix); |
| }); |
| const statSpy = vi.spyOn(fsSync, "statSync").mockImplementation((p) => { |
| if (typeof p === "string" && p.endsWith(credsSuffix)) { |
| return { isFile: () => true, size: 12 } as never; |
| } |
| throw new Error(`unexpected statSync path: ${String(p)}`); |
| }); |
| const readSpy = vi.spyOn(fsSync, "readFileSync").mockImplementation((p) => { |
| if (typeof p === "string" && p.endsWith(credsSuffix)) { |
| return "{" as never; |
| } |
| throw new Error(`unexpected readFileSync path: ${String(p)}`); |
| }); |
|
|
| await createWaSocket(false, false); |
| const sock = getLastSocket(); |
| const saveCreds = (await baileys.useMultiFileAuthState.mock.results[0].value).saveCreds; |
|
|
| sock.ev.emit("creds.update", {}); |
| await new Promise<void>((resolve) => setImmediate(resolve)); |
|
|
| expect(copySpy).not.toHaveBeenCalled(); |
| expect(saveCreds).toHaveBeenCalled(); |
|
|
| copySpy.mockRestore(); |
| existsSpy.mockRestore(); |
| statSpy.mockRestore(); |
| readSpy.mockRestore(); |
| }); |
|
|
| it("serializes creds.update saves to avoid overlapping writes", async () => { |
| let inFlight = 0; |
| let maxInFlight = 0; |
| let release: (() => void) | null = null; |
| const gate = new Promise<void>((resolve) => { |
| release = resolve; |
| }); |
|
|
| const saveCreds = vi.fn(async () => { |
| inFlight += 1; |
| maxInFlight = Math.max(maxInFlight, inFlight); |
| await gate; |
| inFlight -= 1; |
| }); |
| baileys.useMultiFileAuthState.mockResolvedValueOnce({ |
| state: { creds: {}, keys: {} }, |
| saveCreds, |
| }); |
|
|
| await createWaSocket(false, false); |
| const sock = getLastSocket(); |
|
|
| sock.ev.emit("creds.update", {}); |
| sock.ev.emit("creds.update", {}); |
|
|
| await new Promise<void>((resolve) => setImmediate(resolve)); |
| expect(inFlight).toBe(1); |
|
|
| release?.(); |
|
|
| |
| await new Promise<void>((resolve) => setImmediate(resolve)); |
| await new Promise<void>((resolve) => setImmediate(resolve)); |
|
|
| expect(saveCreds).toHaveBeenCalledTimes(2); |
| expect(maxInFlight).toBe(1); |
| expect(inFlight).toBe(0); |
| }); |
|
|
| it("rotates creds backup when creds.json is valid JSON", async () => { |
| const credsSuffix = path.join(".openclaw", "credentials", "whatsapp", "default", "creds.json"); |
| const backupSuffix = path.join( |
| ".openclaw", |
| "credentials", |
| "whatsapp", |
| "default", |
| "creds.json.bak", |
| ); |
|
|
| const copySpy = vi.spyOn(fsSync, "copyFileSync").mockImplementation(() => {}); |
| const existsSpy = vi.spyOn(fsSync, "existsSync").mockImplementation((p) => { |
| if (typeof p !== "string") { |
| return false; |
| } |
| return p.endsWith(credsSuffix); |
| }); |
| const statSpy = vi.spyOn(fsSync, "statSync").mockImplementation((p) => { |
| if (typeof p === "string" && p.endsWith(credsSuffix)) { |
| return { isFile: () => true, size: 12 } as never; |
| } |
| throw new Error(`unexpected statSync path: ${String(p)}`); |
| }); |
| const readSpy = vi.spyOn(fsSync, "readFileSync").mockImplementation((p) => { |
| if (typeof p === "string" && p.endsWith(credsSuffix)) { |
| return "{}" as never; |
| } |
| throw new Error(`unexpected readFileSync path: ${String(p)}`); |
| }); |
|
|
| await createWaSocket(false, false); |
| const sock = getLastSocket(); |
| const saveCreds = (await baileys.useMultiFileAuthState.mock.results[0].value).saveCreds; |
|
|
| sock.ev.emit("creds.update", {}); |
| await new Promise<void>((resolve) => setImmediate(resolve)); |
|
|
| expect(copySpy).toHaveBeenCalledTimes(1); |
| const args = copySpy.mock.calls[0] ?? []; |
| expect(String(args[0] ?? "")).toContain(credsSuffix); |
| expect(String(args[1] ?? "")).toContain(backupSuffix); |
| expect(saveCreds).toHaveBeenCalled(); |
|
|
| copySpy.mockRestore(); |
| existsSpy.mockRestore(); |
| statSpy.mockRestore(); |
| readSpy.mockRestore(); |
| }); |
| }); |
|
|