2026-04-14 19:34:36 -05:00
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
|
|
|
|
|
|
function makeWsImpl() {
|
|
|
|
|
return class MockWebSocket {
|
|
|
|
|
static CONNECTING = 0;
|
|
|
|
|
static OPEN = 1;
|
|
|
|
|
static CLOSING = 2;
|
|
|
|
|
static CLOSED = 3;
|
|
|
|
|
|
|
|
|
|
constructor(url) {
|
|
|
|
|
this.url = url;
|
|
|
|
|
this.readyState = MockWebSocket.CONNECTING;
|
|
|
|
|
this._listeners = { open: [], close: [], error: [], message: [] };
|
|
|
|
|
queueMicrotask(() => {
|
|
|
|
|
if (this.readyState === MockWebSocket.CLOSED) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this.readyState = MockWebSocket.OPEN;
|
|
|
|
|
this._listeners.open.forEach((fn) => fn());
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
addEventListener(type, fn) {
|
|
|
|
|
this._listeners[type]?.push(fn);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
send(data) {
|
|
|
|
|
if (data.includes('"type":"ping"')) {
|
|
|
|
|
queueMicrotask(() => {
|
|
|
|
|
this._listeners.message.forEach((fn) => fn({ data: JSON.stringify({ type: "pong" }) }));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
close(code, reason) {
|
|
|
|
|
if (this.readyState === MockWebSocket.CLOSED) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this.readyState = MockWebSocket.CLOSED;
|
|
|
|
|
queueMicrotask(() => {
|
|
|
|
|
this._listeners.close.forEach((fn) => fn({ code, reason }));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 10:23:08 -05:00
|
|
|
/** A WebSocket mock whose pings are never answered, to exercise pong-timeout handling. */
|
|
|
|
|
function makeSilentWsImpl() {
|
|
|
|
|
const Base = makeWsImpl();
|
|
|
|
|
return class SilentWebSocket extends Base {
|
|
|
|
|
send() {
|
|
|
|
|
// swallow all sends (including pings) - never emit a pong
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Minimal EventTarget-backed window mock so real focus/online/visibilitychange
|
|
|
|
|
* events can be dispatched and routed through addEventListener like a browser. */
|
|
|
|
|
function makeWindowMock(extra = {}) {
|
|
|
|
|
const target = new EventTarget();
|
|
|
|
|
return {
|
|
|
|
|
api: {},
|
|
|
|
|
location: { origin: "http://127.0.0.1:5173" },
|
|
|
|
|
addEventListener: target.addEventListener.bind(target),
|
|
|
|
|
removeEventListener: target.removeEventListener.bind(target),
|
|
|
|
|
dispatchEvent: target.dispatchEvent.bind(target),
|
|
|
|
|
...extra,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 19:34:36 -05:00
|
|
|
describe("WebSocketConnection module", () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.resetModules();
|
|
|
|
|
global.window = {
|
|
|
|
|
api: {},
|
|
|
|
|
location: { origin: "http://127.0.0.1:5173" },
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
vi.useRealTimers();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("emits connected then disconnected on close and reconnects with backoff", async () => {
|
|
|
|
|
const MockWS = makeWsImpl();
|
|
|
|
|
global.WebSocket = MockWS;
|
|
|
|
|
|
|
|
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
|
|
|
|
|
|
|
|
const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
|
|
|
|
|
|
|
|
|
|
const connected = vi.fn();
|
|
|
|
|
const disconnected = vi.fn();
|
|
|
|
|
WebSocketConnection.on("connected", connected);
|
|
|
|
|
WebSocketConnection.on("disconnected", disconnected);
|
|
|
|
|
|
|
|
|
|
await WebSocketConnection.connect();
|
|
|
|
|
|
|
|
|
|
await vi.waitUntil(() => connected.mock.calls.length >= 1);
|
|
|
|
|
expect(connected.mock.calls[0][0]).toEqual({ isReconnect: false });
|
|
|
|
|
|
|
|
|
|
const firstWs = WebSocketConnection.ws;
|
|
|
|
|
firstWs.close(1000, "test");
|
|
|
|
|
|
|
|
|
|
await vi.waitUntil(() => disconnected.mock.calls.length >= 1);
|
|
|
|
|
|
|
|
|
|
const delay = 1000;
|
|
|
|
|
await vi.advanceTimersByTimeAsync(delay + 500);
|
|
|
|
|
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws && WebSocketConnection.ws !== firstWs);
|
|
|
|
|
|
|
|
|
|
await vi.waitUntil(() => connected.mock.calls.length >= 2);
|
|
|
|
|
expect(connected.mock.calls[1][0]).toEqual({ isReconnect: true });
|
|
|
|
|
|
|
|
|
|
WebSocketConnection.destroy();
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-14 17:47:50 -05:00
|
|
|
it("emits ready on the first backend frame, not on TCP open", async () => {
|
|
|
|
|
const MockWS = makeWsImpl();
|
|
|
|
|
global.WebSocket = MockWS;
|
|
|
|
|
|
|
|
|
|
const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
|
|
|
|
|
|
|
|
|
|
const connected = vi.fn();
|
|
|
|
|
const ready = vi.fn();
|
|
|
|
|
WebSocketConnection.on("connected", connected);
|
|
|
|
|
WebSocketConnection.on("ready", ready);
|
|
|
|
|
|
|
|
|
|
await WebSocketConnection.connect();
|
|
|
|
|
await vi.waitUntil(() => connected.mock.calls.length >= 1);
|
|
|
|
|
expect(ready).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
WebSocketConnection.ws.onmessage({ data: JSON.stringify({ type: "pong" }) });
|
|
|
|
|
expect(ready).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
|
|
|
|
WebSocketConnection.ws.onmessage({ data: JSON.stringify({ type: "config", config: {} }) });
|
|
|
|
|
expect(ready).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
|
|
|
|
WebSocketConnection.destroy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not emit ready when the socket opens but never receives a frame", async () => {
|
|
|
|
|
const SilentWS = makeSilentWsImpl();
|
|
|
|
|
global.WebSocket = SilentWS;
|
|
|
|
|
|
|
|
|
|
const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
|
|
|
|
|
|
|
|
|
|
const connected = vi.fn();
|
|
|
|
|
const ready = vi.fn();
|
|
|
|
|
WebSocketConnection.on("connected", connected);
|
|
|
|
|
WebSocketConnection.on("ready", ready);
|
|
|
|
|
|
|
|
|
|
await WebSocketConnection.connect();
|
|
|
|
|
await vi.waitUntil(() => connected.mock.calls.length >= 1);
|
|
|
|
|
await Promise.resolve();
|
|
|
|
|
await Promise.resolve();
|
|
|
|
|
expect(ready).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
WebSocketConnection.destroy();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-14 19:34:36 -05:00
|
|
|
it("strips pong from message stream", async () => {
|
|
|
|
|
const MockWS = makeWsImpl();
|
|
|
|
|
global.WebSocket = MockWS;
|
|
|
|
|
|
|
|
|
|
const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
|
|
|
|
|
|
|
|
|
|
const onMessage = vi.fn();
|
|
|
|
|
WebSocketConnection.on("message", onMessage);
|
|
|
|
|
|
|
|
|
|
await WebSocketConnection.connect();
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
|
|
|
|
|
|
|
|
|
|
const sock = WebSocketConnection.ws;
|
|
|
|
|
sock.onmessage({ data: JSON.stringify({ type: "pong" }) });
|
|
|
|
|
|
|
|
|
|
expect(onMessage).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
sock.onmessage({ data: JSON.stringify({ type: "config", config: {} }) });
|
|
|
|
|
|
|
|
|
|
expect(onMessage).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
|
|
|
|
WebSocketConnection.destroy();
|
|
|
|
|
});
|
2026-05-01 04:10:08 -05:00
|
|
|
|
|
|
|
|
it("forwards invalid JSON frames without throwing", async () => {
|
|
|
|
|
const MockWS = makeWsImpl();
|
|
|
|
|
global.WebSocket = MockWS;
|
|
|
|
|
|
|
|
|
|
const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
|
|
|
|
|
|
|
|
|
|
const onMessage = vi.fn();
|
|
|
|
|
WebSocketConnection.on("message", onMessage);
|
|
|
|
|
|
|
|
|
|
await WebSocketConnection.connect();
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
|
|
|
|
|
|
|
|
|
|
const sock = WebSocketConnection.ws;
|
|
|
|
|
expect(() => sock.onmessage({ data: "<<<not-json>>>" })).not.toThrow();
|
|
|
|
|
expect(onMessage).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
|
|
|
|
WebSocketConnection.destroy();
|
|
|
|
|
});
|
2026-07-08 10:06:14 -05:00
|
|
|
|
|
|
|
|
it("reconnects or pings on foreground or network change", async () => {
|
|
|
|
|
const MockWS = makeWsImpl();
|
|
|
|
|
global.WebSocket = MockWS;
|
|
|
|
|
|
|
|
|
|
const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
|
|
|
|
|
|
|
|
|
|
await WebSocketConnection.connect();
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
|
|
|
|
|
|
|
|
|
|
const firstWs = WebSocketConnection.ws;
|
|
|
|
|
const sendSpy = vi.spyOn(firstWs, "send");
|
|
|
|
|
|
|
|
|
|
// 1. If idleTime is small, it should just send a ping
|
|
|
|
|
WebSocketConnection._lastReceivedTime = Date.now();
|
|
|
|
|
WebSocketConnection.handleForegroundOrNetworkChange();
|
|
|
|
|
expect(sendSpy).toHaveBeenCalled();
|
|
|
|
|
expect(WebSocketConnection.ws).toBe(firstWs);
|
|
|
|
|
|
|
|
|
|
// 2. If idleTime is large, it should force a reconnect
|
2026-07-26 05:44:02 -05:00
|
|
|
WebSocketConnection._lastReceivedTime = Date.now() - 120000;
|
2026-07-08 10:06:14 -05:00
|
|
|
WebSocketConnection.handleForegroundOrNetworkChange();
|
|
|
|
|
|
|
|
|
|
// Wait for the new WebSocket to be created and opened
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws && WebSocketConnection.ws !== firstWs);
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws.readyState === MockWS.OPEN);
|
|
|
|
|
|
|
|
|
|
expect(WebSocketConnection.ws).not.toBe(firstWs);
|
|
|
|
|
|
|
|
|
|
WebSocketConnection.destroy();
|
|
|
|
|
});
|
2026-07-08 10:23:08 -05:00
|
|
|
|
|
|
|
|
it("reconnect() does not thrash a connection attempt that is already in flight", async () => {
|
|
|
|
|
const MockWS = makeWsImpl();
|
|
|
|
|
global.WebSocket = MockWS;
|
|
|
|
|
|
|
|
|
|
const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
|
|
|
|
|
|
|
|
|
|
// connect() has no internal awaits before constructing the socket,
|
|
|
|
|
// so checking readyState right after calling it (without awaiting
|
|
|
|
|
// the returned promise, which only settles after the queued "open"
|
|
|
|
|
// microtask) reliably catches it mid-connect.
|
|
|
|
|
WebSocketConnection.connect();
|
|
|
|
|
const connectingWs = WebSocketConnection.ws;
|
|
|
|
|
expect(connectingWs.readyState).toBe(MockWS.CONNECTING);
|
|
|
|
|
|
|
|
|
|
// calling reconnect() again while the first attempt hasn't resolved
|
|
|
|
|
// yet must not close/replace it.
|
|
|
|
|
WebSocketConnection.reconnect();
|
|
|
|
|
WebSocketConnection.reconnect();
|
|
|
|
|
expect(WebSocketConnection.ws).toBe(connectingWs);
|
|
|
|
|
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws.readyState === MockWS.OPEN);
|
|
|
|
|
expect(WebSocketConnection.ws).toBe(connectingWs);
|
|
|
|
|
|
|
|
|
|
WebSocketConnection.destroy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("ignores repeated foreground/network re-triggers while a reconnect is already in flight", async () => {
|
|
|
|
|
const MockWS = makeWsImpl();
|
|
|
|
|
global.WebSocket = MockWS;
|
|
|
|
|
|
|
|
|
|
const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
|
|
|
|
|
|
|
|
|
|
await WebSocketConnection.connect();
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
|
|
|
|
|
|
|
|
|
|
const firstWs = WebSocketConnection.ws;
|
2026-07-26 05:44:02 -05:00
|
|
|
WebSocketConnection._lastReceivedTime = Date.now() - 120000;
|
2026-07-08 10:23:08 -05:00
|
|
|
WebSocketConnection.handleForegroundOrNetworkChange();
|
|
|
|
|
|
|
|
|
|
const connectingWs = WebSocketConnection.ws;
|
|
|
|
|
expect(connectingWs).not.toBe(firstWs);
|
|
|
|
|
expect(connectingWs.readyState).toBe(MockWS.CONNECTING);
|
|
|
|
|
|
|
|
|
|
// simulate visibilitychange, focus and online all firing again
|
|
|
|
|
// before the new socket has finished opening (very plausible when
|
|
|
|
|
// a mobile browser/webview comes back to the foreground).
|
|
|
|
|
WebSocketConnection.handleForegroundOrNetworkChange();
|
|
|
|
|
WebSocketConnection.handleForegroundOrNetworkChange();
|
|
|
|
|
WebSocketConnection.handleForegroundOrNetworkChange();
|
|
|
|
|
|
|
|
|
|
expect(WebSocketConnection.ws).toBe(connectingWs);
|
|
|
|
|
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws.readyState === MockWS.OPEN);
|
|
|
|
|
expect(WebSocketConnection.ws).toBe(connectingWs);
|
|
|
|
|
|
|
|
|
|
WebSocketConnection.destroy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("cancels a pending backoff retry when a reconnect happens out of turn", async () => {
|
|
|
|
|
const MockWS = makeWsImpl();
|
|
|
|
|
global.WebSocket = MockWS;
|
|
|
|
|
|
|
|
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
|
|
|
|
|
|
|
|
const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
|
|
|
|
|
|
|
|
|
|
await WebSocketConnection.connect();
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
|
|
|
|
|
|
|
|
|
|
const firstWs = WebSocketConnection.ws;
|
|
|
|
|
// a real, unexpected disconnect - schedules a backoff retry
|
|
|
|
|
firstWs.close(1006, "abnormal");
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection._reconnectTimeout !== null);
|
|
|
|
|
|
|
|
|
|
// something else (e.g. a foreground event) triggers an immediate,
|
|
|
|
|
// out-of-turn reconnect before the backoff timer fires
|
|
|
|
|
WebSocketConnection.reconnect();
|
|
|
|
|
const outOfTurnWs = WebSocketConnection.ws;
|
|
|
|
|
expect(outOfTurnWs).not.toBe(firstWs);
|
|
|
|
|
expect(WebSocketConnection._reconnectTimeout).toBeNull();
|
|
|
|
|
|
|
|
|
|
await vi.waitUntil(() => outOfTurnWs.readyState === MockWS.OPEN);
|
|
|
|
|
|
|
|
|
|
// advance well past when the original backoff timer would have
|
|
|
|
|
// fired (base delay ~1s, capped well under this), but short of the
|
|
|
|
|
// next heartbeat/pong cycle so that unrelated timers don't muddy
|
|
|
|
|
// the assertion. It must have been cancelled and not fire a
|
|
|
|
|
// redundant second reconnect that would replace the healthy socket.
|
|
|
|
|
await vi.advanceTimersByTimeAsync(5000);
|
|
|
|
|
|
|
|
|
|
expect(WebSocketConnection.ws).toBe(outOfTurnWs);
|
|
|
|
|
expect(WebSocketConnection.ws.readyState).toBe(MockWS.OPEN);
|
|
|
|
|
|
|
|
|
|
WebSocketConnection.destroy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("forceReconnect performs a silent reconnect without emitting disconnected", async () => {
|
|
|
|
|
const MockWS = makeWsImpl();
|
|
|
|
|
global.WebSocket = MockWS;
|
|
|
|
|
|
|
|
|
|
const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
|
|
|
|
|
|
|
|
|
|
const connected = vi.fn();
|
|
|
|
|
const disconnected = vi.fn();
|
|
|
|
|
WebSocketConnection.on("connected", connected);
|
|
|
|
|
WebSocketConnection.on("disconnected", disconnected);
|
|
|
|
|
|
|
|
|
|
await WebSocketConnection.connect();
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
|
|
|
|
|
|
|
|
|
|
const firstWs = WebSocketConnection.ws;
|
2026-07-26 05:44:02 -05:00
|
|
|
WebSocketConnection._lastReceivedTime = Date.now() - 120000;
|
2026-07-08 10:23:08 -05:00
|
|
|
WebSocketConnection.handleForegroundOrNetworkChange();
|
|
|
|
|
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws && WebSocketConnection.ws !== firstWs);
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws.readyState === MockWS.OPEN);
|
|
|
|
|
|
|
|
|
|
expect(disconnected).not.toHaveBeenCalled();
|
|
|
|
|
expect(connected).toHaveBeenCalledTimes(2);
|
2026-07-24 07:23:10 -05:00
|
|
|
// Background-tab stale recovery must still tell the shell this is a
|
|
|
|
|
// reconnect so CSRF/config/status can resync (without flashing disconnect).
|
|
|
|
|
expect(connected.mock.calls[1][0]).toEqual({ isReconnect: true });
|
|
|
|
|
|
|
|
|
|
WebSocketConnection.destroy();
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-26 05:44:02 -05:00
|
|
|
it("does not emit disconnected before the first successful open", async () => {
|
|
|
|
|
global.WebSocket = class FailingWS {
|
|
|
|
|
static CONNECTING = 0;
|
|
|
|
|
static OPEN = 1;
|
|
|
|
|
static CLOSING = 2;
|
|
|
|
|
static CLOSED = 3;
|
|
|
|
|
|
|
|
|
|
constructor(url) {
|
|
|
|
|
this.url = url;
|
|
|
|
|
this.readyState = FailingWS.CONNECTING;
|
|
|
|
|
this._listeners = { open: [], close: [], error: [], message: [] };
|
|
|
|
|
queueMicrotask(() => {
|
|
|
|
|
this.readyState = FailingWS.CLOSED;
|
|
|
|
|
this._listeners.close.forEach((fn) => fn({ code: 1006, reason: "startup" }));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
addEventListener(type, fn) {
|
|
|
|
|
this._listeners[type]?.push(fn);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
send() {}
|
|
|
|
|
|
|
|
|
|
close() {
|
|
|
|
|
if (this.readyState === FailingWS.CLOSED) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this.readyState = FailingWS.CLOSED;
|
|
|
|
|
queueMicrotask(() => {
|
|
|
|
|
this._listeners.close.forEach((fn) => fn({}));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
|
|
|
|
|
|
|
|
const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
|
|
|
|
|
|
|
|
|
|
const connected = vi.fn();
|
|
|
|
|
const disconnected = vi.fn();
|
|
|
|
|
WebSocketConnection.on("connected", connected);
|
|
|
|
|
WebSocketConnection.on("disconnected", disconnected);
|
|
|
|
|
|
|
|
|
|
WebSocketConnection.connect();
|
|
|
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
|
|
|
expect(disconnected).not.toHaveBeenCalled();
|
|
|
|
|
expect(connected).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
WebSocketConnection.destroy();
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-24 07:23:10 -05:00
|
|
|
it("marks forced reconnect as isReconnect after a prior successful open (background-tab stall)", async () => {
|
|
|
|
|
const MockWS = makeWsImpl();
|
|
|
|
|
global.WebSocket = MockWS;
|
|
|
|
|
|
|
|
|
|
const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
|
|
|
|
|
|
|
|
|
|
const connected = vi.fn();
|
|
|
|
|
WebSocketConnection.on("connected", connected);
|
|
|
|
|
|
|
|
|
|
await WebSocketConnection.connect();
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
|
|
|
|
|
expect(connected.mock.calls[0][0]).toEqual({ isReconnect: false });
|
|
|
|
|
|
|
|
|
|
const firstWs = WebSocketConnection.ws;
|
|
|
|
|
// Simulate a zombie OPEN socket after the tab slept: readyState still
|
|
|
|
|
// OPEN, but no frames for longer than the ping interval.
|
2026-07-26 05:44:02 -05:00
|
|
|
WebSocketConnection._lastReceivedTime = Date.now() - 120000;
|
2026-07-24 07:23:10 -05:00
|
|
|
WebSocketConnection.forceReconnect();
|
|
|
|
|
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws && WebSocketConnection.ws !== firstWs);
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws.readyState === MockWS.OPEN);
|
|
|
|
|
|
|
|
|
|
expect(connected.mock.calls[1][0]).toEqual({ isReconnect: true });
|
2026-07-08 10:23:08 -05:00
|
|
|
|
|
|
|
|
WebSocketConnection.destroy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handleForegroundOrNetworkChange and forceReconnect are no-ops before connect() or after destroy()", async () => {
|
|
|
|
|
const MockWS = makeWsImpl();
|
|
|
|
|
global.WebSocket = MockWS;
|
|
|
|
|
|
|
|
|
|
const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
|
|
|
|
|
|
|
|
|
|
// never connected yet
|
|
|
|
|
expect(() => WebSocketConnection.handleForegroundOrNetworkChange()).not.toThrow();
|
|
|
|
|
expect(() => WebSocketConnection.forceReconnect()).not.toThrow();
|
|
|
|
|
expect(WebSocketConnection.ws).toBeNull();
|
|
|
|
|
|
|
|
|
|
await WebSocketConnection.connect();
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
|
|
|
|
|
WebSocketConnection.destroy();
|
|
|
|
|
|
|
|
|
|
// destroyed
|
|
|
|
|
expect(() => WebSocketConnection.handleForegroundOrNetworkChange()).not.toThrow();
|
|
|
|
|
expect(() => WebSocketConnection.forceReconnect()).not.toThrow();
|
|
|
|
|
expect(WebSocketConnection.ws).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("send() and ping() are safe no-ops when there is no open socket", async () => {
|
|
|
|
|
const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
|
|
|
|
|
|
|
|
|
|
expect(WebSocketConnection.ws).toBeNull();
|
2026-07-10 00:57:53 -05:00
|
|
|
expect(WebSocketConnection.send("hello")).toBe(false);
|
2026-07-08 10:23:08 -05:00
|
|
|
expect(() => WebSocketConnection.ping()).not.toThrow();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("registers window event listeners only once across repeated connect() calls", async () => {
|
|
|
|
|
const MockWS = makeWsImpl();
|
|
|
|
|
global.WebSocket = MockWS;
|
|
|
|
|
|
|
|
|
|
const addEventListenerSpy = vi.fn();
|
|
|
|
|
global.window = {
|
|
|
|
|
api: {},
|
|
|
|
|
location: { origin: "http://127.0.0.1:5173" },
|
|
|
|
|
addEventListener: addEventListenerSpy,
|
|
|
|
|
removeEventListener: vi.fn(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
|
|
|
|
|
|
|
|
|
|
await WebSocketConnection.connect();
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
|
|
|
|
|
const countAfterFirst = addEventListenerSpy.mock.calls.length;
|
|
|
|
|
expect(countAfterFirst).toBeGreaterThan(0);
|
|
|
|
|
|
|
|
|
|
WebSocketConnection.destroy();
|
|
|
|
|
await WebSocketConnection.connect();
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
|
|
|
|
|
|
|
|
|
|
expect(addEventListenerSpy.mock.calls.length).toBe(countAfterFirst);
|
|
|
|
|
|
|
|
|
|
WebSocketConnection.destroy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("only reacts to visibilitychange when the document becomes visible", async () => {
|
|
|
|
|
const MockWS = makeWsImpl();
|
|
|
|
|
global.WebSocket = MockWS;
|
|
|
|
|
global.window = makeWindowMock();
|
|
|
|
|
global.document = { visibilityState: "hidden" };
|
|
|
|
|
|
|
|
|
|
const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
|
|
|
|
|
|
|
|
|
|
await WebSocketConnection.connect();
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
|
|
|
|
|
|
|
|
|
|
const firstWs = WebSocketConnection.ws;
|
2026-07-26 05:44:02 -05:00
|
|
|
WebSocketConnection._lastReceivedTime = Date.now() - 120000;
|
2026-07-08 10:23:08 -05:00
|
|
|
|
|
|
|
|
// still hidden - must not trigger a reconnect
|
|
|
|
|
global.window.dispatchEvent(new Event("visibilitychange"));
|
|
|
|
|
expect(WebSocketConnection.ws).toBe(firstWs);
|
|
|
|
|
expect(firstWs.readyState).toBe(MockWS.OPEN);
|
|
|
|
|
|
|
|
|
|
// becoming visible should run the stale-connection check
|
|
|
|
|
global.document.visibilityState = "visible";
|
|
|
|
|
global.window.dispatchEvent(new Event("visibilitychange"));
|
|
|
|
|
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws && WebSocketConnection.ws !== firstWs);
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws.readyState === MockWS.OPEN);
|
|
|
|
|
expect(WebSocketConnection.ws).not.toBe(firstWs);
|
|
|
|
|
|
|
|
|
|
WebSocketConnection.destroy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("reacts to real focus and online DOM events", async () => {
|
|
|
|
|
const MockWS = makeWsImpl();
|
|
|
|
|
global.WebSocket = MockWS;
|
|
|
|
|
global.window = makeWindowMock();
|
|
|
|
|
global.document = { visibilityState: "visible" };
|
|
|
|
|
|
|
|
|
|
const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
|
|
|
|
|
|
|
|
|
|
await WebSocketConnection.connect();
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
|
|
|
|
|
|
|
|
|
|
const firstWs = WebSocketConnection.ws;
|
|
|
|
|
const sendSpy = vi.spyOn(firstWs, "send");
|
|
|
|
|
|
|
|
|
|
WebSocketConnection._lastReceivedTime = Date.now();
|
|
|
|
|
global.window.dispatchEvent(new Event("focus"));
|
|
|
|
|
expect(sendSpy).toHaveBeenCalled();
|
|
|
|
|
expect(WebSocketConnection.ws).toBe(firstWs);
|
|
|
|
|
|
2026-07-26 05:44:02 -05:00
|
|
|
WebSocketConnection._lastReceivedTime = Date.now() - 120000;
|
2026-07-08 10:23:08 -05:00
|
|
|
global.window.dispatchEvent(new Event("online"));
|
|
|
|
|
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws && WebSocketConnection.ws !== firstWs);
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws.readyState === MockWS.OPEN);
|
|
|
|
|
expect(WebSocketConnection.ws).not.toBe(firstWs);
|
|
|
|
|
|
|
|
|
|
WebSocketConnection.destroy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("closes and schedules a reconnect when a heartbeat ping goes unanswered", async () => {
|
|
|
|
|
const SilentWS = makeSilentWsImpl();
|
|
|
|
|
global.WebSocket = SilentWS;
|
|
|
|
|
|
|
|
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
|
|
|
|
|
|
|
|
const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
|
|
|
|
|
|
|
|
|
|
const disconnected = vi.fn();
|
|
|
|
|
WebSocketConnection.on("disconnected", disconnected);
|
|
|
|
|
|
|
|
|
|
await WebSocketConnection.connect();
|
|
|
|
|
await vi.waitUntil(() => WebSocketConnection.ws?.readyState === SilentWS.OPEN);
|
|
|
|
|
|
|
|
|
|
const firstWs = WebSocketConnection.ws;
|
|
|
|
|
|
|
|
|
|
// the very first heartbeat ping is sent on open but never answered;
|
|
|
|
|
// once the pong timeout elapses the socket should be force-closed.
|
|
|
|
|
await vi.advanceTimersByTimeAsync(12000 + 500);
|
|
|
|
|
|
|
|
|
|
await vi.waitUntil(() => disconnected.mock.calls.length >= 1);
|
|
|
|
|
expect(firstWs.readyState).toBe(SilentWS.CLOSED);
|
|
|
|
|
|
|
|
|
|
WebSocketConnection.destroy();
|
|
|
|
|
});
|
2026-04-14 19:34:36 -05:00
|
|
|
});
|