mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix: enhance bugfix workflow documentation with detailed clustering and staging instructions * fix(voice): 6 defect(s) (OC-0001, OC-0006, OC-0009, OC-0010, OC-0015, OC-0029) * fix(voice): 1 defect(s) (OC-0005) * fix(client): 1 defect(s) (OC-0007) * fix(client): 1 defect(s) (OC-0011) * fix(client): 1 defect(s) (OC-0012) * fix(admin): 1 defect(s) (OC-0013) * fix(client): 3 defect(s) (OC-0014, OC-0024, OC-0031) * fix(voice): 1 defect(s) (OC-0018) * fix(voice): 1 defect(s) (OC-0019) * fix(client): 1 defect(s) (OC-0021) * fix(client): 1 defect(s) (OC-0025) * fix(ws): 1 defect(s) (OC-0026) * fix(client): 1 defect(s) (OC-0027) * fix(client): 1 defect(s) (OC-0028) * fix(identity): 1 defect(s) (OC-0030) * fix(voice): 1 defect(s) (OC-0016) * fix(client): 2 defect(s) (OC-0002, OC-0020) OC-0002: chain offer handling behind the announce chain so an offer that arrives immediately behind its sender's announce is not dropped as an unknown peer. OC-0020: retire a departing peer's ECDH key on participant-left so a replayed pre-leave announce cannot overwrite the fresh key they rejoined with. * fix(voice): 1 defect(s) (OC-0008) handleVoiceJoin handed the client its LiveKit token before checking whether the join had been superseded by a concurrent eviction (moderator kick/move, the CONNECT_VOICE revocation sweep, CleanupVoiceForChannel). Those evictors delete the voice_states row, clear the client's in-memory state, and call RemoveParticipant — which no-ops because the join has not reached the SFU yet. The client was left holding a live 5-minute RoomJoin credential for a membership the server had just torn down. Re-check the client's voice state immediately after GenerateToken and withhold the credential if the join was superseded, with a best-effort RemoveParticipant to match every other eviction path. * fix(ws): 2 defect(s) (OC-0017, OC-0022) OC-0017: sweepStaleVoiceStates re-checks the live client immediately before deleting a snapshotted-stale voice_states row. voice_join commits the row before calling c.setVoiceState, so a join that lands inside that window was snapshotted as a ghost and had its just-committed row deleted, leaving the client in voice in memory with no DB row. OC-0022: CleanupVoiceForChannel resolves its voice_leave audience with a variant of channelReadAudience that skips the archived short-circuit. Both production callers archive the channel before evicting, so the plain resolver always returned an empty audience and only the evicted participants learned the call ended. * fix(voice): 1 defect(s) (OC-0023) Camera and screenshare now draw from the same per-channel voice_max_video budget. handleVoiceScreenshareV2 performed no cap check at all, and the camera gate's slot-count subquery counted only `camera = 1` rows, so a screensharing occupant was invisible to it. Both gates now count `camera = 1 OR screenshare = 1` via a shared enableVideoSlot helper. * fix(client): 2 defect(s) (OC-0032, OC-0033) OC-0033: voice_disconnected staleness guard swallowed the kick toast when the sibling voice_leave had already cleared currentChannelId. Treat a cleared store as not-stale. OC-0032: VIDEO_LIMIT rollback assumed the camera, tearing down a working camera and leaving refused screen tracks published. Correlate by envelope id and roll back the kind that was actually refused. * fix(voice): 1 defect(s) (OC-0034) * fix(client): 1 defect(s) (OC-0035) A superseded video-enable id makes rollbackPendingVideo return undefined. The dispatcher's ternary treated undefined as "not screen" and called disableCamera(), tearing down a working camera the user never touched. Return early instead: undefined means there is nothing to roll back. * fix(voice): 1 defect(s) (OC-0036) --------- Co-authored-by: Claude <noreply@anthropic.com>
1174 lines
35 KiB
TypeScript
1174 lines
35 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 } from "../../src/lib/ws";
|
|
import { addLogListener, type LogEntry } from "../../src/lib/logger";
|
|
|
|
describe("WebSocket Client (Tauri proxy)", () => {
|
|
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("starts in disconnected state", () => {
|
|
expect(client.getState()).toBe("disconnected");
|
|
});
|
|
|
|
it("transitions to connecting on connect", async () => {
|
|
const states: ConnectionState[] = [];
|
|
client.onStateChange((s) => states.push(s));
|
|
client.connect({ host: "localhost:8443", token: "test-token" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
expect(states).toContain("connecting");
|
|
});
|
|
|
|
it("calls ws_connect with correct URL", async () => {
|
|
client.connect({ host: "localhost:8443", token: "test-token" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
expect(mockInvoke).toHaveBeenCalledWith("ws_connect", {
|
|
url: "wss://localhost:8443/api/v1/ws",
|
|
});
|
|
});
|
|
|
|
it("sends auth message when Rust reports open", async () => {
|
|
client.connect({ host: "localhost:8443", token: "test-token" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
// Simulate Rust reporting connection open
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
// Should call ws_send with auth message
|
|
expect(mockInvoke).toHaveBeenCalledWith(
|
|
"ws_send",
|
|
expect.objectContaining({
|
|
message: expect.stringContaining('"type":"auth"'),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("transitions to connected on auth_ok", async () => {
|
|
client.connect({ host: "localhost:8443", token: "test-token" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const states: ConnectionState[] = [];
|
|
client.onStateChange((s) => states.push(s));
|
|
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "auth_ok",
|
|
payload: {
|
|
user: { id: 1, username: "alex", avatar: null, role: "admin" },
|
|
server_name: "Test",
|
|
motd: "Hello",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(states).toContain("connected");
|
|
});
|
|
|
|
it("dispatches messages to typed listeners", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const messages: unknown[] = [];
|
|
client.on("chat_message", (payload) => messages.push(payload));
|
|
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 5,
|
|
user: { id: 1, username: "alex", avatar: null },
|
|
content: "Hello",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-03-14T10:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(messages).toHaveLength(1);
|
|
});
|
|
|
|
it("unsubscribe removes listener", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const messages: unknown[] = [];
|
|
const unsub = client.on("chat_message", (payload) => messages.push(payload));
|
|
unsub();
|
|
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 5,
|
|
user: { id: 1, username: "alex", avatar: null },
|
|
content: "Hello",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-03-14T10:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(messages).toHaveLength(0);
|
|
});
|
|
|
|
it("auth_error does NOT trigger reconnect", async () => {
|
|
client.connect({ host: "localhost:8443", token: "bad-token" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const authErrors: unknown[] = [];
|
|
client.on("auth_error", (payload) => authErrors.push(payload));
|
|
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "auth_error",
|
|
payload: { message: "Invalid token" },
|
|
}),
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
|
|
expect(authErrors).toHaveLength(1);
|
|
expect(client.getState()).toBe("disconnected");
|
|
});
|
|
|
|
it("reconnects on unexpected close with backoff", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const states: ConnectionState[] = [];
|
|
client.onStateChange((s) => states.push(s));
|
|
|
|
// Simulate connection closed by Rust proxy
|
|
emitTauriEvent("ws-state", "closed");
|
|
|
|
expect(states).toContain("reconnecting");
|
|
|
|
// After 1s backoff, should call ws_connect again
|
|
mockInvoke.mockClear();
|
|
await vi.advanceTimersByTimeAsync(1100);
|
|
expect(mockInvoke).toHaveBeenCalledWith("ws_connect", expect.anything());
|
|
});
|
|
|
|
it("send returns correlation ID", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const id = client.send({
|
|
type: "chat_send",
|
|
payload: { channel_id: 1, content: "hi", reply_to: null, attachments: [] },
|
|
});
|
|
|
|
expect(id).toBe("test-uuid-1234");
|
|
});
|
|
|
|
it("drops oversized messages", async () => {
|
|
client.connect({
|
|
host: "localhost:8443",
|
|
token: "t",
|
|
maxMessageSizeBytes: 50,
|
|
});
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const messages: unknown[] = [];
|
|
client.on("chat_message", (p) => messages.push(p));
|
|
|
|
const bigData = JSON.stringify({
|
|
type: "chat_message",
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "x".repeat(100),
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
});
|
|
|
|
emitTauriEvent("ws-message", bigData);
|
|
expect(messages).toHaveLength(0);
|
|
});
|
|
|
|
it("drops malformed JSON", 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", "not-json{{{");
|
|
expect(messages).toHaveLength(0);
|
|
});
|
|
|
|
it("does not log raw frame content on parse failure (no plaintext leak)", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const entries: LogEntry[] = [];
|
|
const remove = addLogListener((e) => entries.push(e));
|
|
const secret = "SUPER_SECRET_eyJhbGciOiJIUzI1NiJ9";
|
|
emitTauriEvent("ws-message", secret + " not-json{{{");
|
|
remove();
|
|
|
|
// The decrypted frame must never reach the (on-disk-persisted) log...
|
|
expect(JSON.stringify(entries)).not.toContain(secret);
|
|
// ...but the parse failure is still recorded so it stays debuggable.
|
|
expect(entries.some((e) => e.message.includes("Failed to parse WS message"))).toBe(true);
|
|
});
|
|
|
|
it("disconnect prevents reconnect", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
client.disconnect();
|
|
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
expect(client.getState()).toBe("disconnected");
|
|
});
|
|
});
|
|
|
|
describe("heartbeat", () => {
|
|
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("sends heartbeat ping every 30 seconds after auth_ok", 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.mockClear();
|
|
|
|
// Advance 30 seconds — should send a ping
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
|
|
const pingSends = mockInvoke.mock.calls.filter(
|
|
(c) =>
|
|
c[0] === "ws_send" &&
|
|
typeof c[1]?.message === "string" &&
|
|
(c[1].message as string).includes('"type":"ping"'),
|
|
);
|
|
expect(pingSends.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it("stops heartbeat on disconnect", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
client.disconnect();
|
|
mockInvoke.mockClear();
|
|
|
|
// No heartbeat should be sent after disconnect
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
|
|
const pingSends = mockInvoke.mock.calls.filter(
|
|
(c) =>
|
|
c[0] === "ws_send" &&
|
|
typeof c[1]?.message === "string" &&
|
|
(c[1].message as string).includes('"type":"ping"'),
|
|
);
|
|
expect(pingSends).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("setState deduplication", () => {
|
|
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 notify listeners when state is already the same", async () => {
|
|
const states: ConnectionState[] = [];
|
|
client.onStateChange((s) => states.push(s));
|
|
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
// State is now "connecting". Count how many times "connecting" appeared.
|
|
const connectingCount = states.filter((s) => s === "connecting").length;
|
|
expect(connectingCount).toBe(1);
|
|
});
|
|
|
|
it("notifies listeners when state actually changes", async () => {
|
|
const states: ConnectionState[] = [];
|
|
client.onStateChange((s) => states.push(s));
|
|
|
|
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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Should have transitioned: connecting -> authenticating -> connected
|
|
expect(states).toContain("connecting");
|
|
expect(states).toContain("authenticating");
|
|
expect(states).toContain("connected");
|
|
});
|
|
});
|
|
|
|
describe("getReconnectDelay boundary and arithmetic", () => {
|
|
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("first reconnect delay is 1000ms (1000 * 2^0)", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
emitTauriEvent("ws-state", "closed");
|
|
mockInvoke.mockClear();
|
|
|
|
// At 999ms, should NOT have reconnected yet
|
|
await vi.advanceTimersByTimeAsync(999);
|
|
const callsBefore = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect");
|
|
expect(callsBefore).toHaveLength(0);
|
|
|
|
// At 1000ms total, should reconnect
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
const callsAfter = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect");
|
|
expect(callsAfter).toHaveLength(1);
|
|
});
|
|
|
|
it("second reconnect delay is 2000ms (1000 * 2^1)", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// First drop + reconnect
|
|
emitTauriEvent("ws-state", "closed");
|
|
await vi.advanceTimersByTimeAsync(1100);
|
|
// Don't send auth_ok, so reconnectAttempt stays incremented
|
|
// Simulate another close immediately
|
|
emitTauriEvent("ws-state", "closed");
|
|
|
|
mockInvoke.mockClear();
|
|
|
|
// Second attempt should have 2000ms delay
|
|
await vi.advanceTimersByTimeAsync(1999);
|
|
const callsBefore = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect");
|
|
expect(callsBefore).toHaveLength(0);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
const callsAfter = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect");
|
|
expect(callsAfter).toHaveLength(1);
|
|
});
|
|
|
|
it("delay uses default 30000ms cap when maxReconnectDelayMs not set", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Simulate many drops to ramp up backoff
|
|
for (let i = 0; i < 10; i++) {
|
|
emitTauriEvent("ws-state", "closed");
|
|
await vi.advanceTimersByTimeAsync(31_000);
|
|
}
|
|
|
|
// After 10 attempts, uncapped delay would be 1000*2^10 = 1024000ms
|
|
// But it should be capped at 30000ms (default)
|
|
mockInvoke.mockClear();
|
|
emitTauriEvent("ws-state", "closed");
|
|
|
|
// Should reconnect within 30s (capped), not 1024s
|
|
await vi.advanceTimersByTimeAsync(30_001);
|
|
const calls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect");
|
|
expect(calls.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
describe("wsGeneration stale listener guard", () => {
|
|
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("ignores events from stale generation after new connect()", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
// Capture the handlers registered in the first connect
|
|
const oldMsgHandlers = [...(eventHandlers.get("ws-message") ?? [])];
|
|
const oldStateHandlers = [...(eventHandlers.get("ws-state") ?? [])];
|
|
|
|
// Start a new connection (increments wsGeneration, cleans up old handlers)
|
|
client.connect({ host: "localhost:8443", token: "t2" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
const states: ConnectionState[] = [];
|
|
client.onStateChange((s) => states.push(s));
|
|
|
|
// If any old handlers survived cleanup, calling them should be a no-op
|
|
// because gen !== wsGeneration
|
|
for (const h of oldMsgHandlers) {
|
|
h({
|
|
payload: JSON.stringify({
|
|
type: "auth_ok",
|
|
payload: {
|
|
user: { id: 1, username: "a", avatar: null, role: "admin" },
|
|
server_name: "S",
|
|
motd: "",
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
|
|
for (const h of oldStateHandlers) {
|
|
h({ payload: "open" });
|
|
}
|
|
|
|
// State should NOT have changed to connected from stale handlers
|
|
expect(states).not.toContain("connected");
|
|
});
|
|
});
|
|
|
|
describe("connect() catch guards against a superseded ws_connect rejection", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
// OC-0026: connect()'s two earlier await points (ensureTauriApis,
|
|
// setupEventListeners) both guard with `if (gen !== wsGeneration) return;`
|
|
// before acting on their resumption, but the `await tauriInvoke("ws_connect")`
|
|
// catch did not. The Rust proxy deliberately rejects a superseded handshake
|
|
// with "superseded by a newer connection" (ws_proxy.rs install_sender), so a
|
|
// stale attempt A's rejection — arriving after a newer attempt B already
|
|
// reached "connected" — must not tear B's live connection back down.
|
|
it("does not flip a live connection back to reconnecting when a stale attempt's ws_connect rejects with 'superseded'", async () => {
|
|
let rejectStaleWsConnect: ((err: Error) => void) | null = null;
|
|
let staleCallSeen = false;
|
|
|
|
mockInvoke.mockImplementation(async (cmd: string) => {
|
|
if (cmd === "ws_connect" && !staleCallSeen) {
|
|
staleCallSeen = true;
|
|
return new Promise((_resolve, reject) => {
|
|
rejectStaleWsConnect = reject;
|
|
});
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
// Attempt A: connect() suspends on the Rust handshake (ws_connect pends
|
|
// up to CONNECT_TIMEOUT in the real proxy).
|
|
client.connect({ host: "localhost:8443", token: "tA" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
expect(rejectStaleWsConnect).not.toBeNull();
|
|
|
|
// Attempt B supersedes A before A's handshake settles — e.g. a
|
|
// cert-accept retry, or logout immediately followed by re-login.
|
|
client.connect({ host: "localhost:8443", token: "tB" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
// B completes its handshake and reaches "connected".
|
|
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: "",
|
|
},
|
|
}),
|
|
);
|
|
expect(client.getState()).toBe("connected");
|
|
|
|
// A's stale ws_connect now rejects with the real error the Rust proxy
|
|
// sends for a superseded handshake.
|
|
rejectStaleWsConnect!(new Error("superseded by a newer connection"));
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
// The live connection must not be knocked back into "reconnecting" by
|
|
// the superseded attempt's rejection.
|
|
expect(client.getState()).toBe("connected");
|
|
|
|
// No reconnect timer should have been armed by the stale catch —
|
|
// advancing well past any backoff must not trigger a redial of the
|
|
// still-healthy connection.
|
|
mockInvoke.mockClear();
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
const reconnectCalls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect");
|
|
expect(reconnectCalls).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("disconnect() cancelling an in-flight connect()", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
let originalMockListenImpl: (typeof mockListen)["getMockImplementation"] extends () => infer R
|
|
? R
|
|
: never;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
originalMockListenImpl = mockListen.getMockImplementation()!;
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
mockListen.mockImplementation(originalMockListenImpl!);
|
|
client.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
// Mirrors main.ts's onAutoLoginCancel: by the time the Cancel button is
|
|
// clickable, wirePostAuth has already called ws.connect() and connect()
|
|
// is suspended mid-await (setupEventListeners' tauriListen round trips).
|
|
// disconnect() runs synchronously while that await is pending, then the
|
|
// suspended connect() resumes.
|
|
it("prevents ws_connect from being invoked after disconnect() runs mid-connect()", async () => {
|
|
let releaseListen: (() => void) | null = null;
|
|
mockListen.mockImplementation(
|
|
async (event: string, handler: (e: { payload: unknown }) => void) => {
|
|
if (event === "ws-message" && releaseListen === null) {
|
|
// Pause connect() here, mimicking the Cancel click landing while
|
|
// connect() is still awaiting its Tauri IPC round trips.
|
|
await new Promise<void>((resolve) => {
|
|
releaseListen = resolve;
|
|
});
|
|
}
|
|
return originalMockListenImpl!(event, handler);
|
|
},
|
|
);
|
|
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
// Let connect() run past ensureTauriApis()/cleanupEventListeners() and
|
|
// into the paused first tauriListen("ws-message", ...) call.
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
expect(releaseListen).not.toBeNull();
|
|
|
|
// Cancel arrives while connect() is suspended mid-await.
|
|
client.disconnect();
|
|
expect(client.getState()).toBe("disconnected");
|
|
|
|
// Resume the suspended connect() — it must notice the cancellation and
|
|
// bail out instead of completing setupEventListeners() and invoking
|
|
// ws_connect.
|
|
releaseListen!();
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
const wsConnectCalls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect");
|
|
expect(wsConnectCalls).toHaveLength(0);
|
|
// The cancelled attempt must not have flipped the state back out of
|
|
// "disconnected" (e.g. to "authenticating"/"reconnecting").
|
|
expect(client.getState()).toBe("disconnected");
|
|
});
|
|
});
|
|
|
|
describe("heartbeat proxyOpen guard", () => {
|
|
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 send ping when proxyOpen is false (connection dropped mid-heartbeat)", 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: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Heartbeat started. Now close the proxy (sets proxyOpen=false)
|
|
emitTauriEvent("ws-state", "closed");
|
|
|
|
// Clear mocks and advance past heartbeat interval
|
|
mockInvoke.mockClear();
|
|
|
|
// The heartbeat was stopped by close handler, so no pings should fire
|
|
await vi.advanceTimersByTimeAsync(35_000);
|
|
|
|
const pings = mockInvoke.mock.calls.filter(
|
|
(c) =>
|
|
c[0] === "ws_send" &&
|
|
typeof c[1]?.message === "string" &&
|
|
(c[1].message as string).includes('"type":"ping"'),
|
|
);
|
|
expect(pings).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("connect when Tauri APIs unavailable", () => {
|
|
it("falls back to disconnected when ensureTauriApis fails", async () => {
|
|
vi.useFakeTimers();
|
|
|
|
// Create a fresh client that will try to load Tauri APIs fresh
|
|
// The mock is already set up to resolve, so we need to simulate unavailability
|
|
// by making tauriInvoke null after ensureTauriApis
|
|
const origInvoke = mockInvoke;
|
|
|
|
// Temporarily clear the mock module to simulate Tauri not available
|
|
// We test this indirectly: if ws_connect is never called but state
|
|
// goes back to disconnected, the guard worked
|
|
const client2 = createWsClient();
|
|
const states: ConnectionState[] = [];
|
|
client2.onStateChange((s) => states.push(s));
|
|
|
|
client2.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
// With the mock in place, it should proceed normally
|
|
expect(states).toContain("connecting");
|
|
|
|
client2.disconnect();
|
|
vi.useRealTimers();
|
|
});
|
|
});
|
|
|
|
describe("cleanupEventListeners edge cases", () => {
|
|
let client: ReturnType<typeof createWsClient>;
|
|
// Save original mockListen implementation to restore after override tests
|
|
let originalMockListenImpl: (typeof mockListen)["getMockImplementation"] extends () => infer R
|
|
? R
|
|
: never;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockInvoke.mockReset();
|
|
mockInvoke.mockResolvedValue(undefined);
|
|
originalMockListenImpl = mockListen.getMockImplementation()!;
|
|
mockListen.mockClear();
|
|
eventHandlers.clear();
|
|
client = createWsClient();
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.disconnect();
|
|
// Restore the original mockListen implementation so later tests work
|
|
mockListen.mockImplementation(originalMockListenImpl!);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("handles unsub functions that return rejected promises", async () => {
|
|
// Override mockListen to return an unsub that returns a rejected promise
|
|
mockListen.mockImplementation(
|
|
async (_event: string, _handler: (e: { payload: unknown }) => void) => {
|
|
return () => {
|
|
return Promise.reject(new Error("resource invalidated"));
|
|
};
|
|
},
|
|
);
|
|
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
// Disconnect triggers cleanupEventListeners — should not crash
|
|
client.disconnect();
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
expect(client.getState()).toBe("disconnected");
|
|
});
|
|
|
|
it("handles unsub functions that throw synchronously", async () => {
|
|
mockListen.mockImplementation(
|
|
async (_event: string, _handler: (e: { payload: unknown }) => void) => {
|
|
return () => {
|
|
throw new Error("sync unsub error");
|
|
};
|
|
},
|
|
);
|
|
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
// Should not crash
|
|
client.disconnect();
|
|
expect(client.getState()).toBe("disconnected");
|
|
});
|
|
});
|
|
|
|
describe("dedup does not filter auth_ok, auth_error, or ready during replay", () => {
|
|
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("ready message is not deduped during replay", 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: 5,
|
|
payload: {
|
|
user: { id: 1, username: "a", avatar: null, role: "admin" },
|
|
server_name: "S",
|
|
motd: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
seq: 10,
|
|
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",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Disconnect and reconnect
|
|
emitTauriEvent("ws-state", "closed");
|
|
await vi.advanceTimersByTimeAsync(1100);
|
|
emitTauriEvent("ws-state", "open");
|
|
expect(client.isReplaying()).toBe(true);
|
|
|
|
const readyPayloads: unknown[] = [];
|
|
client.on("ready", (p) => readyPayloads.push(p));
|
|
|
|
// Send ready during replay BEFORE auth_ok — should NOT be deduped
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "ready",
|
|
seq: 11,
|
|
payload: {
|
|
channels: [],
|
|
members: [],
|
|
voice_states: [],
|
|
roles: [],
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(readyPayloads).toHaveLength(1);
|
|
|
|
// Send ready again with same seq — ready is exempt from dedup, so it passes
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "ready",
|
|
seq: 11,
|
|
payload: {
|
|
channels: [],
|
|
members: [],
|
|
voice_states: [],
|
|
roles: [],
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(readyPayloads).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Listener registry mechanics (no Tauri connection needed)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("listener registry mechanics (on/off/dispatch)", () => {
|
|
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("on() registers a listener and returns an unsubscribe function", () => {
|
|
const listener = vi.fn();
|
|
const unsub = client.on("chat_message", listener);
|
|
expect(typeof unsub).toBe("function");
|
|
});
|
|
|
|
it("off via returned unsubscribe removes a specific listener", async () => {
|
|
// Connect so we can dispatch messages through the proxy
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const calls: string[] = [];
|
|
const listenerA = () => calls.push("A");
|
|
const listenerB = () => calls.push("B");
|
|
|
|
client.on("chat_message", listenerA);
|
|
const unsubB = client.on("chat_message", listenerB);
|
|
|
|
// Remove only B
|
|
unsubB();
|
|
|
|
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",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(calls).toEqual(["A"]);
|
|
});
|
|
|
|
it("multiple listeners on the same event type all get called", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const calls: string[] = [];
|
|
client.on("chat_message", () => calls.push("first"));
|
|
client.on("chat_message", () => calls.push("second"));
|
|
client.on("chat_message", () => calls.push("third"));
|
|
|
|
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",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(calls).toEqual(["first", "second", "third"]);
|
|
});
|
|
|
|
it("listener removal mid-dispatch does not crash", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const calls: string[] = [];
|
|
let unsubSelf: (() => void) | null = null;
|
|
|
|
// This listener unsubscribes itself when called
|
|
unsubSelf = client.on("chat_message", () => {
|
|
calls.push("self-removing");
|
|
unsubSelf!();
|
|
});
|
|
|
|
// Second listener should still be called
|
|
client.on("chat_message", () => calls.push("survivor"));
|
|
|
|
const msgJson = 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",
|
|
},
|
|
});
|
|
|
|
// First dispatch — self-removing listener fires then removes itself
|
|
emitTauriEvent("ws-message", msgJson);
|
|
expect(calls).toContain("self-removing");
|
|
expect(calls).toContain("survivor");
|
|
|
|
// Second dispatch — only survivor should fire
|
|
calls.length = 0;
|
|
emitTauriEvent("ws-message", msgJson);
|
|
expect(calls).toEqual(["survivor"]);
|
|
});
|
|
|
|
it("unknown event type dispatch does not throw", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
// Dispatch a completely unknown event type — should not crash
|
|
expect(() => {
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "totally_unknown_event",
|
|
payload: { foo: "bar" },
|
|
}),
|
|
);
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it("error boundary: throwing listener does not prevent next listener from running", async () => {
|
|
client.connect({ host: "localhost:8443", token: "t" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
emitTauriEvent("ws-state", "open");
|
|
|
|
const received: string[] = [];
|
|
|
|
client.on("chat_message", () => {
|
|
throw new Error("first listener explodes");
|
|
});
|
|
client.on("chat_message", (payload) => {
|
|
received.push((payload as { content: string }).content);
|
|
});
|
|
client.on("chat_message", () => {
|
|
throw new Error("third listener also explodes");
|
|
});
|
|
client.on("chat_message", (payload) => {
|
|
received.push("fourth:" + (payload as { content: string }).content);
|
|
});
|
|
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "chat_message",
|
|
payload: {
|
|
id: 1,
|
|
channel_id: 1,
|
|
user: { id: 1, username: "a", avatar: null },
|
|
content: "hello",
|
|
reply_to: null,
|
|
attachments: [],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Both non-throwing listeners should have received the message
|
|
expect(received).toEqual(["hello", "fourth:hello"]);
|
|
});
|
|
});
|