Files
openclaw/extensions/whatsapp/src/session.test.ts
Jason 9d3e653ec9 fix(web): handle 515 Stream Error during WhatsApp QR pairing (#27910)
* fix(web): handle 515 Stream Error during WhatsApp QR pairing

getStatusCode() never unwrapped the lastDisconnect wrapper object,
so login.errorStatus was always undefined and the 515 restart path
in restartLoginSocket was dead code.

- Add err.error?.output?.statusCode fallback to getStatusCode()
- Export waitForCredsSaveQueue() so callers can await pending creds
- Await creds flush in restartLoginSocket before creating new socket

Fixes #3942

* test: update session mock for getStatusCode unwrap + waitForCredsSaveQueue

Mirror the getStatusCode fix (err.error?.output?.statusCode fallback)
in the test mock and export waitForCredsSaveQueue so restartLoginSocket
tests work correctly.

* fix(web): scope creds save queue per-authDir to avoid cross-account blocking

The credential save queue was a single global promise chain shared by all
WhatsApp accounts. In multi-account setups, a slow save on one account
blocked credential writes and 515 restart recovery for unrelated accounts.

Replace the global queue with a per-authDir Map so each account's creds
serialize independently. waitForCredsSaveQueue() now accepts an optional
authDir to wait on a single account's queue, or waits on all when omitted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: use real Baileys v7 error shape in 515 restart test

The test was using { output: { statusCode: 515 } } which was already
handled before the fix. Updated to use the actual Baileys v7 shape
{ error: { output: { statusCode: 515 } } } to cover the new fallback
path in getStatusCode.

Co-Authored-By: Claude Code (Opus 4.6) <noreply@anthropic.com>

* fix(web): bound credential-queue wait during 515 restart

Prevents restartLoginSocket from blocking indefinitely if a queued
saveCreds() promise stalls (e.g. hung filesystem write).

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: clear flush timeout handle and assert creds queue in test

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: evict settled credsSaveQueues entries to prevent unbounded growth

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: share WhatsApp 515 creds flush handling (#27910) (thanks @asyncjason)

---------

Co-authored-by: Jason Separovic <jason@wilma.dog>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-15 17:00:07 +05:30

285 lines
8.6 KiB
TypeScript

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 "../../../src/logging.js";
import { baileys, getLastSocket, resetBaileysMocks, resetLoadConfigMock } from "./test-helpers.js";
const { createWaSocket, formatError, logWebSelfId, waitForWaConnection } =
await import("./session.js");
const useMultiFileAuthStateMock = vi.mocked(baileys.useMultiFileAuthState);
async function flushCredsUpdate() {
await new Promise<void>((resolve) => setImmediate(resolve));
}
async function emitCredsUpdateAndReadSaveCreds() {
const sock = getLastSocket();
const saveCreds = (await useMultiFileAuthStateMock.mock.results[0]?.value)?.saveCreds;
sock.ev.emit("creds.update", {});
await flushCredsUpdate();
return saveCreds;
}
function mockCredsJsonSpies(readContents: string) {
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 readContents as never;
}
throw new Error(`unexpected readFileSync path: ${String(p)}`);
});
return {
copySpy,
credsSuffix,
restore: () => {
copySpy.mockRestore();
existsSpy.mockRestore();
statSpy.mockRestore();
readSpy.mockRestore();
},
};
}
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 useMultiFileAuthStateMock.mock.results[0]?.value)?.saveCreds;
// trigger creds.update listener
sock.ev.emit("creds.update", {});
await flushCredsUpdate();
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 creds = mockCredsJsonSpies("{");
await createWaSocket(false, false);
const saveCreds = await emitCredsUpdateAndReadSaveCreds();
expect(creds.copySpy).not.toHaveBeenCalled();
expect(saveCreds).toHaveBeenCalled();
creds.restore();
});
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;
});
useMultiFileAuthStateMock.mockResolvedValueOnce({
state: { creds: {} as never, keys: {} as never },
saveCreds,
});
await createWaSocket(false, false);
const sock = getLastSocket();
sock.ev.emit("creds.update", {});
sock.ev.emit("creds.update", {});
await flushCredsUpdate();
expect(inFlight).toBe(1);
(release as (() => void) | null)?.();
// let both queued saves complete
await flushCredsUpdate();
await flushCredsUpdate();
expect(saveCreds).toHaveBeenCalledTimes(2);
expect(maxInFlight).toBe(1);
expect(inFlight).toBe(0);
});
it("lets different authDir queues flush independently", async () => {
let inFlightA = 0;
let inFlightB = 0;
let releaseA: (() => void) | null = null;
let releaseB: (() => void) | null = null;
const gateA = new Promise<void>((resolve) => {
releaseA = resolve;
});
const gateB = new Promise<void>((resolve) => {
releaseB = resolve;
});
const saveCredsA = vi.fn(async () => {
inFlightA += 1;
await gateA;
inFlightA -= 1;
});
const saveCredsB = vi.fn(async () => {
inFlightB += 1;
await gateB;
inFlightB -= 1;
});
useMultiFileAuthStateMock
.mockResolvedValueOnce({
state: { creds: {} as never, keys: {} as never },
saveCreds: saveCredsA,
})
.mockResolvedValueOnce({
state: { creds: {} as never, keys: {} as never },
saveCreds: saveCredsB,
});
await createWaSocket(false, false, { authDir: "/tmp/wa-a" });
const sockA = getLastSocket();
await createWaSocket(false, false, { authDir: "/tmp/wa-b" });
const sockB = getLastSocket();
sockA.ev.emit("creds.update", {});
sockB.ev.emit("creds.update", {});
await flushCredsUpdate();
expect(saveCredsA).toHaveBeenCalledTimes(1);
expect(saveCredsB).toHaveBeenCalledTimes(1);
expect(inFlightA).toBe(1);
expect(inFlightB).toBe(1);
(releaseA as (() => void) | null)?.();
(releaseB as (() => void) | null)?.();
await flushCredsUpdate();
await flushCredsUpdate();
expect(inFlightA).toBe(0);
expect(inFlightB).toBe(0);
});
it("rotates creds backup when creds.json is valid JSON", async () => {
const creds = mockCredsJsonSpies("{}");
const backupSuffix = path.join(
".openclaw",
"credentials",
"whatsapp",
"default",
"creds.json.bak",
);
await createWaSocket(false, false);
const saveCreds = await emitCredsUpdateAndReadSaveCreds();
expect(creds.copySpy).toHaveBeenCalledTimes(1);
const args = creds.copySpy.mock.calls[0] ?? [];
expect(String(args[0] ?? "")).toContain(creds.credsSuffix);
expect(String(args[1] ?? "")).toContain(backupSuffix);
expect(saveCreds).toHaveBeenCalled();
creds.restore();
});
});