mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(client): 1 defect(s) (OC-0201)
* fix(service): 1 defect(s) (OC-0202)
HandleTyping built the per-user-per-channel rate-limit key before resolving the channel or checking read permission, so forged channel ids could pin unbounded dead entries in the shared process-wide RateLimiter.
* fix(client): 2 defect(s) (OC-0203, OC-0224)
* fix(server): 1 defect(s) (OC-0204)
* fix(ws): 2 defect(s) (OC-0205, OC-0211)
* fix(admin): 2 defect(s) (OC-0209, OC-0212)
* fix(client): 1 defect(s) (OC-0210)
* fix(db): 1 defect(s) (OC-0213)
* fix(ws): 1 defect(s) (OC-0214)
Route handler-driven PresenceEvent through BroadcastToAll instead of BroadcastToAllLow so every source of a user's presence shares one ordered per-client FIFO.
* fix(admin): 1 defect(s) (OC-0215)
PATCH /users/{id} combining banned + role_id committed and broadcast the ban before authorizing the role change, so a refused role change returned an error while leaving the target banned. Authorize the role change up front via the new ModerationService.AuthorizeRoleChange.
* fix(db): 1 defect(s) (OC-0216)
LinkAttachmentsToMessage no longer claims an attachment that is a user's live avatar (users.avatar points at it). Once message_id is set, handleServeFile's avatar branch (gated on ChannelID == nil) is unreachable and the file falls under the message's channel ACL / soft-delete state, permanently disagreeing with users.avatar about who may read it.
* fix(emoji): 1 defect(s) (OC-0217)
* fix(client): 1 defect(s) (OC-0218)
The data-copy phase of an HTTP proxy tunnel was unbounded. Steps 1-2 of
handle_connection (header read, TCP connect, TLS handshake) each run under
a 10s guard, but step 3 called io::copy_bidirectional with no deadline. A
remote that completes the TLS handshake and then neither responds nor
closes parks the spawned connection task, the loopback socket and the
remote TLS session indefinitely: copy_bidirectional only resolves once
BOTH directions finish, so closing the local side alone does not free it.
Wrap the copy in copy_with_deadline, a generic helper bounded by
DATA_PHASE_TIMEOUT (600s). The bound is deliberately far looser than the
10s setup guards because this phase carries the REST body, including
attachment and avatar uploads, so it must reclaim only genuinely stuck
connections rather than merely slow ones. The helper is generic over the
stream types so it can be exercised without a live TLS connection.
Regression test drives two in-memory duplex pairs whose far ends stay
alive, so neither half ever observes EOF and raw copy_bidirectional would
block forever; the test asserts the call resolves on its own deadline with
ErrorKind::TimedOut.
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL
* fix(ws): 1 defect(s) (OC-0219)
* fix(client): 1 defect(s) (OC-0221)
UpdateNotifier scheduled its deferred update check with a setTimeout whose
handle was never retained, so destroy() could not cancel it. A component torn
down inside the 3s window (page swap / logout) still fired performCheck() and
issued a network update check against the old server URL. Retain the timer
handle and clear it in destroy().
* fix(dm): 1 defect(s) (OC-0222)
* fix(client): 1 defect(s) (OC-0223)
* fix(voice): 1 defect(s) (OC-0225)
The Grant-Microphone retry's .finally hardcoded grantMicBtn.disabled = false, undoing updateFrozen()'s socket-down freeze when the WS socket dropped while the mic permission request was in flight. Delegate the state back to render().
* fix(admin): 1 defect(s) (OC-0226)
handleApplyUpdate broadcasts a 'restarting in 5s' notice before the on-disk
swap. Every failure path in the swap returned silently, leaving clients
counting down to a restart that never happened. Extract the swap into
applyStagedUpdate and send a corrective 'update_aborted' broadcast from a
deferred guard on every path that does not reach the respawn.
* fix(admin): 1 defect(s) (OC-0227)
PATCH /channels/{id} accepted a blank or whitespace-only name, leaving the
channel unidentifiable in clients. updateChannelRequest.validate() now
rejects it the way handleCreateChannel already did.
* fix(identity): 1 defect(s) (OC-0228)
* fix(admin): run deferred cleanup before the update restart exits
The fix batch left three golangci-lint findings and two prettier findings
that CI gates on.
applyStagedUpdate called os.Exit(0) in the same function that defers both
staged.Close() and the corrective "update_aborted" broadcast, so neither
ran (gocritic exitAfterDefer). Return a bool instead and let the caller
exit once those defers have run — on Windows, releasing the staged binary's
file handle is the reason the restart exists at all, so this is a real fix
rather than a lint appeasement. The exported test hook calls the function as
a statement, so the added result does not affect it.
Also modernize a bulk-insert loop to range-over-int, compare backup bytes
with bytes.Equal, and reflow two test files to prettier's output.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL
* test(ws): pin the live presence path against the invisible custom-status leak
OC-0207 and OC-0211 are the same defect at two emitters: hub_broadcast.go's
BroadcastPresence (connect/reconnect) and event.go's presenceEvents (live
presence_update). The fix for OC-0211 closed both sites in one change, but
only the hub_broadcast side got a regression test.
This pins the event.go sibling: an invisible user's real custom status must
be blanked on the PresenceOthersEvent frame while the owner's own
PresenceSelfEvent still carries it. Without it, a later change could reopen
the live path while the committed test kept passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL
* fix(ws): 1 defect(s) (OC-0206)
* test(ws): silence a contextcheck false positive in the reconnect race test
RefreshChannelVisibility takes no context by design — it is reached through
the admin HubBroadcaster interface, which carries none, so it builds its own
internally. contextcheck flags the call only because the test closure around
it holds a ctx for its override write, so there is nothing to propagate.
Suppress at the call site rather than widen a production interface (and its
mocks) to satisfy a lint in a test.
golangci-lint v2.11.3 (the version ci.yml pins) now reports 0 issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENMDTh8gDLiHCaRFdMYRiL
---------
Co-authored-by: Claude <noreply@anthropic.com>
1271 lines
38 KiB
TypeScript
1271 lines
38 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("overlapping connect() attempts (OC-0219)", () => {
|
|
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();
|
|
});
|
|
|
|
// OC-0219: eventUnsubs is a single client-scoped array shared by every
|
|
// connect() attempt. If a stale attempt A resumes inside setupEventListeners()
|
|
// after a newer attempt B has already registered its own listeners into that
|
|
// same shared array, A's stale-branch cleanup must tear down only the
|
|
// listeners A itself just registered — not B's. Otherwise B's connection
|
|
// opens with nobody listening: no auth frame is ever sent, ws-state "closed"
|
|
// is never observed either, and the socket wedges with no reconnect.
|
|
it("does not tear down a newer connect()'s listeners when a stale attempt's setupEventListeners resumes later", async () => {
|
|
let releaseFirstMsgListen: (() => void) | null = null;
|
|
let firstMsgListenSeen = false;
|
|
|
|
mockListen.mockImplementation(
|
|
async (event: string, handler: (e: { payload: unknown }) => void) => {
|
|
if (event === "ws-message" && !firstMsgListenSeen) {
|
|
firstMsgListenSeen = true;
|
|
// Pause attempt A here — mirrors A being suspended inside
|
|
// setupEventListeners()'s Tauri IPC round trips while a newer
|
|
// connect() attempt B runs all the way to completion.
|
|
await new Promise<void>((resolve) => {
|
|
releaseFirstMsgListen = resolve;
|
|
});
|
|
}
|
|
return originalMockListenImpl!(event, handler);
|
|
},
|
|
);
|
|
|
|
// Attempt A: suspends inside its first tauriListen("ws-message", ...) call.
|
|
client.connect({ host: "localhost:8443", token: "tA" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
expect(releaseFirstMsgListen).not.toBeNull();
|
|
|
|
// Attempt B supersedes A (e.g. a reconnect timer firing alongside a
|
|
// fresh connect()) and runs to completion — registers its own listeners
|
|
// and calls ws_connect — while A is still suspended.
|
|
client.connect({ host: "localhost:8443", token: "tB" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
// Resume A. It notices it is stale (gen mismatch) and tears down
|
|
// listeners — this must remove only the listeners it just registered,
|
|
// not B's live ones.
|
|
releaseFirstMsgListen!();
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
// B's underlying (mock) connection now reports open. If A's stale
|
|
// cleanup wiped B's ws-state listener, nothing observes this and the
|
|
// auth frame is never sent.
|
|
emitTauriEvent("ws-state", "open");
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
const authSends = mockInvoke.mock.calls.filter(
|
|
(c) =>
|
|
c[0] === "ws_send" &&
|
|
typeof c[1]?.message === "string" &&
|
|
(c[1].message as string).includes('"type":"auth"'),
|
|
);
|
|
expect(authSends.length).toBeGreaterThanOrEqual(1);
|
|
|
|
// Complete the handshake and confirm B reaches "connected" — proof its
|
|
// ws-message listener also survived A's stale cleanup.
|
|
emitTauriEvent(
|
|
"ws-message",
|
|
JSON.stringify({
|
|
type: "auth_ok",
|
|
payload: {
|
|
user: { id: 1, username: "b", avatar: null, role: "admin" },
|
|
server_name: "S",
|
|
motd: "",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(client.getState()).toBe("connected");
|
|
});
|
|
});
|
|
|
|
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"]);
|
|
});
|
|
});
|