diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index d68b8894d..000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(pnpm build:*)", - "Bash(pnpm test:*)", - "Bash(pnpm -w run test:*)" - ] - } -} diff --git a/extensions/twitch/src/probe.test.ts b/extensions/twitch/src/probe.test.ts index dd1dbc90f..ad985f460 100644 --- a/extensions/twitch/src/probe.test.ts +++ b/extensions/twitch/src/probe.test.ts @@ -3,13 +3,50 @@ import { probeTwitch } from "./probe.js"; import type { TwitchAccountConfig } from "./types.js"; // Mock Twurple modules - Vitest v4 compatible mocking -const mockConnect = vi.fn().mockResolvedValue(undefined); +const mockUnbind = vi.fn(); + +// Event handler storage +let connectHandler: (() => void) | null = null; +let disconnectHandler: ((manually: boolean, reason?: Error) => void) | null = null; +let authFailHandler: (() => void) | null = null; + +// Event listener mocks that store handlers and return unbind function +const mockOnConnect = vi.fn((handler: () => void) => { + connectHandler = handler; + return { unbind: mockUnbind }; +}); + +const mockOnDisconnect = vi.fn((handler: (manually: boolean, reason?: Error) => void) => { + disconnectHandler = handler; + return { unbind: mockUnbind }; +}); + +const mockOnAuthenticationFailure = vi.fn((handler: () => void) => { + authFailHandler = handler; + return { unbind: mockUnbind }; +}); + +// Connect mock that triggers the registered handler +const defaultConnectImpl = async () => { + // Simulate successful connection by calling the handler after a delay + if (connectHandler) { + // Use setTimeout to simulate async connection + await new Promise((resolve) => setTimeout(resolve, 0)); + connectHandler(); + } +}; + +const mockConnect = vi.fn().mockImplementation(defaultConnectImpl); + const mockQuit = vi.fn().mockResolvedValue(undefined); vi.mock("@twurple/chat", () => ({ ChatClient: class { connect = mockConnect; quit = mockQuit; + onConnect = mockOnConnect; + onDisconnect = mockOnDisconnect; + onAuthenticationFailure = mockOnAuthenticationFailure; }, })); @@ -25,6 +62,10 @@ describe("probeTwitch", () => { beforeEach(() => { vi.clearAllMocks(); + // Reset handlers + connectHandler = null; + disconnectHandler = null; + authFailHandler = null; }); it("returns error when username is missing", async () => { @@ -85,11 +126,18 @@ describe("probeTwitch", () => { expect(result.error).toContain("timeout"); // Reset mock - mockConnect.mockResolvedValue(undefined); + mockConnect.mockImplementation(defaultConnectImpl); }); it("cleans up client even on failure", async () => { - mockConnect.mockRejectedValueOnce(new Error("Connection failed")); + mockConnect.mockImplementationOnce(async () => { + // Simulate connection failure by calling disconnect handler + // onDisconnect signature: (manually: boolean, reason?: Error) => void + if (disconnectHandler) { + await new Promise((resolve) => setTimeout(resolve, 0)); + disconnectHandler(false, new Error("Connection failed")); + } + }); const result = await probeTwitch(mockAccount, 5000); @@ -98,7 +146,7 @@ describe("probeTwitch", () => { expect(mockQuit).toHaveBeenCalled(); // Reset mocks - mockConnect.mockResolvedValue(undefined); + mockConnect.mockImplementation(defaultConnectImpl); }); it("measures elapsed time", async () => { @@ -109,7 +157,14 @@ describe("probeTwitch", () => { }); it("handles connection errors gracefully", async () => { - mockConnect.mockRejectedValueOnce(new Error("Network error")); + mockConnect.mockImplementationOnce(async () => { + // Simulate connection failure by calling disconnect handler + // onDisconnect signature: (manually: boolean, reason?: Error) => void + if (disconnectHandler) { + await new Promise((resolve) => setTimeout(resolve, 0)); + disconnectHandler(false, new Error("Network error")); + } + }); const result = await probeTwitch(mockAccount, 5000); @@ -117,7 +172,7 @@ describe("probeTwitch", () => { expect(result.error).toContain("Network error"); // Reset mock - mockConnect.mockResolvedValue(undefined); + mockConnect.mockImplementation(defaultConnectImpl); }); it("trims token before validation", async () => { @@ -132,7 +187,14 @@ describe("probeTwitch", () => { }); it("handles non-Error objects in catch block", async () => { - mockConnect.mockRejectedValueOnce("String error"); + mockConnect.mockImplementationOnce(async () => { + // Simulate connection failure by calling disconnect handler + // onDisconnect signature: (manually: boolean, reason?: Error) => void + if (disconnectHandler) { + await new Promise((resolve) => setTimeout(resolve, 0)); + disconnectHandler(false, "String error" as unknown as Error); + } + }); const result = await probeTwitch(mockAccount, 5000); @@ -140,6 +202,6 @@ describe("probeTwitch", () => { expect(result.error).toBe("String error"); // Reset mock - mockConnect.mockResolvedValue(undefined); + mockConnect.mockImplementation(defaultConnectImpl); }); }); diff --git a/extensions/twitch/src/twitch-client.test.ts b/extensions/twitch/src/twitch-client.test.ts index 998880e6b..5361f5a49 100644 --- a/extensions/twitch/src/twitch-client.test.ts +++ b/extensions/twitch/src/twitch-client.test.ts @@ -18,8 +18,23 @@ const mockConnect = vi.fn().mockResolvedValue(undefined); const mockJoin = vi.fn().mockResolvedValue(undefined); const mockSay = vi.fn().mockResolvedValue({ messageId: "test-msg-123" }); const mockQuit = vi.fn(); -const mockOnMessage = vi.fn(); -const mockOnWhisper = vi.fn(); +const mockUnbind = vi.fn(); + +// Event handler storage for testing +const messageHandlers: Array<(channel: string, user: string, message: string, msg: any) => void> = []; +const whisperHandlers: Array<(user: string, message: string, msg: any) => void> = []; + +// Mock functions that track handlers and return unbind objects +const mockOnMessage = vi.fn((handler: any) => { + messageHandlers.push(handler); + return { unbind: mockUnbind }; +}); + +const mockOnWhisper = vi.fn((handler: any) => { + whisperHandlers.push(handler); + return { unbind: mockUnbind }; +}); + const mockAddUserForToken = vi.fn().mockResolvedValue("123456"); const mockOnRefresh = vi.fn(); const mockOnRefreshFailure = vi.fn(); @@ -33,6 +48,14 @@ vi.mock("@twurple/chat", () => ({ say = mockSay; quit = mockQuit; }, + LogLevel: { + CRITICAL: "CRITICAL", + ERROR: "ERROR", + WARNING: "WARNING", + INFO: "INFO", + DEBUG: "DEBUG", + TRACE: "TRACE", + }, })); const mockAuthProvider = { @@ -85,6 +108,10 @@ describe("TwitchClientManager", () => { // Clear all mocks first vi.clearAllMocks(); + // Clear handler arrays + messageHandlers.length = 0; + whisperHandlers.length = 0; + // Re-set up the default token mock implementation after clearing const { resolveTwitchToken } = await import("./token.js"); vi.mocked(resolveTwitchToken).mockReturnValue({ @@ -396,7 +423,7 @@ describe("TwitchClientManager", () => { await manager.getClient(testAccount); // Get the onMessage callback - const onMessageCallback = mockOnMessage.mock.calls[0]?.[0]; + const onMessageCallback = messageHandlers[0]; if (!onMessageCallback) throw new Error("onMessageCallback not found"); // Simulate Twitch message @@ -426,7 +453,7 @@ describe("TwitchClientManager", () => { await manager.getClient(testAccount); // Get the onWhisper callback - const onWhisperCallback = mockOnWhisper.mock.calls[0]?.[0]; + const onWhisperCallback = whisperHandlers[0]; if (!onWhisperCallback) throw new Error("onWhisperCallback not found"); // Simulate Twitch whisper @@ -452,7 +479,7 @@ describe("TwitchClientManager", () => { it("should normalize channel names without # prefix", async () => { await manager.getClient(testAccount); - const onMessageCallback = mockOnMessage.mock.calls[0]?.[0]; + const onMessageCallback = messageHandlers[0]; onMessageCallback("testchannel", "testuser", "Test", { userInfo: { @@ -473,7 +500,7 @@ describe("TwitchClientManager", () => { it("should include user role flags in message", async () => { await manager.getClient(testAccount); - const onMessageCallback = mockOnMessage.mock.calls[0]?.[0]; + const onMessageCallback = messageHandlers[0]; onMessageCallback("#testchannel", "moduser", "Test", { userInfo: { @@ -497,7 +524,7 @@ describe("TwitchClientManager", () => { it("should handle broadcaster messages", async () => { await manager.getClient(testAccount); - const onMessageCallback = mockOnMessage.mock.calls[0]?.[0]; + const onMessageCallback = messageHandlers[0]; onMessageCallback("#testchannel", "broadcaster", "Test", { userInfo: { @@ -528,7 +555,7 @@ describe("TwitchClientManager", () => { await manager.getClient(testAccount2); // Simulate message for first account - const onMessage1 = mockOnMessage.mock.calls[0]?.[0]; + const onMessage1 = messageHandlers[0]; if (!onMessage1) throw new Error("onMessage1 not found"); onMessage1("#testchannel", "user1", "msg1", { userInfo: { @@ -544,7 +571,7 @@ describe("TwitchClientManager", () => { }); // Simulate message for second account - const onMessage2 = mockOnMessage.mock.calls[1]?.[0]; + const onMessage2 = messageHandlers[1]; if (!onMessage2) throw new Error("onMessage2 not found"); onMessage2("#testchannel2", "user2", "msg2", { userInfo: {