Files
OwnCord/Client/tauri-client/tests/unit/ws-messaging.test.ts
T
J3vbandClaude Fable 5 d880b64d64 test: audit 2026-08-19 — fix stale tests, close coverage gaps (#1397)
* test(server): admin/handlers/channels — test-audit 2026-08-19 fixes

* test(server): api/constants — test-audit 2026-08-19 fixes

* test(server): api/middleware — test-audit 2026-08-19 fixes

* test(server): api/waf — test-audit 2026-08-19 fixes

* test(server): auth/totp/encrypt — test-audit 2026-08-19 fixes

* test(server): db/session/expiry/test — test-audit 2026-08-19 fixes

* test(server): migrations/030/attachments/unlink/on/message/delete — test-audit 2026-08-19 fixes

* test(server): updater/download — test-audit 2026-08-19 fixes

* test(server): ws/handlers_command — test-audit 2026-08-19 fixes

* test(server): ws/hub/broadcast — test-audit 2026-08-19 fixes

* test(server): ws/hub/events — test-audit 2026-08-19 fixes

* test(server): ws/livekit/webhook — test-audit 2026-08-19 fixes

* test(server): ws/voice/controls — test-audit 2026-08-19 fixes

* test(server): ws/voice/join — test-audit 2026-08-19 fixes

* test(server): ws/voice/moderation — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/commands.rs — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/secret_store.rs — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/update_commands.rs — test-audit 2026-08-19 fixes

* test(client): src/components/ChannelSidebar.ts — test-audit 2026-08-19 fixes

* test(client): src/lib/ws.ts — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/credentials.rs — test-audit 2026-08-19 fixes

* test(rust): src-tauri/src/tofu.rs — test-audit 2026-08-19 fixes

* test(client): src/lib/hostValidation.ts — test-audit 2026-08-19 fixes

* test(client): src/lib/rate-limiter.ts — test-audit 2026-08-19 fixes

* test(client): src/pages/connect-page/LoginForm.ts — test-audit 2026-08-19 fixes

* test(client): src/pages/main-page/SidebarArea.ts — test-audit 2026-08-19 fixes

* test(client): src/stores/voice.store.ts — test-audit 2026-08-19 fixes

* test(client): tests/browser/smoke.test.ts — test-audit 2026-08-19 fixes

* test(client): tests/unit/media.test.ts — test-audit 2026-08-19 fixes

* test(client): tests/unit/renderers.test.ts — test-audit 2026-08-19 fixes

* test(client): src/components/UserProfilePopup.ts — test-audit 2026-08-19 fixes

* test(client): src/lib/e2eeCrypto.ts — test-audit 2026-08-19 fixes

* test(client): tests/unit/log-persistence.test.ts — test-audit 2026-08-19 fixes

* test(client): keep tests/browser out of the jsdom suite and run it in CI

* test(server): ws/hub_broadcast_test.go — bytes.Equal payload compare (gocritic)

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

* test(client): src/lib/credentials.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/dispatcher.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/permissions.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/rate-limiter.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/hostValidation.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/stores/messages.store.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/e2eeCrypto.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/ws.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/identity.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/lib/livekitE2EE.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/stores/auth.store.ts — test-audit 2026-08-19 round 2 (Stryker)

* test(client): src/stores/voice.store.ts — test-audit 2026-08-19 round 2 (Stryker)

* docs: test audit 2026-08-19 — findings, fixes, measured baselines

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

* chore(graph): refresh the knowledge graph after the 2026-08-19 test audit

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 19:29:31 +02:00

1052 lines
30 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { ConnectionState } from "../../src/lib/ws";
// vi.mock is hoisted per file; the factories resolve to the shared handles
// exported from ./helpers/ws-mocks (see that module's doc comment).
vi.mock("@tauri-apps/api/core", async () => ({
invoke: (await import("./helpers/ws-mocks")).mockInvoke,
}));
vi.mock("@tauri-apps/api/event", async () => ({
listen: (await import("./helpers/ws-mocks")).mockListen,
}));
import { mockInvoke, mockListen, eventHandlers, emitTauriEvent } from "./helpers/ws-mocks";
import { createWsClient, toConnectionStatus } from "../../src/lib/ws";
describe("message handling edge cases", () => {
let client: ReturnType<typeof createWsClient>;
beforeEach(() => {
vi.useFakeTimers();
mockInvoke.mockReset();
mockInvoke.mockResolvedValue(undefined);
mockListen.mockClear();
eventHandlers.clear();
client = createWsClient();
});
afterEach(() => {
client.disconnect();
vi.useRealTimers();
});
it("silently ignores pong messages", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
const messages: unknown[] = [];
client.on("chat_message", (p) => messages.push(p));
// "pong" is not part of the ServerMessage union — the transport eats it
// before dispatch — so the registry has to be reached through a cast to
// prove nothing arrives. The frame carries a payload on purpose: a
// payload-less pong is also dropped by the "missing type or payload"
// guard further down, which would hide a broken early return.
const onPong = vi.fn();
(client.on as unknown as (t: string, l: () => void) => () => void)("pong", onPong);
emitTauriEvent("ws-message", JSON.stringify({ type: "pong", payload: {} }));
expect(onPong).not.toHaveBeenCalled();
expect(messages).toHaveLength(0);
});
it("drops messages with missing type", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
const messages: unknown[] = [];
client.on("chat_message", (p) => messages.push(p));
emitTauriEvent("ws-message", JSON.stringify({ payload: { data: "no type" } }));
expect(messages).toHaveLength(0);
});
it("drops messages with undefined payload", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
const messages: unknown[] = [];
client.on("chat_message", (p) => messages.push(p));
emitTauriEvent("ws-message", JSON.stringify({ type: "chat_message" }));
expect(messages).toHaveLength(0);
});
it("tracks highest seq number (ignores lower seq)", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "auth_ok",
seq: 10,
payload: {
user: { id: 1, username: "a", avatar: null, role: "admin" },
server_name: "S",
motd: "",
},
}),
);
// seq=50 then seq=30 — should keep 50
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "chat_message",
seq: 50,
payload: {
id: 1,
channel_id: 1,
user: { id: 1, username: "a", avatar: null },
content: "hi",
reply_to: null,
attachments: [],
timestamp: "2026-01-01T00:00:00Z",
},
}),
);
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "chat_message",
seq: 30,
payload: {
id: 2,
channel_id: 1,
user: { id: 1, username: "a", avatar: null },
content: "hello",
reply_to: null,
attachments: [],
timestamp: "2026-01-01T00:00:00Z",
},
}),
);
// Disconnect and reconnect to verify lastSeq
emitTauriEvent("ws-state", "closed");
mockInvoke.mockClear();
await vi.advanceTimersByTimeAsync(1100);
emitTauriEvent("ws-state", "open");
const authCall = mockInvoke.mock.calls.find(
(c) =>
c[0] === "ws_send" &&
typeof c[1]?.message === "string" &&
(c[1].message as string).includes('"type":"auth"'),
);
const authMsg = JSON.parse((authCall![1] as { message: string }).message);
expect(authMsg.payload.last_seq).toBe(50);
});
it("handles message without seq field (defaults to 0)", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
const messages: unknown[] = [];
client.on("chat_message", (p) => messages.push(p));
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "chat_message",
// no seq field
payload: {
id: 1,
channel_id: 1,
user: { id: 1, username: "a", avatar: null },
content: "no seq",
reply_to: null,
attachments: [],
timestamp: "2026-01-01T00:00:00Z",
},
}),
);
expect(messages).toHaveLength(1);
});
it("dispatch logs when no listeners for message type", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
// Send a message with no listener registered — should log "no listeners"
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "auth_ok",
payload: {
user: { id: 1, username: "a", avatar: null, role: "admin" },
server_name: "S",
motd: "",
},
}),
);
// No crash means the "no listeners" debug log path executed
expect(client.getState()).toBe("connected");
});
it("dispatch catches listener errors", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
// Register a listener that throws
client.on("chat_message", () => {
throw new Error("listener boom");
});
// Also register a second listener to verify it still runs
const messages: unknown[] = [];
client.on("chat_message", (p) => messages.push(p));
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "chat_message",
payload: {
id: 1,
channel_id: 1,
user: { id: 1, username: "a", avatar: null },
content: "test",
reply_to: null,
attachments: [],
timestamp: "2026-01-01T00:00:00Z",
},
}),
);
// Second listener should still receive the message
expect(messages).toHaveLength(1);
});
it("state listener errors are caught", async () => {
client.onStateChange(() => {
throw new Error("state listener boom");
});
// Should not crash
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
expect(client.getState()).toBe("connecting");
});
it("ws-error event is logged without crash", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
// Emit a ws-error event
emitTauriEvent("ws-error", "Connection reset by peer");
// No crash expected
expect(client.getState()).toBe("connecting");
});
it("_getWs returns null", () => {
expect(client._getWs()).toBeNull();
});
it("onStateChange unsubscribe works", async () => {
const states: ConnectionState[] = [];
const unsub = client.onStateChange((s) => states.push(s));
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
expect(states.length).toBeGreaterThan(0);
const count = states.length;
unsub();
emitTauriEvent("ws-state", "open");
expect(states.length).toBe(count);
});
it("onCertMismatch unsubscribe works", async () => {
const events: unknown[] = [];
const unsub = client.onCertMismatch((evt) => events.push(evt));
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
unsub();
emitTauriEvent("cert-tofu", {
host: "localhost:8443",
fingerprint: "sha256:NEW",
status: "mismatch",
message: "Stored: sha256:OLD",
});
expect(events).toHaveLength(0);
});
});
describe("handleMessage size boundary", () => {
let client: ReturnType<typeof createWsClient>;
beforeEach(() => {
vi.useFakeTimers();
mockInvoke.mockReset();
mockInvoke.mockResolvedValue(undefined);
mockListen.mockClear();
eventHandlers.clear();
client = createWsClient();
});
afterEach(() => {
client.disconnect();
vi.useRealTimers();
});
it("accepts message exactly at size limit", async () => {
const limit = 200;
client.connect({ host: "localhost:8443", token: "t", maxMessageSizeBytes: limit });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
const messages: unknown[] = [];
client.on("chat_message", (p) => messages.push(p));
const msg = {
type: "chat_message",
payload: {
id: 1,
channel_id: 1,
user: { id: 1, username: "a", avatar: null },
content: "",
reply_to: null,
attachments: [],
timestamp: "2026-01-01T00:00:00Z",
},
};
const json = JSON.stringify(msg);
// Pad content to make JSON exactly at limit
const padding = limit - json.length;
if (padding > 0) {
msg.payload.content = "x".repeat(padding);
}
const exactJson = JSON.stringify(msg);
// Exactly ON the limit, not merely under it — the guard drops only what is
// strictly OVER, so a frame one byte short would pass either way and prove
// nothing about the boundary.
expect(exactJson.length).toBe(limit);
emitTauriEvent("ws-message", exactJson);
expect(messages).toHaveLength(1);
});
it("drops message one byte over size limit", async () => {
const limit = 100;
client.connect({ host: "localhost:8443", token: "t", maxMessageSizeBytes: limit });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
const messages: unknown[] = [];
client.on("chat_message", (p) => messages.push(p));
const msg = {
type: "chat_message",
payload: {
id: 1,
channel_id: 1,
user: { id: 1, username: "a", avatar: null },
content: "x".repeat(limit), // guarantees over limit
reply_to: null,
attachments: [],
timestamp: "2026-01-01T00:00:00Z",
},
};
emitTauriEvent("ws-message", JSON.stringify(msg));
expect(messages).toHaveLength(0);
});
it("uses default 1MB limit when maxMessageSizeBytes not configured", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
const messages: unknown[] = [];
client.on("chat_message", (p) => messages.push(p));
// Message under 1MB should pass
const smallMsg = JSON.stringify({
type: "chat_message",
payload: {
id: 1,
channel_id: 1,
user: { id: 1, username: "a", avatar: null },
content: "small",
reply_to: null,
attachments: [],
timestamp: "2026-01-01T00:00:00Z",
},
});
emitTauriEvent("ws-message", smallMsg);
expect(messages).toHaveLength(1);
});
it("does not drop an oversized `ready` frame — the handshake payload has no seq and no retry path (OC-0160)", async () => {
const limit = 200;
client.connect({ host: "localhost:8443", token: "t", maxMessageSizeBytes: limit });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
const readyMessages: unknown[] = [];
client.on("ready", (p) => readyMessages.push(p));
const msg = {
type: "ready",
payload: {
channels: [],
// Padding well past `limit` — a real `ready` grows unbounded with the
// server's member/channel/DM counts and carries no seq, so unlike a
// sequenced frame nothing ever re-requests it after a drop.
members: Array.from({ length: 20 }, (_, i) => ({ id: i, username: `user${i}` })),
voice_states: [],
roles: [],
},
};
const json = JSON.stringify(msg);
expect(json.length).toBeGreaterThan(limit);
emitTauriEvent("ws-message", json);
expect(readyMessages).toHaveLength(1);
});
it("does not drop an oversized `auth_ok` frame (handshake exempt from size limit, OC-0160)", async () => {
const limit = 100;
client.connect({ host: "localhost:8443", token: "t", maxMessageSizeBytes: limit });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
const msg = {
type: "auth_ok",
payload: {
user: { id: 1, username: "a".repeat(limit), avatar: null, role: "admin" },
server_name: "S",
motd: "",
},
};
const json = JSON.stringify(msg);
expect(json.length).toBeGreaterThan(limit);
emitTauriEvent("ws-message", json);
expect(client.getState()).toBe("connected");
});
it("still drops an oversized regular (non-handshake) frame (OC-0160 regression guard)", async () => {
const limit = 100;
client.connect({ host: "localhost:8443", token: "t", maxMessageSizeBytes: limit });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
const messages: unknown[] = [];
client.on("chat_message", (p) => messages.push(p));
const msg = {
type: "chat_message",
payload: {
id: 1,
channel_id: 1,
user: { id: 1, username: "a", avatar: null },
content: "x".repeat(limit),
reply_to: null,
attachments: [],
timestamp: "2026-01-01T00:00:00Z",
},
};
emitTauriEvent("ws-message", JSON.stringify(msg));
expect(messages).toHaveLength(0);
});
});
describe("dispatch with no listeners for type", () => {
let client: ReturnType<typeof createWsClient>;
beforeEach(() => {
vi.useFakeTimers();
mockInvoke.mockReset();
mockInvoke.mockResolvedValue(undefined);
mockListen.mockClear();
eventHandlers.clear();
client = createWsClient();
});
afterEach(() => {
client.disconnect();
vi.useRealTimers();
});
it("does not crash when dispatching to type with empty listener set", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
// Register and immediately unregister a listener
const unsub = client.on("chat_message", () => {});
unsub();
// Now dispatch a message to that type — empty set
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "chat_message",
payload: {
id: 1,
channel_id: 1,
user: { id: 1, username: "a", avatar: null },
content: "test",
reply_to: null,
attachments: [],
timestamp: "2026-01-01T00:00:00Z",
},
}),
);
// No crash
expect(true).toBe(true);
});
it("dispatches message with id to listener", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
const receivedIds: (string | undefined)[] = [];
client.on("chat_message", (_payload, id) => {
receivedIds.push(id);
});
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "chat_message",
id: "correlation-123",
payload: {
id: 1,
channel_id: 1,
user: { id: 1, username: "a", avatar: null },
content: "test",
reply_to: null,
attachments: [],
timestamp: "2026-01-01T00:00:00Z",
},
}),
);
expect(receivedIds).toEqual(["correlation-123"]);
});
});
describe("on() creates Set for new type", () => {
let client: ReturnType<typeof createWsClient>;
beforeEach(() => {
vi.useFakeTimers();
mockInvoke.mockReset();
mockInvoke.mockResolvedValue(undefined);
mockListen.mockClear();
eventHandlers.clear();
client = createWsClient();
});
afterEach(() => {
client.disconnect();
vi.useRealTimers();
});
it("creates a listener set for a type that has never been registered", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
const results: unknown[] = [];
client.on("presence", (p) => results.push(p));
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "presence",
payload: { user_id: 1, status: "online" },
}),
);
expect(results).toHaveLength(1);
});
it("multiple listeners on same type all receive messages", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
const results1: unknown[] = [];
const results2: unknown[] = [];
client.on("typing", (p) => results1.push(p));
client.on("typing", (p) => results2.push(p));
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "typing",
payload: { channel_id: 1, user_id: 1, username: "a" },
}),
);
expect(results1).toHaveLength(1);
expect(results2).toHaveLength(1);
});
});
describe("send envelope format", () => {
let client: ReturnType<typeof createWsClient>;
beforeEach(() => {
vi.useFakeTimers();
mockInvoke.mockReset();
mockInvoke.mockResolvedValue(undefined);
mockListen.mockClear();
eventHandlers.clear();
client = createWsClient();
});
afterEach(() => {
client.disconnect();
vi.useRealTimers();
});
it("wraps message with id and serializes to JSON", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "auth_ok",
payload: {
user: { id: 1, username: "a", avatar: null, role: "admin" },
server_name: "S",
motd: "",
},
}),
);
mockInvoke.mockClear();
client.send({
type: "chat_send",
payload: { channel_id: 1, content: "hello", reply_to: null, attachments: [] },
});
const sendCall = mockInvoke.mock.calls.find((c) => c[0] === "ws_send");
expect(sendCall).toBeDefined();
const sent = JSON.parse((sendCall![1] as { message: string }).message);
expect(sent.type).toBe("chat_send");
expect(sent.id).toBe("test-uuid-1234");
expect(sent.payload.channel_id).toBe(1);
expect(sent.payload.content).toBe("hello");
expect(sent.payload.reply_to).toBeNull();
expect(sent.payload.attachments).toEqual([]);
});
});
describe("send edge cases", () => {
let client: ReturnType<typeof createWsClient>;
beforeEach(() => {
vi.useFakeTimers();
mockInvoke.mockReset();
mockInvoke.mockResolvedValue(undefined);
mockListen.mockClear();
eventHandlers.clear();
client = createWsClient();
});
afterEach(() => {
client.disconnect();
vi.useRealTimers();
});
it("send when not connected does not crash (logs warning)", () => {
// Client is disconnected — send should warn but not crash
const id = client.send({
type: "chat_send",
payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] },
});
expect(id).toBe("test-uuid-1234");
});
it("ws_connect failure triggers reconnect", async () => {
mockInvoke.mockImplementation(async (cmd: string) => {
if (cmd === "ws_connect") throw new Error("connection refused");
return undefined;
});
const states: ConnectionState[] = [];
client.onStateChange((s) => states.push(s));
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
// Should attempt reconnect after failure
expect(states).toContain("reconnecting");
});
it("reconnect with successful auth_ok resets reconnect attempt counter", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "auth_ok",
seq: 1,
payload: {
user: { id: 1, username: "a", avatar: null, role: "admin" },
server_name: "S",
motd: "",
},
}),
);
// Drop connection
emitTauriEvent("ws-state", "closed");
// First reconnect (1s backoff)
await vi.advanceTimersByTimeAsync(1100);
emitTauriEvent("ws-state", "open");
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "auth_ok",
seq: 2,
payload: {
user: { id: 1, username: "a", avatar: null, role: "admin" },
server_name: "S",
motd: "",
},
}),
);
// Drop again
emitTauriEvent("ws-state", "closed");
// If reconnect counter was reset, delay should be back to 1s (not 2s)
mockInvoke.mockClear();
await vi.advanceTimersByTimeAsync(1100);
const reconnects = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect");
expect(reconnects.length).toBeGreaterThanOrEqual(1);
});
it("ws_send rejection is caught without crash", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "auth_ok",
seq: 1,
payload: {
user: { id: 1, username: "a", avatar: null, role: "admin" },
server_name: "S",
motd: "",
},
}),
);
// Make ws_send reject
mockInvoke.mockImplementation(async (cmd: string) => {
if (cmd === "ws_send") throw new Error("send failed");
return undefined;
});
// Send should not crash despite ws_send rejection
client.send({
type: "chat_send",
payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] },
});
// Flush promise to trigger the catch
await vi.advanceTimersByTimeAsync(10);
expect(client.getState()).toBe("connected");
});
it("onSendFailure fires with NETWORK when ws_send hits backpressure (channel full)", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "auth_ok",
seq: 1,
payload: {
user: { id: 1, username: "a", avatar: null, role: "admin" },
server_name: "S",
motd: "",
},
}),
);
mockInvoke.mockImplementation(async (cmd: string) => {
if (cmd === "ws_send") throw new Error("ws_send: channel full, message dropped");
return undefined;
});
const failures: Array<{ id: string; code: string }> = [];
client.onSendFailure((id, code) => failures.push({ id, code }));
const id = client.send({
type: "chat_send",
payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] },
});
await vi.advanceTimersByTimeAsync(10);
expect(failures).toEqual([{ id, code: "NETWORK" }]);
});
it("onSendFailure fires with OFFLINE when ws_send reports the channel closed", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "auth_ok",
seq: 1,
payload: {
user: { id: 1, username: "a", avatar: null, role: "admin" },
server_name: "S",
motd: "",
},
}),
);
mockInvoke.mockImplementation(async (cmd: string) => {
if (cmd === "ws_send") throw new Error("ws_send: channel closed");
return undefined;
});
const failures: Array<{ id: string; code: string }> = [];
client.onSendFailure((id, code) => failures.push({ id, code }));
const id = client.send({
type: "chat_send",
payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] },
});
await vi.advanceTimersByTimeAsync(10);
expect(failures).toEqual([{ id, code: "OFFLINE" }]);
});
it("onSendFailure fires with OFFLINE when sending while the proxy is not open", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "auth_ok",
seq: 1,
payload: {
user: { id: 1, username: "a", avatar: null, role: "admin" },
server_name: "S",
motd: "",
},
}),
);
// Drop the proxy: subsequent sends take the not-open early return.
emitTauriEvent("ws-state", "closed");
const failures: Array<{ id: string; code: string }> = [];
client.onSendFailure((id, code) => failures.push({ id, code }));
const id = client.send({
type: "chat_send",
payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] },
});
// The early-return notification is deferred a microtask so callers can
// register the id (optimistic row) before the failure lands.
expect(failures).toEqual([]);
await vi.advanceTimersByTimeAsync(0);
expect(failures).toEqual([{ id, code: "OFFLINE" }]);
});
it("heartbeat ping failures do not fire onSendFailure (no envelope id)", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "auth_ok",
seq: 1,
payload: {
user: { id: 1, username: "a", avatar: null, role: "admin" },
server_name: "S",
motd: "",
},
}),
);
mockInvoke.mockImplementation(async (cmd: string) => {
if (cmd === "ws_send") throw new Error("ws_send: channel full, message dropped");
return undefined;
});
const failures: Array<{ id: string; code: string }> = [];
client.onSendFailure((id, code) => failures.push({ id, code }));
// Let the 30s heartbeat fire (and its ws_send reject).
await vi.advanceTimersByTimeAsync(30_100);
expect(failures).toEqual([]);
});
it("onSendFailure unsubscribe works", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "auth_ok",
seq: 1,
payload: {
user: { id: 1, username: "a", avatar: null, role: "admin" },
server_name: "S",
motd: "",
},
}),
);
mockInvoke.mockImplementation(async (cmd: string) => {
if (cmd === "ws_send") throw new Error("ws_send: channel full, message dropped");
return undefined;
});
const failures: Array<{ id: string; code: string }> = [];
const unsub = client.onSendFailure((id, code) => failures.push({ id, code }));
unsub();
client.send({
type: "chat_send",
payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] },
});
await vi.advanceTimersByTimeAsync(10);
expect(failures).toEqual([]);
});
it("ws_disconnect error is ignored during disconnectProxy", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "auth_ok",
seq: 1,
payload: {
user: { id: 1, username: "a", avatar: null, role: "admin" },
server_name: "S",
motd: "",
},
}),
);
// Make ws_disconnect throw
mockInvoke.mockImplementation(async (cmd: string) => {
if (cmd === "ws_disconnect") throw new Error("disconnect failed");
return undefined;
});
// Disconnect should not crash
client.disconnect();
await vi.advanceTimersByTimeAsync(10);
expect(client.getState()).toBe("disconnected");
});
it("reconnect delay is capped by maxReconnectDelayMs", async () => {
client.connect({
host: "localhost:8443",
token: "t",
maxReconnectDelayMs: 5000,
});
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "auth_ok",
seq: 1,
payload: {
user: { id: 1, username: "a", avatar: null, role: "admin" },
server_name: "S",
motd: "",
},
}),
);
// Force multiple reconnect attempts to ramp up backoff
for (let i = 0; i < 5; i++) {
emitTauriEvent("ws-state", "closed");
await vi.advanceTimersByTimeAsync(10_000); // well past any backoff
emitTauriEvent("ws-state", "open");
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "auth_ok",
seq: i + 2,
payload: {
user: { id: 1, username: "a", avatar: null, role: "admin" },
server_name: "S",
motd: "",
},
}),
);
}
// At this point, the reconnect delay should be capped at 5000ms
// The fact that the loop completed without hanging proves capping works
expect(client.getState()).toBe("connected");
});
});
describe("toConnectionStatus", () => {
it("maps the internal 5-state machine onto the UX-facing 3-state status", () => {
expect(toConnectionStatus("connected")).toBe("connected");
expect(toConnectionStatus("disconnected")).toBe("disconnected");
// Mid-retry states must read as "reconnecting", not "disconnected" —
// a reconnect cycle passes through connecting/authenticating.
expect(toConnectionStatus("reconnecting")).toBe("reconnecting");
expect(toConnectionStatus("connecting")).toBe("reconnecting");
expect(toConnectionStatus("authenticating")).toBe("reconnecting");
});
});