import { test } from "node:test"; import assert from "node:assert/strict"; import { createGateway, OP, CONNECTOR_INTENTS, INTENT } from "../src/gateway.mjs"; import { fakeTimers } from "./helpers.mjs"; // A fake WebSocket: the test is the server. `sockets` records every instance. function fakeSocketClass() { const sockets = []; class FakeWebSocket { constructor(url) { this.url = url; this.readyState = 0; this.sent = []; this.closeCalls = []; sockets.push(this); } send(data) { this.sent.push(JSON.parse(data)); } close(code, reason) { this.closeCalls.push({ code, reason }); this.readyState = 2; // The server-side close event follows. this.readyState = 3; if (this.onclose) this.onclose({ code, reason }); } // test helpers acting as the server open() { this.readyState = 1; if (this.onopen) this.onopen({}); } receive(payload) { this.onmessage({ data: JSON.stringify(payload) }); } serverClose(code, reason = "") { this.readyState = 3; this.onclose({ code, reason }); } sentOps(op) { return this.sent.filter((p) => p.op === op); } } return { FakeWebSocket, sockets }; } function setup({ random = () => 0.5 } = {}) { const t = fakeTimers(); const { FakeWebSocket, sockets } = fakeSocketClass(); const events = []; const gw = createGateway({ url: "wss://gateway.example", token: "T0KEN-not-real-1234567890", WebSocketImpl: FakeWebSocket, setTimeoutImpl: t.setTimeout, clearTimeoutImpl: t.clearTimeout, random, }); for (const name of ["ready", "resumed", "dispatch", "closed", "fatal", "log"]) gw.on(name, (p) => events.push([name, p])); return { gw, t, sockets, events }; } function hello(ws, interval = 1000) { ws.open(); ws.receive({ op: OP.HELLO, d: { heartbeat_interval: interval } }); } test("gateway: hello -> identify with intents, ready, heartbeat with jitter, ack", () => { const { gw, t, sockets, events } = setup(); gw.connect(); const ws = sockets[0]; assert.equal(ws.url, "wss://gateway.example/?v=10&encoding=json"); hello(ws, 1000); const id = ws.sentOps(OP.IDENTIFY); assert.equal(id.length, 1); assert.equal(id[0].d.intents, CONNECTOR_INTENTS); assert.equal(CONNECTOR_INTENTS, INTENT.GUILDS | INTENT.GUILD_MESSAGES | INTENT.MESSAGE_CONTENT); assert.equal(id[0].d.token, "T0KEN-not-real-1234567890"); ws.receive({ op: OP.DISPATCH, t: "READY", s: 1, d: { session_id: "sess1", resume_gateway_url: "wss://resume.example", user: { id: "1" }, guilds: [{ id: "g" }] } }); assert.equal(events.filter((e) => e[0] === "ready").length, 1); assert.equal(gw.sessionId, "sess1"); // First heartbeat after interval * jitter (0.5 -> 500 ms), carrying the sequence. t.advance(499); assert.equal(ws.sentOps(OP.HEARTBEAT).length, 0); t.advance(1); assert.equal(ws.sentOps(OP.HEARTBEAT).length, 1); assert.equal(ws.sentOps(OP.HEARTBEAT)[0].d, 1); ws.receive({ op: OP.HEARTBEAT_ACK }); t.advance(1000); assert.equal(ws.sentOps(OP.HEARTBEAT).length, 2); // Server-requested heartbeat is answered immediately. ws.receive({ op: OP.HEARTBEAT }); assert.equal(ws.sentOps(OP.HEARTBEAT).length, 3); // No token in any emitted event or log. assert.ok(!JSON.stringify(events).includes("T0KEN")); }); test("gateway: missed ack closes the socket and resumes with the last sequence", () => { const { gw, t, sockets, events } = setup(); gw.connect(); const ws = sockets[0]; hello(ws, 1000); ws.receive({ op: OP.DISPATCH, t: "READY", s: 1, d: { session_id: "sess1", resume_gateway_url: "wss://resume.example", user: { id: "1" } } }); ws.receive({ op: OP.DISPATCH, t: "MESSAGE_CREATE", s: 7, d: { id: "x" } }); t.advance(500); // first heartbeat t.advance(1000); // second beat, ack still pending -> close assert.equal(ws.closeCalls.length, 1); const closed = events.find((e) => e[0] === "closed"); assert.equal(closed[1].willReconnect, true); t.advance(2000); // backoff (1000 + 500 jitter) assert.equal(sockets.length, 2); const ws2 = sockets[1]; assert.equal(ws2.url, "wss://resume.example/?v=10&encoding=json"); hello(ws2, 1000); const resume = ws2.sentOps(OP.RESUME); assert.equal(resume.length, 1); assert.deepEqual(resume[0].d, { token: "T0KEN-not-real-1234567890", session_id: "sess1", seq: 7 }); assert.equal(ws2.sentOps(OP.IDENTIFY).length, 0); ws2.receive({ op: OP.DISPATCH, t: "RESUMED", s: 8, d: {} }); assert.equal(events.filter((e) => e[0] === "resumed").length, 1); }); test("gateway: op 7 reconnect resumes; op 9 non-resumable re-identifies", () => { const { gw, t, sockets } = setup(); gw.connect(); const ws = sockets[0]; hello(ws); ws.receive({ op: OP.DISPATCH, t: "READY", s: 3, d: { session_id: "s", resume_gateway_url: "wss://r.example", user: { id: "1" } } }); ws.receive({ op: OP.RECONNECT }); assert.equal(ws.closeCalls.length, 1); t.advance(2000); const ws2 = sockets[1]; hello(ws2); assert.equal(ws2.sentOps(OP.RESUME).length, 1); ws2.receive({ op: OP.INVALID_SESSION, d: false }); t.advance(3000); const ws3 = sockets[2]; assert.equal(ws3.url, "wss://gateway.example/?v=10&encoding=json"); hello(ws3); assert.equal(ws3.sentOps(OP.IDENTIFY).length, 1); assert.equal(ws3.sentOps(OP.RESUME).length, 0); }); test("gateway: op 9 resumable resumes", () => { const { gw, t, sockets } = setup(); gw.connect(); const ws = sockets[0]; hello(ws); ws.receive({ op: OP.DISPATCH, t: "READY", s: 3, d: { session_id: "s", resume_gateway_url: "wss://r.example", user: { id: "1" } } }); ws.receive({ op: OP.INVALID_SESSION, d: true }); t.advance(3000); const ws2 = sockets[1]; hello(ws2); assert.equal(ws2.sentOps(OP.RESUME).length, 1); }); test("gateway: close 4014 is fatal, reports the missing intent, never reconnects", () => { const { gw, t, sockets, events } = setup(); gw.connect(); const ws = sockets[0]; hello(ws); ws.serverClose(4014, "Disallowed intent(s)."); const fatal = events.find((e) => e[0] === "fatal"); assert.ok(fatal); assert.equal(fatal[1].code, 4014); assert.match(fatal[1].reason, /message content/); t.advance(120000); assert.equal(sockets.length, 1); assert.throws(() => gw.connect(), /already stopped/); }); test("gateway: 4004 and 4013 are fatal too; 1006 reconnects with identify when no session", () => { for (const code of [4004, 4013]) { const { gw, t, sockets, events } = setup(); gw.connect(); hello(sockets[0]); sockets[0].serverClose(code); t.advance(120000); assert.equal(sockets.length, 1, `code ${code}`); assert.equal(events.filter((e) => e[0] === "fatal").length, 1); } const { gw, t, sockets } = setup(); gw.connect(); hello(sockets[0]); sockets[0].serverClose(1006); t.advance(2000); assert.equal(sockets.length, 2); hello(sockets[1]); assert.equal(sockets[1].sentOps(OP.IDENTIFY).length, 1); }); test("gateway: close() is final and unparseable frames are ignored", () => { const { gw, t, sockets, events } = setup(); gw.connect(); const ws = sockets[0]; hello(ws); ws.onmessage({ data: "{nope" }); assert.ok(events.some((e) => e[0] === "log" && /unparseable/.test(e[1]))); gw.close(); assert.equal(ws.closeCalls[0].code, 1000); t.advance(120000); assert.equal(sockets.length, 1); assert.equal(t.pending, 0); });