dev/2026-04-08 #1

Merged
hzhang merged 24 commits from dev/2026-04-08 into main 2026-04-13 09:34:01 +00:00
Showing only changes of commit b10ebc541e - Show all commits

View File

@@ -0,0 +1,163 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type EventName = "open" | "close" | "error" | "message";
type EventHandler = (...args: any[]) => void;
const socketInstances: MockWebSocket[] = [];
const pendingBehaviors: Array<"open" | "error"> = [];
class MockWebSocket {
static readonly OPEN = 1;
static readonly CLOSED = 3;
readyState = 0;
sent: string[] = [];
private readonly handlers = new Map<EventName, EventHandler[]>();
constructor(public readonly url: string) {
socketInstances.push(this);
const behavior = pendingBehaviors.shift() ?? "open";
queueMicrotask(() => {
if (behavior === "open") {
this.readyState = MockWebSocket.OPEN;
this.emit("open");
return;
}
const error = new Error(`mock connect failure for ${url}`);
this.emit("error", error);
this.emit("close", 1006, Buffer.from("connect failed"));
this.readyState = MockWebSocket.CLOSED;
});
}
once(event: EventName, handler: EventHandler): this {
const onceHandler: EventHandler = (...args: any[]) => {
this.off(event, onceHandler);
handler(...args);
};
return this.on(event, onceHandler);
}
on(event: EventName, handler: EventHandler): this {
const existing = this.handlers.get(event) ?? [];
existing.push(handler);
this.handlers.set(event, existing);
return this;
}
off(event: EventName, handler: EventHandler): this {
const existing = this.handlers.get(event) ?? [];
this.handlers.set(
event,
existing.filter((candidate) => candidate !== handler)
);
return this;
}
send(message: string): void {
this.sent.push(message);
}
close(code = 1000, reason = ""):
void {
this.readyState = MockWebSocket.CLOSED;
this.emit("close", code, Buffer.from(reason));
}
emit(event: EventName, ...args: any[]): void {
for (const handler of [...(this.handlers.get(event) ?? [])]) {
handler(...args);
}
}
}
vi.mock("ws", () => ({
WebSocket: MockWebSocket
}));
describe("Yonexus.Client transport reconnect behavior", () => {
beforeEach(() => {
vi.useFakeTimers();
socketInstances.length = 0;
pendingBehaviors.length = 0;
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it("CF-02: retries initial connect with exponential backoff when server is unreachable", async () => {
const { createClientTransport } = await import("../plugin/core/transport.js");
const onStateChange = vi.fn();
const onError = vi.fn();
pendingBehaviors.push("error", "error", "open");
const transport = createClientTransport({
config: {
mainHost: "ws://localhost:8787",
identifier: "client-a",
notifyBotToken: "stub-token",
adminUserId: "admin-user"
},
onMessage: vi.fn(),
onStateChange,
onError
});
await expect(transport.connect()).rejects.toThrow("mock connect failure");
expect(socketInstances).toHaveLength(1);
expect(transport.state).toBe("disconnected");
await vi.advanceTimersByTimeAsync(999);
expect(socketInstances).toHaveLength(1);
await vi.advanceTimersByTimeAsync(1);
expect(socketInstances).toHaveLength(2);
await vi.advanceTimersByTimeAsync(1_999);
expect(socketInstances).toHaveLength(2);
await vi.advanceTimersByTimeAsync(1);
expect(socketInstances).toHaveLength(3);
expect(transport.state).toBe("connected");
expect(onError.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(onStateChange).toHaveBeenCalledWith("connecting");
expect(onStateChange).toHaveBeenCalledWith("connected");
});
it("CF-01: reconnects with backoff after network partition closes an established connection", async () => {
const { createClientTransport } = await import("../plugin/core/transport.js");
pendingBehaviors.push("open", "open");
const transport = createClientTransport({
config: {
mainHost: "ws://localhost:8787",
identifier: "client-a",
notifyBotToken: "stub-token",
adminUserId: "admin-user"
},
onMessage: vi.fn(),
onStateChange: vi.fn(),
onError: vi.fn()
});
await transport.connect();
expect(socketInstances).toHaveLength(1);
expect(transport.state).toBe("connected");
socketInstances[0].emit("close", 1006, Buffer.from("network partition"));
expect(transport.state).toBe("disconnected");
await vi.advanceTimersByTimeAsync(999);
expect(socketInstances).toHaveLength(1);
await vi.advanceTimersByTimeAsync(1);
expect(socketInstances).toHaveLength(2);
expect(transport.state).toBe("connected");
});
});