Files
openclaw/extensions/discord/src/monitor/provider.lifecycle.test.ts
2026-06-01 01:12:21 +01:00

774 lines
26 KiB
TypeScript

import { EventEmitter } from "node:events";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { beforeAll, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import type { GatewayPlugin } from "../internal/gateway.js";
import type { WaitForDiscordGatewayStopParams } from "../monitor.gateway.js";
import {
DISCORD_GATEWAY_TRANSPORT_ACTIVITY_EVENT,
type MutableDiscordGateway,
} from "./gateway-handle.js";
import type { DiscordGatewayEvent } from "./gateway-supervisor.js";
type LifecycleParams = Parameters<
typeof import("./provider.lifecycle.js").runDiscordGatewayLifecycle
>[0];
type MockGateway = {
isConnected: boolean;
options: GatewayPlugin["options"];
disconnect: Mock<() => void>;
connect: Mock<(resume?: boolean) => void>;
emitter: EventEmitter;
ws?: EventEmitter & { terminate?: Mock<() => void> };
};
const {
attachDiscordGatewayLoggingMock,
getDiscordGatewayEmitterMock,
registerGatewayMock,
stopGatewayLoggingMock,
unregisterGatewayMock,
waitForDiscordGatewayStopMock,
} = vi.hoisted(() => {
const stopGatewayLoggingMockLocal = vi.fn();
const getDiscordGatewayEmitterMockLocal = vi.fn<() => EventEmitter | undefined>(() => undefined);
return {
attachDiscordGatewayLoggingMock: vi.fn(() => stopGatewayLoggingMockLocal),
getDiscordGatewayEmitterMock: getDiscordGatewayEmitterMockLocal,
waitForDiscordGatewayStopMock: vi.fn((_params: WaitForDiscordGatewayStopParams) =>
Promise.resolve(),
),
registerGatewayMock: vi.fn(),
unregisterGatewayMock: vi.fn(),
stopGatewayLoggingMock: stopGatewayLoggingMockLocal,
};
});
vi.mock("../gateway-logging.js", () => ({
attachDiscordGatewayLogging: attachDiscordGatewayLoggingMock,
}));
vi.mock("../monitor.gateway.js", () => ({
getDiscordGatewayEmitter: getDiscordGatewayEmitterMock,
waitForDiscordGatewayStop: waitForDiscordGatewayStopMock,
}));
vi.mock("./gateway-registry.js", () => ({
registerGateway: registerGatewayMock,
unregisterGateway: unregisterGatewayMock,
}));
describe("runDiscordGatewayLifecycle", () => {
let runDiscordGatewayLifecycle: typeof import("./provider.lifecycle.js").runDiscordGatewayLifecycle;
let resolveDiscordGatewayReadyTimeoutMs: typeof import("./provider.lifecycle.js").resolveDiscordGatewayReadyTimeoutMs;
let resolveDiscordGatewayRuntimeReadyTimeoutMs: typeof import("./provider.lifecycle.js").resolveDiscordGatewayRuntimeReadyTimeoutMs;
beforeAll(async () => {
({
runDiscordGatewayLifecycle,
resolveDiscordGatewayReadyTimeoutMs,
resolveDiscordGatewayRuntimeReadyTimeoutMs,
} = await import("./provider.lifecycle.js"));
});
beforeEach(() => {
attachDiscordGatewayLoggingMock.mockClear();
getDiscordGatewayEmitterMock.mockClear();
waitForDiscordGatewayStopMock.mockClear();
registerGatewayMock.mockClear();
unregisterGatewayMock.mockClear();
stopGatewayLoggingMock.mockClear();
});
function createGatewayHarness(params?: {
ws?: EventEmitter & { terminate?: Mock<() => void> };
}): { emitter: EventEmitter; gateway: MockGateway } {
const emitter = new EventEmitter();
return {
emitter,
gateway: {
isConnected: false,
options: { intents: 0, reconnect: { maxAttempts: 50 } } as GatewayPlugin["options"],
disconnect: vi.fn(),
connect: vi.fn(),
emitter,
...(params?.ws ? { ws: params.ws } : {}),
},
};
}
function createGatewayEvent(
type: DiscordGatewayEvent["type"],
message: string,
): DiscordGatewayEvent {
const err = new Error(message);
return {
type,
err,
message: String(err),
shouldStopLifecycle: type !== "other",
};
}
function createLifecycleHarness(params?: {
gateway?: MockGateway | null;
isDisallowedIntentsError?: (err: unknown) => boolean;
pendingGatewayEvents?: DiscordGatewayEvent[];
}) {
const gateway =
params && "gateway" in params
? params.gateway
: (() => {
const defaultGateway = createGatewayHarness().gateway;
defaultGateway.isConnected = true;
return defaultGateway;
})();
const gatewayEmitter = gateway?.emitter ?? new EventEmitter();
const threadStop = vi.fn();
const runtimeLog = vi.fn();
const runtimeError = vi.fn();
const pendingGatewayEvents = params?.pendingGatewayEvents ?? [];
const gatewaySupervisor = {
attachLifecycle: vi.fn(),
detachLifecycle: vi.fn(),
drainPending: vi.fn((handler: (event: DiscordGatewayEvent) => "continue" | "stop") => {
const queued = [...pendingGatewayEvents];
pendingGatewayEvents.length = 0;
for (const event of queued) {
if (handler(event) === "stop") {
return "stop";
}
}
return "continue";
}),
dispose: vi.fn(),
emitter: gatewayEmitter,
};
const statusSink = vi.fn();
const runtime: RuntimeEnv = {
log: runtimeLog,
error: runtimeError,
exit: vi.fn(),
};
const lifecycleParams: LifecycleParams = {
accountId: "default",
gateway: gateway ? (gateway as unknown as MutableDiscordGateway) : undefined,
runtime,
isDisallowedIntentsError: params?.isDisallowedIntentsError ?? (() => false),
voiceManager: null,
voiceManagerRef: { current: null },
threadBindings: { stop: threadStop },
gatewaySupervisor,
statusSink,
abortSignal: undefined,
};
return {
threadStop,
runtimeLog,
runtimeError,
gatewaySupervisor,
statusSink,
lifecycleParams,
};
}
function expectLifecycleCleanup(params: {
threadStop: ReturnType<typeof vi.fn>;
waitCalls: number;
gatewaySupervisor: { detachLifecycle: ReturnType<typeof vi.fn> };
detachCalls?: number;
}) {
expect(waitForDiscordGatewayStopMock).toHaveBeenCalledTimes(params.waitCalls);
expect(unregisterGatewayMock).toHaveBeenCalledWith("default");
expect(stopGatewayLoggingMock).toHaveBeenCalledTimes(1);
expect(params.threadStop).toHaveBeenCalledTimes(1);
expect(params.gatewaySupervisor.detachLifecycle).toHaveBeenCalledTimes(params.detachCalls ?? 1);
}
function mockMessages(mock: ReturnType<typeof vi.fn>): string[] {
return mock.mock.calls.map((call) => String(call[0] ?? ""));
}
function expectMockMessageContains(mock: ReturnType<typeof vi.fn>, expected: string): void {
expect(mockMessages(mock).join("\n")).toContain(expected);
}
function expectMockMessageNotContains(mock: ReturnType<typeof vi.fn>, expected: string): void {
expect(mockMessages(mock).join("\n")).not.toContain(expected);
}
type StatusPatch = {
connected?: boolean;
lastDisconnect?: null | Record<string, unknown>;
lastError?: string | null;
};
function statusPatches(statusSink: ReturnType<typeof vi.fn>): StatusPatch[] {
return statusSink.mock.calls.map((call) => call[0] as StatusPatch);
}
function expectStatusPatch(
statusSink: ReturnType<typeof vi.fn>,
predicate: (patch: StatusPatch) => boolean,
): void {
expect(statusPatches(statusSink).some(predicate)).toBe(true);
}
it("resolves gateway READY timeouts from config, env, then defaults", () => {
expect(resolveDiscordGatewayReadyTimeoutMs({ configuredTimeoutMs: 45_000 })).toBe(45_000);
expect(
resolveDiscordGatewayReadyTimeoutMs({
env: { OPENCLAW_DISCORD_READY_TIMEOUT_MS: "90000" },
}),
).toBe(90_000);
expect(resolveDiscordGatewayReadyTimeoutMs({ env: {} })).toBe(15_000);
expect(resolveDiscordGatewayRuntimeReadyTimeoutMs({ configuredTimeoutMs: 60_000 })).toBe(
60_000,
);
expect(
resolveDiscordGatewayRuntimeReadyTimeoutMs({
env: { OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS: "120000" },
}),
).toBe(120_000);
expect(resolveDiscordGatewayRuntimeReadyTimeoutMs({ env: {} })).toBe(30_000);
});
it("ignores non-integer gateway READY timeout values", () => {
expect(
resolveDiscordGatewayReadyTimeoutMs({
configuredTimeoutMs: 1.5,
env: { OPENCLAW_DISCORD_READY_TIMEOUT_MS: "0x1000" },
}),
).toBe(15_000);
expect(
resolveDiscordGatewayRuntimeReadyTimeoutMs({
env: { OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS: "1e3" },
}),
).toBe(30_000);
});
it("cleans up thread bindings when gateway wait fails before READY", async () => {
waitForDiscordGatewayStopMock.mockRejectedValueOnce(new Error("startup failed"));
const { lifecycleParams, threadStop, gatewaySupervisor } = createLifecycleHarness();
await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow("startup failed");
expectLifecycleCleanup({
threadStop,
waitCalls: 1,
gatewaySupervisor,
});
});
it("cleans up when gateway wait fails after startup", async () => {
waitForDiscordGatewayStopMock.mockRejectedValueOnce(new Error("gateway wait failed"));
const { lifecycleParams, threadStop, gatewaySupervisor } = createLifecycleHarness();
await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow(
"gateway wait failed",
);
expectLifecycleCleanup({
threadStop,
waitCalls: 1,
gatewaySupervisor,
});
});
it("pushes connected status when gateway is already connected at lifecycle start", async () => {
const { emitter, gateway } = createGatewayHarness();
gateway.isConnected = true;
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
const { lifecycleParams, statusSink } = createLifecycleHarness({ gateway });
await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined();
expectStatusPatch(
statusSink,
(patch) => patch.connected === true && patch.lastDisconnect === null,
);
});
it("does not treat a missing gateway handle as ready", async () => {
vi.useFakeTimers();
try {
const { lifecycleParams, threadStop, statusSink, gatewaySupervisor } = createLifecycleHarness(
{
gateway: null,
},
);
lifecycleParams.gatewayReadyTimeoutMs = 5_000;
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
lifecyclePromise.catch(() => {});
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(5_500);
await expect(lifecyclePromise).rejects.toThrow(
"discord gateway did not reach READY within 5000ms",
);
expect(statusPatches(statusSink).every((patch) => patch.connected !== true)).toBe(true);
expectLifecycleCleanup({
threadStop,
waitCalls: 0,
gatewaySupervisor,
});
} finally {
vi.useRealTimers();
}
});
it("records throttled gateway socket activity as transport liveness", async () => {
const { emitter, gateway } = createGatewayHarness();
gateway.isConnected = true;
let resolveWait: (() => void) | undefined;
waitForDiscordGatewayStopMock.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveWait = resolve;
}),
);
const { lifecycleParams, statusSink } = createLifecycleHarness({ gateway });
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
await vi.waitFor(() => expect(waitForDiscordGatewayStopMock).toHaveBeenCalledTimes(1));
const baselinePatchCount = statusSink.mock.calls.length;
emitter.emit(DISCORD_GATEWAY_TRANSPORT_ACTIVITY_EVENT, { at: 100_000 });
emitter.emit(DISCORD_GATEWAY_TRANSPORT_ACTIVITY_EVENT, { at: 101_000 });
emitter.emit(DISCORD_GATEWAY_TRANSPORT_ACTIVITY_EVENT, { at: 131_000 });
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(200_000);
try {
emitter.emit(DISCORD_GATEWAY_TRANSPORT_ACTIVITY_EVENT, {
at: Number.MAX_SAFE_INTEGER,
});
} finally {
nowSpy.mockRestore();
}
const transportPatches = statusSink.mock.calls
.slice(baselinePatchCount)
.map((call) => call[0] as Record<string, unknown>)
.filter((patch) => typeof patch.lastTransportActivityAt === "number");
expect(transportPatches).toEqual([
{ lastTransportActivityAt: 100_000 },
{ lastTransportActivityAt: 131_000 },
{ lastTransportActivityAt: 200_000 },
]);
expect(
transportPatches.every(
(patch) => patch.lastEventAt === undefined && patch.connected === undefined,
),
).toBe(true);
if (!resolveWait) {
throw new Error("expected lifecycle wait resolver");
}
resolveWait();
await expect(lifecyclePromise).resolves.toBeUndefined();
});
it("removes the gateway socket activity listener during lifecycle cleanup", async () => {
const { emitter, gateway } = createGatewayHarness();
gateway.isConnected = true;
const { lifecycleParams, statusSink } = createLifecycleHarness({ gateway });
await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined();
const callCountAfterCleanup = statusSink.mock.calls.length;
emitter.emit(DISCORD_GATEWAY_TRANSPORT_ACTIVITY_EVENT, { at: Date.now() });
expect(statusSink).toHaveBeenCalledTimes(callCountAfterCleanup);
});
it("reconnects with backoff when startup never reaches READY, then recovers", async () => {
vi.useFakeTimers();
try {
const { emitter, gateway } = createGatewayHarness();
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
gateway.connect.mockImplementation(() => {
setTimeout(() => {
gateway.isConnected = true;
}, 1_000);
});
const { lifecycleParams, runtimeError, statusSink } = createLifecycleHarness({ gateway });
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
await vi.advanceTimersByTimeAsync(18_500);
await expect(lifecyclePromise).resolves.toBeUndefined();
expectMockMessageContains(runtimeError, "gateway READY wait timed out after 15000ms");
expectMockMessageNotContains(
runtimeError,
"gateway was not ready after 15000ms; restarting gateway",
);
expect(gateway.disconnect).toHaveBeenCalledTimes(1);
expect(gateway.connect).toHaveBeenCalledTimes(1);
expect(gateway.connect).toHaveBeenCalledWith(false);
expectStatusPatch(
statusSink,
(patch) =>
patch.connected === true && patch.lastDisconnect === null && patch.lastError === null,
);
} finally {
vi.useRealTimers();
}
});
it("waits for the stale startup socket to close before reconnecting", async () => {
vi.useFakeTimers();
try {
const socket = new EventEmitter();
const { emitter, gateway } = createGatewayHarness({ ws: socket });
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
gateway.disconnect.mockImplementation(() => {
setTimeout(() => {
socket.emit("close", 1000, "Client disconnect");
}, 1_000);
});
gateway.connect.mockImplementation(() => {
setTimeout(() => {
gateway.isConnected = true;
}, 1_000);
});
const { lifecycleParams } = createLifecycleHarness({ gateway });
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
await vi.advanceTimersByTimeAsync(15_100);
expect(gateway.disconnect).toHaveBeenCalledTimes(1);
expect(gateway.connect).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1_100);
expect(gateway.connect).toHaveBeenCalledTimes(1);
expect(gateway.connect).toHaveBeenCalledWith(false);
await vi.advanceTimersByTimeAsync(3_000);
await expect(lifecyclePromise).resolves.toBeUndefined();
} finally {
vi.useRealTimers();
}
});
it("keeps retrying when startup still is not ready after a reconnect", async () => {
vi.useFakeTimers();
try {
const { emitter, gateway } = createGatewayHarness();
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
const { lifecycleParams, threadStop, gatewaySupervisor } = createLifecycleHarness({
gateway,
});
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
lifecyclePromise.catch(() => {});
await vi.advanceTimersByTimeAsync(34_000);
expect(gateway.disconnect).toHaveBeenCalledTimes(2);
expect(gateway.connect).toHaveBeenCalledTimes(2);
expect(gateway.connect).toHaveBeenCalledWith(false);
expect(waitForDiscordGatewayStopMock).not.toHaveBeenCalled();
gateway.isConnected = true;
await vi.advanceTimersByTimeAsync(2_500);
await expect(lifecyclePromise).resolves.toBeUndefined();
expectLifecycleCleanup({ threadStop, waitCalls: 1, gatewaySupervisor });
} finally {
vi.useRealTimers();
}
});
it("handles queued disallowed intents errors without waiting for gateway events", async () => {
const { lifecycleParams, threadStop, runtimeError, gatewaySupervisor } = createLifecycleHarness(
{
pendingGatewayEvents: [
createGatewayEvent("disallowed-intents", "Fatal Gateway error: 4014"),
],
isDisallowedIntentsError: (err) => String(err).includes("4014"),
},
);
await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined();
expectMockMessageContains(runtimeError, "discord: gateway closed with code 4014");
expectLifecycleCleanup({
threadStop,
waitCalls: 0,
gatewaySupervisor,
});
});
it("logs queued non-fatal startup gateway errors and continues", async () => {
const { lifecycleParams, threadStop, runtimeError, gatewaySupervisor } = createLifecycleHarness(
{
pendingGatewayEvents: [createGatewayEvent("other", "transient startup error")],
},
);
await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined();
expectMockMessageContains(
runtimeError,
"discord gateway error: Error: transient startup error",
);
expectLifecycleCleanup({
threadStop,
waitCalls: 1,
gatewaySupervisor,
});
});
it("throws queued fatal startup gateway errors", async () => {
const { lifecycleParams, threadStop, gatewaySupervisor } = createLifecycleHarness({
pendingGatewayEvents: [createGatewayEvent("fatal", "Fatal Gateway error: 4000")],
});
await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow(
"discord gateway fatal: Error: Fatal Gateway error: 4000",
);
expectLifecycleCleanup({
threadStop,
waitCalls: 0,
gatewaySupervisor,
});
});
it("throws queued reconnect exhaustion errors", async () => {
const { lifecycleParams, threadStop, gatewaySupervisor } = createLifecycleHarness({
pendingGatewayEvents: [
createGatewayEvent(
"reconnect-exhausted",
"Max reconnect attempts (50) reached after code 1005",
),
],
});
await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow(
"discord gateway reconnect-exhausted: Error: Max reconnect attempts (50) reached after code 1005",
);
expectLifecycleCleanup({
threadStop,
waitCalls: 0,
gatewaySupervisor,
});
});
it("treats abort-time live reconnect exhaustion as expected shutdown", async () => {
const abortController = new AbortController();
let liveGatewayHandler: ((event: DiscordGatewayEvent) => void) | undefined;
const { lifecycleParams, threadStop, runtimeLog, runtimeError, gatewaySupervisor } =
createLifecycleHarness();
lifecycleParams.abortSignal = abortController.signal;
gatewaySupervisor.attachLifecycle.mockImplementation(
(handler: (event: DiscordGatewayEvent) => void) => {
liveGatewayHandler = handler;
},
);
abortController.signal.addEventListener(
"abort",
() => {
if (!liveGatewayHandler) {
throw new Error("discord gateway lifecycle handler was not attached");
}
liveGatewayHandler(
createGatewayEvent(
"reconnect-exhausted",
"Max reconnect attempts (50) reached after close code 1005",
),
);
},
{ once: true },
);
waitForDiscordGatewayStopMock.mockImplementationOnce(async (waitParams) => {
const actual =
await vi.importActual<typeof import("../monitor.gateway.js")>("../monitor.gateway.js");
const waitPromise = actual.waitForDiscordGatewayStop(waitParams);
abortController.abort(new Error("shutdown"));
return await waitPromise;
});
await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined();
expect(gatewaySupervisor.attachLifecycle).toHaveBeenCalledTimes(1);
expectMockMessageContains(
runtimeLog,
"treating reconnect-exhausted during expected shutdown as clean",
);
expectMockMessageContains(
runtimeLog,
"Max reconnect attempts (50) reached after close code 1005",
);
expectMockMessageNotContains(runtimeError, "discord gateway reconnect-exhausted");
expectLifecycleCleanup({
threadStop,
waitCalls: 1,
gatewaySupervisor,
detachCalls: 2,
});
});
it("surfaces fatal startup gateway errors while waiting for READY", async () => {
vi.useFakeTimers();
try {
const pendingGatewayEvents: DiscordGatewayEvent[] = [];
const { emitter, gateway } = createGatewayHarness();
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
const { lifecycleParams, threadStop, runtimeError, gatewaySupervisor } =
createLifecycleHarness({
gateway,
pendingGatewayEvents,
});
setTimeout(() => {
pendingGatewayEvents.push(createGatewayEvent("fatal", "Fatal Gateway error: 4001"));
}, 1_000);
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
lifecyclePromise.catch(() => {});
await vi.advanceTimersByTimeAsync(1_500);
await expect(lifecyclePromise).rejects.toThrow(
"discord gateway fatal: Error: Fatal Gateway error: 4001",
);
expectMockMessageContains(
runtimeError,
"discord gateway fatal: Error: Fatal Gateway error: 4001",
);
expect(gateway.disconnect).not.toHaveBeenCalled();
expect(gateway.connect).not.toHaveBeenCalled();
expectLifecycleCleanup({
threadStop,
waitCalls: 0,
gatewaySupervisor,
});
} finally {
vi.useRealTimers();
}
});
it("pushes disconnected status when the gateway closes after startup", async () => {
const { emitter, gateway } = createGatewayHarness();
gateway.isConnected = true;
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
waitForDiscordGatewayStopMock.mockImplementationOnce(async () => {
emitter.emit("debug", "Gateway websocket closed: 1006");
});
const { lifecycleParams, statusSink } = createLifecycleHarness({ gateway });
await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined();
expectStatusPatch(
statusSink,
(patch) =>
patch.connected === false &&
patch.lastDisconnect !== null &&
patch.lastDisconnect?.status === 1006,
);
});
it("pushes disconnected status when the gateway schedules a reconnect", async () => {
const { emitter, gateway } = createGatewayHarness();
gateway.isConnected = true;
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
waitForDiscordGatewayStopMock.mockImplementationOnce(async () => {
emitter.emit("debug", "Gateway reconnect scheduled in 1000ms (zombie, resume=true)");
});
const { lifecycleParams, statusSink } = createLifecycleHarness({ gateway });
await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined();
expectStatusPatch(
statusSink,
(patch) =>
patch.connected === false &&
patch.lastError === "Gateway reconnect scheduled in 1000ms (zombie, resume=true)",
);
});
it("pushes connected status when a runtime reconnect becomes ready", async () => {
vi.useFakeTimers();
try {
const { emitter, gateway } = createGatewayHarness();
gateway.isConnected = true;
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
waitForDiscordGatewayStopMock.mockImplementationOnce(async () => {
gateway.isConnected = false;
emitter.emit("debug", "Gateway websocket opened");
setTimeout(() => {
gateway.isConnected = true;
}, 1_000);
await vi.advanceTimersByTimeAsync(1_500);
});
const { lifecycleParams, statusSink } = createLifecycleHarness({ gateway });
await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined();
expectStatusPatch(statusSink, (patch) => patch.connected === false);
expectStatusPatch(
statusSink,
(patch) => patch.connected === true && patch.lastDisconnect === null,
);
} finally {
vi.useRealTimers();
}
});
it("force-stops when a runtime reconnect opens but never becomes ready", async () => {
vi.useFakeTimers();
try {
const { emitter, gateway } = createGatewayHarness();
gateway.isConnected = true;
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
waitForDiscordGatewayStopMock.mockImplementationOnce(
(params: WaitForDiscordGatewayStopParams) =>
new Promise<void>((_resolve, reject) => {
params.registerForceStop?.((err) =>
reject(toLintErrorObject(err, "Non-Error rejection")),
);
gateway.isConnected = false;
emitter.emit("debug", "Gateway websocket opened");
}),
);
const { lifecycleParams, runtimeError, statusSink } = createLifecycleHarness({ gateway });
lifecycleParams.gatewayRuntimeReadyTimeoutMs = 5_000;
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
lifecyclePromise.catch(() => {});
await vi.advanceTimersByTimeAsync(5_500);
await expect(lifecyclePromise).rejects.toThrow(
"discord gateway opened but did not reach READY within 5000ms",
);
expectMockMessageContains(runtimeError, "did not reach READY within 5000ms");
expectStatusPatch(
statusSink,
(patch) =>
patch.connected === false &&
patch.lastDisconnect !== null &&
patch.lastDisconnect?.error === "runtime-not-ready",
);
} finally {
vi.useRealTimers();
}
});
});
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}