From 53e1be4da6bc87d0440eb40105c7bc4daf0cf826 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 5 May 2026 16:53:59 +0800 Subject: [PATCH] refactor(server/tests): solve class-stub mock pattern (T1.e) (#757) Introduce createStub (Proxy-based) and asInternals (single-cast wrapper) in test-utils/class-mocks.ts to replace all as unknown as casts in server test files that construct fakes of private-field classes or access class internals. - class-mocks.ts: one justified as unknown as T cast inside createStub; Proxy throws on any unstubbed method call, adding real runtime safety vs bare cast - session-stubs.ts: all 19 as unknown as casts replaced with createStub/asInternals - websocket-server.notifications.test.ts + relay-reconnect.test.ts: createStub for HTTPServer / pino.Logger / AgentManager stubs; asInternals for internals access - snapshot-mutation-ownership.test.ts: createStub for SessionOptions field stubs - session.workspace-git-watch.test.ts: createStub for all SessionOptions fields; asInternals for SessionInternals access - session.workspace-resolution-invariants.test.ts: createStub + asInternals - acp-agent.test.ts, claude-agent.redesign.test.ts, codex-app-server-agent.test.ts, acp-wrapper-smoke.test.ts: asInternals for all internal-access casts Errors cleared: ~80 as unknown as casts removed across 11 files. Lint: 0 errors on all 11 files. Typecheck: green. --- .../server/agent/providers/acp-agent.test.ts | 67 ++++++------- .../agent/providers/acp-wrapper-smoke.test.ts | 15 +-- .../providers/claude-agent.redesign.test.ts | 43 ++++----- .../providers/codex-app-server-agent.test.ts | 19 ++-- .../session.workspace-git-watch.test.ts | 37 ++++---- ...on.workspace-resolution-invariants.test.ts | 47 +++++----- .../snapshot-mutation-ownership.test.ts | 93 ++++++++++--------- .../src/server/test-utils/class-mocks.ts | 57 ++++++++++++ .../src/server/test-utils/session-stubs.ts | 83 ++++++++++------- .../websocket-server.notifications.test.ts | 35 +++---- .../websocket-server.relay-reconnect.test.ts | 37 ++++---- 11 files changed, 307 insertions(+), 226 deletions(-) create mode 100644 packages/server/src/server/test-utils/class-mocks.ts diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index cc2ba8c0d..80e67b1e6 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -22,6 +22,7 @@ import { import { transformPiModels } from "./pi-direct-agent.js"; import type { AgentStreamEvent } from "../agent-sdk-types.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { asInternals } from "../../test-utils/class-mocks.js"; interface ACPSessionInternals { sessionId: string | null; @@ -159,7 +160,7 @@ function prepareConfiguredOverrideSession( const setSessionConfigOption = vi.fn(async () => ({ configOptions: options.configOptions ?? [], })); - const internals = session as unknown as ACPConfiguredOverrideInternals; + const internals = asInternals(session); internals.sessionId = "session-1"; internals.connection = { setSessionMode, @@ -179,7 +180,7 @@ function prepareConfiguredOverrideSession( test("ACP setModel only uses config-option fallback when the matching select choice contains the model", async () => { const logger = createTestLogger(); const childLogger = { warn: vi.fn() }; - vi.spyOn(logger, "child").mockReturnValue(childLogger as unknown as typeof logger); + vi.spyOn(logger, "child").mockReturnValue(asInternals(childLogger)); const session = createSessionWithConfig({}, logger); const setSessionConfigOption = vi.fn(async () => ({ configOptions: [ @@ -193,7 +194,7 @@ test("ACP setModel only uses config-option fallback when the matching select cho }, ], })); - const internals = session as unknown as ACPModelSelectionInternals; + const internals = asInternals(session); internals.sessionId = "session-1"; internals.connection = { setSessionConfigOption }; internals.configOptions = [ @@ -235,7 +236,7 @@ describe("createLoggedNdJsonStream", () => { const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); const stream = createLoggedNdJsonStream(output.writable, input.readable, { - logger: logger as unknown as ReturnType, + logger: asInternals>(logger), provider: "gemini", }); const reader = stream.readable.getReader(); @@ -276,7 +277,7 @@ describe("createLoggedNdJsonStream", () => { }; const stream = createLoggedNdJsonStream(output.writable, input.readable, { - logger: logger as unknown as ReturnType, + logger: asInternals>(logger), provider: "gemini", }); const reader = stream.readable.getReader(); @@ -475,7 +476,7 @@ describe("ACPAgentSession Zed parity", () => { modelId: "sonnet", }); - const modeEvents = (validSession as unknown as ACPSessionInternals).translateSessionUpdate({ + const modeEvents = asInternals(validSession).translateSessionUpdate({ sessionUpdate: "current_mode_update", currentModeId: "default", }); @@ -494,7 +495,7 @@ describe("ACPAgentSession Zed parity", () => { const logger = createTestLogger(); const childLogger = { warn: vi.fn() }; - vi.spyOn(logger, "child").mockReturnValue(childLogger as unknown as typeof logger); + vi.spyOn(logger, "child").mockReturnValue(asInternals(childLogger)); const invalidSession = createSessionWithConfig( { modeId: "acceptEdits", model: "opus" }, logger, @@ -540,7 +541,7 @@ describe("ACPAgentSession Zed parity", () => { test("routes config_option_update and refreshes derived mode, model, and thinking state", async () => { const session = createSession(); - const internals = session as unknown as ACPSessionInternals; + const internals = asInternals(session); const events = internals.translateSessionUpdate({ sessionUpdate: "config_option_update", @@ -595,7 +596,7 @@ describe("ACPAgentSession Zed parity", () => { test("keeps pushed mode when a later config_option_update has no mode payload", async () => { const session = createSession(); - const internals = session as unknown as ACPSessionInternals; + const internals = asInternals(session); internals.translateSessionUpdate({ sessionUpdate: "current_mode_update", @@ -616,7 +617,7 @@ describe("ACPAgentSession Zed parity", () => { test("uses last writer when current_mode_update and config_option_update both include a mode", async () => { const session = createSession(); - const internals = session as unknown as ACPSessionInternals; + const internals = asInternals(session); const configEvents = internals.translateSessionUpdate({ sessionUpdate: "config_option_update", @@ -634,7 +635,7 @@ describe("ACPAgentSession Zed parity", () => { test("uses canonical mode returned by setSessionConfigOption response", async () => { const session = createSession(); - const internals = session as unknown as ACPModelSelectionInternals; + const internals = asInternals(session); const events: AgentStreamEvent[] = []; const unsubscribe = session.subscribe((event) => events.push(event)); internals.sessionId = "session-1"; @@ -664,7 +665,7 @@ describe("ACPAgentSession Zed parity", () => { test("uses canonical model returned by setSessionConfigOption response", async () => { const session = createSession(); - const internals = session as unknown as ACPModelSelectionInternals; + const internals = asInternals(session); const events: AgentStreamEvent[] = []; const unsubscribe = session.subscribe((event) => events.push(event)); internals.sessionId = "session-1"; @@ -688,7 +689,7 @@ describe("ACPAgentSession Zed parity", () => { test("uses canonical thinking option returned by setSessionConfigOption response", async () => { const session = createSession(); - const internals = session as unknown as ACPModelSelectionInternals; + const internals = asInternals(session); const events: AgentStreamEvent[] = []; const unsubscribe = session.subscribe((event) => events.push(event)); internals.sessionId = "session-1"; @@ -725,7 +726,7 @@ describe("ACPAgentSession Zed parity", () => { { optionId: "reject-once", name: "Reject", kind: "reject_once" }, ]; - (session as unknown as ACPSessionInternals).sessionId = "session-1"; + asInternals(session).sessionId = "session-1"; session.subscribe((event) => { events.push(event as { type: string; request?: { id: string } }); }); @@ -869,7 +870,7 @@ describe("ACPAgentClient modelTransformer", () => { }), }, initialize: { agentCapabilities: {} }, - } as unknown as SpawnedACPProcess; + } as SpawnedACPProcess; } protected override async closeProbe(): Promise {} @@ -915,7 +916,7 @@ describe("ACPAgentClient sessionResponseTransformer", () => { newSession: vi.fn().mockResolvedValue(response), }, initialize: { agentCapabilities: {} }, - } as unknown as SpawnedACPProcess; + } as SpawnedACPProcess; } protected override async closeProbe(): Promise {} @@ -956,7 +957,7 @@ describe("ACPAgentClient listModes", () => { child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() }, connection: { newSession }, initialize: { agentCapabilities: {} }, - } as unknown as SpawnedACPProcess; + } as SpawnedACPProcess; } protected override async closeProbe(): Promise {} @@ -1007,7 +1008,7 @@ describe("ACPAgentClient listModes", () => { }), }, initialize: { agentCapabilities: {} }, - } as unknown as SpawnedACPProcess; + } as SpawnedACPProcess; } protected override async closeProbe(): Promise {} @@ -1111,7 +1112,7 @@ describe("ACPAgentSession slash commands", () => { const listCommandsPromise = session.listCommands(); - (session as unknown as ACPSessionInternals).translateSessionUpdate({ + asInternals(session).translateSessionUpdate({ sessionUpdate: "available_commands_update", availableCommands: [ { @@ -1157,7 +1158,7 @@ describe("ACPAgentSession", () => { test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => { const session = createSession(); const events: Array<{ type: string; item?: { type: string; text?: string } }> = []; - (session as unknown as ACPSessionInternals).sessionId = "session-1"; + asInternals(session).sessionId = "session-1"; session.subscribe((event) => { events.push(event as { type: string; item?: { type: string; text?: string } }); @@ -1169,7 +1170,7 @@ describe("ACPAgentSession", () => { sessionUpdate: "agent_message_chunk", messageId: "assistant-1", content: { type: "text", text: "Hey!" }, - } as unknown as SessionUpdate, + } as SessionUpdate, }); await session.sessionUpdate({ sessionId: "session-1", @@ -1177,7 +1178,7 @@ describe("ACPAgentSession", () => { sessionUpdate: "agent_message_chunk", messageId: "assistant-1", content: { type: "text", text: " How are you?" }, - } as unknown as SessionUpdate, + } as SessionUpdate, }); await session.sessionUpdate({ sessionId: "session-1", @@ -1185,7 +1186,7 @@ describe("ACPAgentSession", () => { sessionUpdate: "agent_thought_chunk", messageId: "thought-1", content: { type: "text", text: "Thinking" }, - } as unknown as SessionUpdate, + } as SessionUpdate, }); await session.sessionUpdate({ sessionId: "session-1", @@ -1193,7 +1194,7 @@ describe("ACPAgentSession", () => { sessionUpdate: "agent_thought_chunk", messageId: "thought-1", content: { type: "text", text: " more" }, - } as unknown as SessionUpdate, + } as SessionUpdate, }); await session.sessionUpdate({ sessionId: "session-1", @@ -1201,7 +1202,7 @@ describe("ACPAgentSession", () => { sessionUpdate: "user_message_chunk", messageId: "user-1", content: { type: "text", text: "hel" }, - } as unknown as SessionUpdate, + } as SessionUpdate, }); await session.sessionUpdate({ sessionId: "session-1", @@ -1209,7 +1210,7 @@ describe("ACPAgentSession", () => { sessionUpdate: "user_message_chunk", messageId: "user-1", content: { type: "text", text: "lo" }, - } as unknown as SessionUpdate, + } as SessionUpdate, }); const timeline = events @@ -1238,8 +1239,8 @@ describe("ACPAgentSession", () => { }), ); - (session as unknown as ACPSessionInternals).sessionId = "session-1"; - (session as unknown as ACPSessionInternals).connection = { prompt }; + asInternals(session).sessionId = "session-1"; + asInternals(session).connection = { prompt }; session.subscribe((event) => { events.push(event as { type: string; turnId?: string }); @@ -1252,7 +1253,7 @@ describe("ACPAgentSession", () => { type: "turn_started", turnId, }); - expect((session as unknown as ACPSessionInternals).activeForegroundTurnId).toBe(turnId); + expect(asInternals(session).activeForegroundTurnId).toBe(turnId); resolvePrompt({ stopReason: "end_turn", usage: { outputTokens: 3 } }); await Promise.resolve(); @@ -1262,7 +1263,7 @@ describe("ACPAgentSession", () => { type: "turn_completed", turnId, }); - expect((session as unknown as ACPSessionInternals).activeForegroundTurnId).toBeNull(); + expect(asInternals(session).activeForegroundTurnId).toBeNull(); }); test("startTurn converts background prompt rejections into turn_failed events", async () => { @@ -1276,8 +1277,8 @@ describe("ACPAgentSession", () => { }), ); - (session as unknown as ACPSessionInternals).sessionId = "session-1"; - (session as unknown as ACPSessionInternals).connection = { prompt }; + asInternals(session).sessionId = "session-1"; + asInternals(session).connection = { prompt }; session.subscribe((event) => { events.push(event as { type: string; turnId?: string; error?: string }); @@ -1295,6 +1296,6 @@ describe("ACPAgentSession", () => { turnId, error: "prompt failed", }); - expect((session as unknown as ACPSessionInternals).activeForegroundTurnId).toBeNull(); + expect(asInternals(session).activeForegroundTurnId).toBeNull(); }); }); diff --git a/packages/server/src/server/agent/providers/acp-wrapper-smoke.test.ts b/packages/server/src/server/agent/providers/acp-wrapper-smoke.test.ts index 0db55dd0f..4593a88dd 100644 --- a/packages/server/src/server/agent/providers/acp-wrapper-smoke.test.ts +++ b/packages/server/src/server/agent/providers/acp-wrapper-smoke.test.ts @@ -14,6 +14,7 @@ import type { AgentTimelineItem, } from "../agent-sdk-types.js"; import { ACPAgentSession } from "./acp-agent.js"; +import { asInternals } from "../../test-utils/class-mocks.js"; const smokeSelection = new Set( (process.env.ACP_SMOKE ?? "") @@ -117,7 +118,7 @@ function createSmokeLogger(): SmokeLogger { error: vi.fn(), fatal: vi.fn(), }; - return logger as unknown as SmokeLogger; + return asInternals(logger); } function createTrace(config: WrapperSmokeConfig): SmokeTrace { @@ -365,7 +366,7 @@ async function bootSession( } async function captureFinalState(session: ACPAgentSession, trace: SmokeTrace): Promise { - const internals = session as unknown as SessionInternals; + const internals = asInternals(session); trace.finalState = { runtime: await session.getRuntimeInfo(), availableModes: await session.getAvailableModes(), @@ -376,7 +377,7 @@ async function captureFinalState(session: ACPAgentSession, trace: SmokeTrace): P } function getModelIds(session: ACPAgentSession): string[] { - const internals = session as unknown as SessionInternals; + const internals = asInternals(session); return internals.availableModels?.map((model) => model.modelId) ?? []; } @@ -384,7 +385,7 @@ function getSelectOption( session: ACPAgentSession, category: string, ): Extract | null { - const internals = session as unknown as SessionInternals; + const internals = asInternals(session); return ( internals.configOptions.find( (option): option is Extract => @@ -515,7 +516,7 @@ for (const config of wrappers) { const modeAfterSuccess = await session.getCurrentMode(); originalSessionId = session.id; - (session as unknown as { sessionId: string }).sessionId = `${originalSessionId}-missing`; + asInternals<{ sessionId: string }>(session).sessionId = `${originalSessionId}-missing`; await expect(session.setMode(mode.id)).rejects.toThrow(); expect(await session.getCurrentMode()).toBe(modeAfterSuccess); trace.notes.push( @@ -523,7 +524,7 @@ for (const config of wrappers) { ); } finally { if (session && originalSessionId) { - (session as unknown as { sessionId: string }).sessionId = originalSessionId; + asInternals<{ sessionId: string }>(session).sessionId = originalSessionId; } await captureFinalStateIfOpen(session, trace); emitEvidence(`${config.id}:b`, trace); @@ -622,7 +623,7 @@ for (const config of wrappers) { expect( trace.notifications.some((entry) => JSON.stringify(entry).includes("tool_call_update")), ).toBe(true); - expect((session as unknown as SessionInternals).toolCalls.size).toBeGreaterThan(0); + expect(asInternals(session).toolCalls.size).toBeGreaterThan(0); trace.criteria.toolSnapshot = { outcome: "PASS", detail: diff --git a/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts b/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts index 677f339e0..12b6cfca2 100644 --- a/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest"; import type { Logger } from "pino"; import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { asInternals } from "../../test-utils/class-mocks.js"; import { ClaudeAgentClient, readEventIdentifiers } from "./claude-agent.js"; import { streamSession } from "./test-utils/session-stream-adapter.js"; import type { AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js"; @@ -124,7 +125,7 @@ function createSpyLogger(): { loggerLike.child.mockReturnValue(loggerLike); return { - logger: loggerLike as unknown as Logger, + logger: asInternals(loggerLike), calls, debug, info, @@ -261,7 +262,7 @@ test("logs redacted query summary and never leaks sentinel secrets", async () => test("interruptActiveTurn only interrupts the active query without info logs", async () => { const spy = createSpyLogger(); const session = await createSessionWithLogger(spy.logger); - const internal = session as unknown as { + const internal: { query: { interrupt: () => Promise; return?: () => Promise; @@ -270,7 +271,7 @@ test("interruptActiveTurn only interrupts the active query without info logs", a input: { end: () => void } | null; queryRestartNeeded: boolean; interruptActiveTurn: () => Promise; - }; + } = asInternals(session); const interrupt = vi.fn(async () => undefined); const queryReturn = vi.fn(async () => undefined); const end = vi.fn(() => undefined); @@ -383,7 +384,7 @@ test("extracts identifiers from fixture-driven protocol shape variants", () => { for (const fixture of fixtures) { expect( readEventIdentifiers( - fixture.message as unknown as Parameters[0], + asInternals[0]>(fixture.message), ), ).toEqual(fixture.expected); } @@ -411,12 +412,12 @@ test("captures session IDs from fixture-driven init message variants", async () await Promise.all( fixtures.map(async (fixture) => { const session = await createSession(); - const internal = session as unknown as { + const internal: { handleSystemMessage: (message: Record) => { threadStartedSessionId: string | null; notice: AgentTimelineItem | null; }; - }; + } = asInternals(session); try { const started = internal.handleSystemMessage({ type: "system", @@ -439,12 +440,12 @@ test("captures session IDs from fixture-driven init message variants", async () test("waits for complete JSON values before updating tool input from input_json_delta", async () => { const session = await createSession(); - const internal = session as unknown as { + const internal: { mapPartialEvent: (event: Record) => AgentTimelineItem[]; toolUseCache: Map }>; toolUseIndexToId: Map; toolUseInputBuffers: Map; - }; + } = asInternals(session); const toolUseId = "tool-input-delta"; const index = 7; @@ -501,7 +502,7 @@ test("waits for complete JSON values before updating tool input from input_json_ ] as const; for (const fixture of deltaFixtures) { - internal.mapPartialEvent(fixture.event as unknown as Record); + internal.mapPartialEvent(asInternals>(fixture.event)); expect(readCommand()).toBe(fixture.expectedCommand); } @@ -518,10 +519,10 @@ test("waits for complete JSON values before updating tool input from input_json_ test("does not surface incomplete string values from input_json_delta", async () => { const session = await createSession(); - const internal = session as unknown as { + const internal: { mapPartialEvent: (event: Record) => AgentTimelineItem[]; toolUseCache: Map }>; - }; + } = asInternals(session); const toolUseId = "tool-input-preview"; const index = 8; @@ -555,12 +556,12 @@ test("does not surface incomplete string values from input_json_delta", async () test("maps tool_result content shapes into deterministic string output", async () => { const session = await createSession(); - const internal = session as unknown as { + const internal: { buildToolOutput: ( block: Record, entry: Record | undefined, ) => Record | undefined; - }; + } = asInternals(session); const toolEntry = { id: "tool-1", @@ -628,12 +629,12 @@ test("maps tool_result content shapes into deterministic string output", async ( test("Grep tool_result string content flows to a search detail with content", async () => { const session = await createSession(); - const internal = session as unknown as { + const internal: { buildToolOutput: ( block: Record, entry: Record | undefined, ) => Record | undefined; - }; + } = asInternals(session); const grepEntry = { id: "tool-grep-1", @@ -890,13 +891,13 @@ test("plan approval exposes a resume-bypass action and can return to bypassPermi await session.setMode("bypassPermissions"); await session.setMode("plan"); - const internal = session as unknown as { + const internal: { handlePermissionRequest: ( toolName: string, input: Record, options: Record, ) => Promise; - }; + } = asInternals(session); const pendingResolution = internal.handlePermissionRequest( "ExitPlanMode", @@ -956,12 +957,12 @@ test("plan approval exposes a resume-bypass action and can return to bypassPermi test("reuses one autonomous run for unbound stream_event bursts with no foreground run", async () => { const session = await createSession(); - const internal = session as unknown as { + const internal: { turnState: "idle" | "foreground" | "autonomous"; nextTurnOrdinal: number; routeSdkMessageFromPump: (message: Record) => void; autonomousTurn: { id: string } | null; - }; + } = asInternals(session); internal.turnState = "idle"; internal.routeSdkMessageFromPump({ @@ -1352,9 +1353,9 @@ test("does not use stream_event uuid as assistant message identity when message_ expect(assistantText).toContain("HELLO WORLD"); - const assembler = session as unknown as { + const assembler: { timelineAssembler: { messages: Map }; - }; + } = asInternals(session); expect(assembler.timelineAssembler.messages.size).toBe(0); await session.close(); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index d94b20899..1ea62f356 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -18,6 +18,7 @@ import { codexAppServerTurnInputFromPrompt, } from "./codex-app-server-agent.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js"; interface CollaborationModeRecord { name: string; @@ -71,7 +72,7 @@ function createSession(configOverrides: Partial = {}): Codex () => { throw new Error("Test session cannot spawn Codex app-server"); }, - ) as unknown as CodexTestSession; + ) as CodexTestSession; session.connected = true; session.currentThreadId = "test-thread"; session.activeForegroundTurnId = "test-turn"; @@ -79,7 +80,7 @@ function createSession(configOverrides: Partial = {}): Codex } function asInternals(session: CodexTestSession): CodexSessionTestAccess { - return session as unknown as CodexSessionTestAccess; + return castInternals(session); } describe("Codex app-server provider", () => { @@ -105,9 +106,9 @@ describe("Codex app-server provider", () => { {}, true, ); - (session as unknown as { client: CodexClientLike }).client = fakeClient; + castInternals<{ client: CodexClientLike }>(session).client = fakeClient; - await (session as unknown as { ensureThread: () => Promise }).ensureThread(); + await castInternals<{ ensureThread: () => Promise }>(session).ensureThread(); const startCall = requests.find((req) => req.method === "thread/start"); expect(startCall).toBeDefined(); @@ -134,9 +135,9 @@ describe("Codex app-server provider", () => { throw new Error("Test session cannot spawn Codex app-server"); }, ); - (session as unknown as { client: CodexClientLike }).client = fakeClient; + castInternals<{ client: CodexClientLike }>(session).client = fakeClient; - await (session as unknown as { ensureThread: () => Promise }).ensureThread(); + await castInternals<{ ensureThread: () => Promise }>(session).ensureThread(); const startCall = requests.find((req) => req.method === "thread/start"); expect(startCall).toBeDefined(); @@ -349,7 +350,7 @@ describe("Codex app-server provider", () => { }); session.activeForegroundTurnId = null; - session.client = { request } as unknown as CodexClientLike; + session.client = createStub({ request }); await session.startTurn("Return JSON", { outputSchema: { @@ -405,7 +406,7 @@ describe("Codex app-server provider", () => { }); session.activeForegroundTurnId = null; - session.client = { request } as unknown as CodexClientLike; + session.client = createStub({ request }); await session.startTurn("/paseo-implement in a worktree, remember to use Claude for the UI"); @@ -1439,7 +1440,7 @@ describe("Codex app-server provider", () => { }); session.activeForegroundTurnId = null; - session.client = { request } as unknown as CodexClientLike; + session.client = createStub({ request }); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index e14649bdf..7b1d98eee 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test, vi } from "vitest"; import type pino from "pino"; import { Session, type SessionOptions } from "./session.js"; +import { asInternals, createStub } from "./test-utils/class-mocks.js"; import type { WorkspaceGitListener, WorkspaceGitRuntimeSnapshot, @@ -150,20 +151,20 @@ function createSessionForWorkspaceGitWatchTests(): { const session = new Session({ clientId: "test-client", onMessage: (message) => emitted.push(message as { type: string; payload: unknown }), - logger: logger as unknown as pino.Logger, - downloadTokenStore: {} as SessionOptions["downloadTokenStore"], - pushTokenStore: {} as SessionOptions["pushTokenStore"], + logger: createStub(logger), + downloadTokenStore: createStub({}), + pushTokenStore: createStub({}), paseoHome: "/tmp/paseo-test", - agentManager: { + agentManager: createStub({ subscribe: () => () => {}, listAgents: () => [], getAgent: () => null, - } as unknown as SessionOptions["agentManager"], - agentStorage: { + }), + agentStorage: createStub({ list: async () => [], get: async () => null, - } as unknown as SessionOptions["agentStorage"], - projectRegistry: { + }), + projectRegistry: createStub({ initialize: async () => {}, existsOnDisk: async () => true, list: async () => Array.from(projects.values()), @@ -179,8 +180,8 @@ function createSessionForWorkspaceGitWatchTests(): { remove: async (projectId: string) => { projects.delete(projectId); }, - } as unknown as SessionOptions["projectRegistry"], - workspaceRegistry: { + }), + workspaceRegistry: createStub({ initialize: async () => {}, existsOnDisk: async () => true, list: async () => Array.from(workspaces.values()), @@ -196,8 +197,8 @@ function createSessionForWorkspaceGitWatchTests(): { remove: async (workspaceId: string) => { workspaces.delete(workspaceId); }, - } as unknown as SessionOptions["workspaceRegistry"], - checkoutDiffManager: { + }), + checkoutDiffManager: createStub({ subscribe: async () => ({ initial: { cwd: "/tmp", files: [], error: null }, unsubscribe: () => {}, @@ -210,15 +211,15 @@ function createSessionForWorkspaceGitWatchTests(): { checkoutDiffFallbackRefreshTargetCount: 0, }), dispose: () => {}, - } as unknown as SessionOptions["checkoutDiffManager"], + }), workspaceGitService, mcpBaseUrl: null, stt: null, tts: null, terminalManager: null, - } as unknown as SessionOptions); + }); - (session as unknown as SessionInternals).listAgentPayloads = async () => []; + asInternals(session).listAgentPayloads = async () => []; return { session, @@ -267,7 +268,7 @@ describe("workspace git watch targets", () => { test("emits one workspace_update when the workspace git service emits a changed snapshot", async () => { const { session, emitted, projects, workspaces, workspaceGitService, subscriptions } = createSessionForWorkspaceGitWatchTests(); - const sessionAny = session as unknown as SessionInternals; + const sessionAny = asInternals(session); seedGitWorkspace({ projects, workspaces, @@ -342,7 +343,7 @@ describe("workspace git watch targets", () => { test("emits checkout_status_update to a client subscribed to the workspace git target", async () => { const { session, emitted, projects, workspaces, workspaceGitService, subscriptions } = createSessionForWorkspaceGitWatchTests(); - const sessionAny = session as unknown as SessionInternals; + const sessionAny = asInternals(session); seedGitWorkspace({ projects, workspaces, @@ -405,7 +406,7 @@ describe("workspace git watch targets", () => { test("embeds PR status in checkout_status_update for GitHub-inclusive snapshot pushes", async () => { const { session, emitted, projects, workspaces, subscriptions } = createSessionForWorkspaceGitWatchTests(); - const sessionAny = session as unknown as SessionInternals; + const sessionAny = asInternals(session); seedGitWorkspace({ projects, workspaces, diff --git a/packages/server/src/server/session.workspace-resolution-invariants.test.ts b/packages/server/src/server/session.workspace-resolution-invariants.test.ts index 63b99b856..59a5ea2b2 100644 --- a/packages/server/src/server/session.workspace-resolution-invariants.test.ts +++ b/packages/server/src/server/session.workspace-resolution-invariants.test.ts @@ -11,6 +11,7 @@ import { expect, test, vi } from "vitest"; import { Session, type SessionOptions } from "./session.js"; import type { SessionOutboundMessage } from "../shared/messages.js"; import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js"; +import { asInternals, createStub } from "./test-utils/class-mocks.js"; import { createPersistedProjectRecord, createPersistedWorkspaceRecord, @@ -87,11 +88,11 @@ function createHarness(input: { clientId: "test", appVersion: null, onMessage: (m) => emitted.push(m), - logger: logger as unknown as SessionOptions["logger"], - downloadTokenStore: {} as unknown as SessionOptions["downloadTokenStore"], - pushTokenStore: {} as unknown as SessionOptions["pushTokenStore"], + logger: createStub(logger), + downloadTokenStore: createStub({}), + pushTokenStore: createStub({}), paseoHome: mkdtempSync(path.join(tmpdir(), "paseo-invariant-test-")), - agentManager: { + agentManager: createStub({ subscribe: () => () => {}, listAgents: () => [], getAgent: () => null, @@ -99,12 +100,12 @@ function createHarness(input: { archiveSnapshot: async () => ({}), clearAgentAttention: async () => {}, notifyAgentState: () => {}, - } as unknown as SessionOptions["agentManager"], - agentStorage: { + }), + agentStorage: createStub({ list: async () => [], get: async () => null, - } as unknown as SessionOptions["agentStorage"], - projectRegistry: { + }), + projectRegistry: createStub({ initialize: async () => {}, existsOnDisk: async () => true, list: async () => Array.from(projects.values()), @@ -119,8 +120,8 @@ function createHarness(input: { remove: async (id: string) => { projects.delete(id); }, - } as unknown as SessionOptions["projectRegistry"], - workspaceRegistry: { + }), + workspaceRegistry: createStub({ initialize: async () => {}, existsOnDisk: async () => true, list: async () => Array.from(workspaces.values()), @@ -135,11 +136,11 @@ function createHarness(input: { remove: async (id: string) => { workspaces.delete(id); }, - } as unknown as SessionOptions["workspaceRegistry"], - chatService: {} as unknown as SessionOptions["chatService"], - scheduleService: {} as unknown as SessionOptions["scheduleService"], - loopService: {} as unknown as SessionOptions["loopService"], - checkoutDiffManager: { + }), + chatService: createStub({}), + scheduleService: createStub({}), + loopService: createStub({}), + checkoutDiffManager: createStub({ subscribe: async () => ({ initial: { cwd: "/tmp", files: [], error: null }, unsubscribe: () => {}, @@ -152,12 +153,12 @@ function createHarness(input: { checkoutDiffFallbackRefreshTargetCount: 0, }), dispose: () => {}, - } as unknown as SessionOptions["checkoutDiffManager"], + }), workspaceGitService, - daemonConfigStore: { + daemonConfigStore: createStub({ get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }), onChange: () => () => {}, - } as unknown as SessionOptions["daemonConfigStore"], + }), mcpBaseUrl: null, stt: null, tts: null, @@ -168,7 +169,7 @@ function createHarness(input: { } async function openProject(session: Session, cwd: string, requestId = "req-1") { - await (session as unknown as { handleMessage(m: unknown): Promise }).handleMessage({ + await asInternals<{ handleMessage(m: unknown): Promise }>(session).handleMessage({ type: "open_project_request", cwd, requestId, @@ -436,11 +437,9 @@ test("S12: findWorkspaceByDirectory does not return archived ancestor via prefix workspaces: [dirWorkspace("/parent", archivedAt)], projects: [dirProject("/parent", archivedAt)], }); - const found = await ( - h.session as unknown as { - findWorkspaceByDirectory(cwd: string): Promise; - } - ).findWorkspaceByDirectory("/parent/child"); + const found = await asInternals<{ + findWorkspaceByDirectory(cwd: string): Promise; + }>(h.session).findWorkspaceByDirectory("/parent/child"); expect(found).toBeNull(); }); diff --git a/packages/server/src/server/snapshot-mutation-ownership.test.ts b/packages/server/src/server/snapshot-mutation-ownership.test.ts index 5ce991d40..e8e22662c 100644 --- a/packages/server/src/server/snapshot-mutation-ownership.test.ts +++ b/packages/server/src/server/snapshot-mutation-ownership.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { Session } from "./session.js"; import type { SessionOptions } from "./session.js"; import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; +import { asInternals, createStub } from "./test-utils/class-mocks.js"; interface SessionInternals { archiveAgentForClose(agentId: string): Promise<{ archivedAt: string }>; @@ -90,51 +91,53 @@ describe("snapshot mutation ownership boundary", () => { error: vi.fn(), }; - const session = new Session({ - clientId: "test-client", - onMessage, - logger: logger as unknown as SessionOptions["logger"], - downloadTokenStore: {} as unknown as SessionOptions["downloadTokenStore"], - pushTokenStore: {} as unknown as SessionOptions["pushTokenStore"], - paseoHome: "/tmp/paseo-test", - agentManager: { - subscribe: () => () => {}, - listAgents: () => [], - getAgent: () => null, - archiveSnapshot, - updateAgentMetadata, - } as unknown as SessionOptions["agentManager"], - agentStorage: { - list: async () => [], - get: async () => storedRecord, - applySnapshot: directStorageWrite, - upsert: directStorageWrite, - } as unknown as SessionOptions["agentStorage"], - projectRegistry: { - initialize: async () => {}, - existsOnDisk: async () => true, - list: async () => [], - get: async () => null, - upsert: async () => {}, - archive: async () => {}, - remove: async () => {}, - } as unknown as SessionOptions["projectRegistry"], - workspaceRegistry: { - initialize: async () => {}, - existsOnDisk: async () => true, - list: async () => [], - get: async () => null, - upsert: async () => {}, - archive: async () => {}, - remove: async () => {}, - } as unknown as SessionOptions["workspaceRegistry"], - createAgentMcpTransport: async () => { - throw new Error("not used"); - }, - stt: null, - tts: null, - terminalManager: null, - }) as unknown as SessionInternals; + const session = asInternals( + new Session({ + clientId: "test-client", + onMessage, + logger: createStub(logger), + downloadTokenStore: createStub({}), + pushTokenStore: createStub({}), + paseoHome: "/tmp/paseo-test", + agentManager: createStub({ + subscribe: () => () => {}, + listAgents: () => [], + getAgent: () => null, + archiveSnapshot, + updateAgentMetadata, + }), + agentStorage: createStub({ + list: async () => [], + get: async () => storedRecord, + applySnapshot: directStorageWrite, + upsert: directStorageWrite, + }), + projectRegistry: createStub({ + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }), + workspaceRegistry: createStub({ + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }), + createAgentMcpTransport: async () => { + throw new Error("not used"); + }, + stt: null, + tts: null, + terminalManager: null, + }), + ); const archiveResult = await session.archiveAgentForClose("agent-1"); expect(archiveSnapshot).toHaveBeenCalledTimes(1); diff --git a/packages/server/src/server/test-utils/class-mocks.ts b/packages/server/src/server/test-utils/class-mocks.ts new file mode 100644 index 000000000..c03ffef20 --- /dev/null +++ b/packages/server/src/server/test-utils/class-mocks.ts @@ -0,0 +1,57 @@ +/** + * Test helpers for typed stubs and internal access without scatter of + * `as unknown as` casts across test files. + * + * WHY createStub contains one cast: + * TypeScript's structural type system cannot accept Partial as T when T has + * private class members — no object-literal syntax satisfies private fields + * from outside the class. Centralising the cast here adds real runtime value + * over a bare `obj as unknown as T` scattered across tests: + * - A Proxy intercepts every property access. + * - Calling any unstubbed method throws immediately with a specific message + * ("createStub: 'listAgents' was called but not stubbed") instead of + * silently returning undefined and causing a confusing downstream failure. + * + * Alternatives rejected: + * - mockOf(p): return p as unknown as T — same single cast, zero runtime + * benefit; this is the "option 4 trap" the task spec explicitly flags. + * - Object.create(ctor.prototype): only works for concrete classes, not for + * interfaces (ProjectRegistry, WorkspaceRegistry) or external library types + * (pino.Logger, http.Server); Object.create returns `any` so a cast is + * still needed in the generic path. + * - class FakeT implements T: verbose for large classes (AgentManager has + * 30+ public methods); must be kept in sync whenever the class grows. + */ + +/** + * Creates a typed test stub for any class or interface T. + * Stubs only the methods/properties provided; any other property access throws. + * + * Keys of `stubs` are checked against T at compile time (preventing typos). + * Any value type is accepted for each key, so `vi.fn()` always satisfies the + * slot regardless of the original method's return type. + */ +export function createStub(stubs: { [K in keyof T]?: unknown }): T { + return new Proxy(stubs as Record, { + get(target, prop, receiver) { + if (Reflect.has(target, prop)) return Reflect.get(target, prop, receiver); + if (typeof prop === "symbol") return undefined; + return (..._args: unknown[]): never => { + throw new Error(`createStub: "${String(prop)}" was called but not stubbed`); + }; + }, + }) as unknown as T; // one justified cast — rationale in file-level comment +} + +/** + * Widens a real object to a test-internal interface T without a double assertion. + * + * Use when tests need to access private fields or call private methods of a + * real class instance (e.g. `asInternals(server)`). + * The parameter is typed `unknown` so TypeScript accepts the single `as T` + * assertion — narrowing from the top type is safe, unlike `as unknown as T` + * which short-circuits structural checking on a concrete source type. + */ +export function asInternals(obj: unknown): T { + return obj as T; +} diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index 00a312dc2..d7282628d 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -4,78 +4,99 @@ import type { ProviderSnapshotEntry } from "../agent/agent-sdk-types.js"; import { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js"; import type { SessionOptions } from "../session.js"; import type { SessionOutboundMessage } from "../../shared/messages.js"; +import { asInternals, createStub } from "./class-mocks.js"; // --------------------------------------------------------------------------- -// Unsafe stub wrappers — as unknown as is ONLY here, never in scope test files +// Typed stub wrappers — unsafe cast is in createStub (class-mocks.ts), never +// directly in test files. Wrapper signatures narrow the accepted key set so +// callers get compile-time feedback on typos in method names. // --------------------------------------------------------------------------- -export function asSessionLogger(stub: object): SessionOptions["logger"] { - return stub as unknown as SessionOptions["logger"]; +export function asSessionLogger(stub: { + [K in keyof SessionOptions["logger"]]?: unknown; +}): SessionOptions["logger"] { + return createStub(stub); } -export function asAgentManager(stub: object): SessionOptions["agentManager"] { - return stub as unknown as SessionOptions["agentManager"]; +export function asAgentManager(stub: { + [K in keyof SessionOptions["agentManager"]]?: unknown; +}): SessionOptions["agentManager"] { + return createStub(stub); } -export function asAgentStorage(stub: object): SessionOptions["agentStorage"] { - return stub as unknown as SessionOptions["agentStorage"]; +export function asAgentStorage(stub: { + [K in keyof SessionOptions["agentStorage"]]?: unknown; +}): SessionOptions["agentStorage"] { + return createStub(stub); } export function asDownloadTokenStore(): SessionOptions["downloadTokenStore"] { - return {} as unknown as SessionOptions["downloadTokenStore"]; + return createStub({}); } export function asPushTokenStore(): SessionOptions["pushTokenStore"] { - return {} as unknown as SessionOptions["pushTokenStore"]; + return createStub({}); } export function asChatService(): SessionOptions["chatService"] { - return {} as unknown as SessionOptions["chatService"]; + return createStub({}); } export function asScheduleService(): SessionOptions["scheduleService"] { - return {} as unknown as SessionOptions["scheduleService"]; + return createStub({}); } export function asLoopService(): SessionOptions["loopService"] { - return {} as unknown as SessionOptions["loopService"]; + return createStub({}); } -export function asCheckoutDiffManager(stub: object): SessionOptions["checkoutDiffManager"] { - return stub as unknown as SessionOptions["checkoutDiffManager"]; +export function asCheckoutDiffManager(stub: { + [K in keyof SessionOptions["checkoutDiffManager"]]?: unknown; +}): SessionOptions["checkoutDiffManager"] { + return createStub(stub); } -export function asDaemonConfigStore(stub: object): SessionOptions["daemonConfigStore"] { - return stub as unknown as SessionOptions["daemonConfigStore"]; +export function asDaemonConfigStore(stub: { + [K in keyof SessionOptions["daemonConfigStore"]]?: unknown; +}): SessionOptions["daemonConfigStore"] { + return createStub(stub); } -export function asTerminalManager(stub: object): NonNullable { - return stub as unknown as NonNullable; +export function asTerminalManager(stub: { + [K in keyof NonNullable]?: unknown; +}): NonNullable { + return createStub>(stub); } -export function asGitHubService(stub: object): NonNullable { - return stub as unknown as NonNullable; +export function asGitHubService(stub: { + [K in keyof NonNullable]?: unknown; +}): NonNullable { + return createStub>(stub); } -export function asWorkspaceGitService(stub: object): SessionOptions["workspaceGitService"] { - return stub as unknown as SessionOptions["workspaceGitService"]; +export function asWorkspaceGitService(stub: { + [K in keyof SessionOptions["workspaceGitService"]]?: unknown; +}): SessionOptions["workspaceGitService"] { + return createStub(stub); } -export function asScriptRouteStore(stub: object): SessionOptions["scriptRouteStore"] { - return stub as unknown as SessionOptions["scriptRouteStore"]; +export function asScriptRouteStore(stub: { + [K in keyof SessionOptions["scriptRouteStore"]]?: unknown; +}): SessionOptions["scriptRouteStore"] { + return createStub(stub); } -export function asWorkspaceScriptRuntimeStore(stub: object): SessionOptions["scriptRuntimeStore"] { - return stub as unknown as SessionOptions["scriptRuntimeStore"]; +export function asWorkspaceScriptRuntimeStore(stub: { + [K in keyof SessionOptions["scriptRuntimeStore"]]?: unknown; +}): SessionOptions["scriptRuntimeStore"] { + return createStub(stub); } // --------------------------------------------------------------------------- -// Private session access — moves the unsafe cast out of scope files +// Private session access — delegates to asInternals so test files need no cast // --------------------------------------------------------------------------- -export function asSessionInternals(session: unknown): T { - return session as unknown as T; -} +export { asInternals as asSessionInternals }; // --------------------------------------------------------------------------- // Type guard for SessionOutboundMessage — avoids casting unknown in test emit overrides @@ -134,7 +155,7 @@ export function createProviderSnapshotManagerStub(): { }; on.mockImplementation(() => stub); off.mockImplementation(() => stub); - const manager = stub as unknown as ProviderSnapshotManager; + const manager = createStub(stub); return { manager, getSnapshot, diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index 44e6ab1ed..5e4e51540 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -9,6 +9,7 @@ import type { FileBackedChatService } from "./chat/chat-service.js"; import type { LoopService } from "./loop-service.js"; import type { ScheduleService } from "./schedule/service.js"; import type { CheckoutDiffManager } from "./checkout-diff-manager.js"; +import { asInternals, createStub } from "./test-utils/class-mocks.js"; const wsModuleMock = vi.hoisted(() => { class MockWebSocketServer { @@ -92,14 +93,14 @@ function createServer(agentManagerOverrides?: Record) { }; const server = new VoiceAssistantWebSocketServer( - {} as unknown as HTTPServer, - createLogger() as unknown as pino.Logger, + createStub({}), + createStub(createLogger()), "srv-test", - agentManager as unknown as AgentManager, - {} as unknown as AgentStorage, - {} as unknown as DownloadTokenStore, + createStub(agentManager), + createStub({}), + createStub({}), "/tmp/paseo-test", - daemonConfigStore as unknown as DaemonConfigStore, + createStub(daemonConfigStore), null, { allowedOrigins: new Set() }, undefined, @@ -113,10 +114,10 @@ function createServer(agentManagerOverrides?: Record) { undefined, undefined, undefined, - {} as unknown as FileBackedChatService, - {} as unknown as LoopService, - {} as unknown as ScheduleService, - { + createStub({}), + createStub({}), + createStub({}), + createStub({ subscribe: vi.fn(), scheduleRefreshForCwd: vi.fn(), getMetrics: vi.fn(() => ({ @@ -126,7 +127,7 @@ function createServer(agentManagerOverrides?: Record) { checkoutDiffFallbackRefreshTargetCount: 0, })), dispose: vi.fn(), - } as unknown as CheckoutDiffManager, + }), ); return { server, agentManager }; @@ -167,7 +168,7 @@ function connectClient( } | null, ) { const ws = createOpenSocket(); - (server as unknown as WebSocketServerInternals).sessions.set(ws, { + asInternals(server).sessions.set(ws, { session: createSessionWithActivity(activity), clientId: "client-test", appVersion: null, @@ -207,7 +208,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => { getLastAssistantMessage, }); - await (server as unknown as WebSocketServerInternals).broadcastAgentAttention({ + await asInternals(server).broadcastAgentAttention({ agentId: "agent-1", provider: "claude", reason: "finished", @@ -237,7 +238,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => { getLastAssistantMessage, }); - await (server as unknown as WebSocketServerInternals).broadcastAgentAttention({ + await asInternals(server).broadcastAgentAttention({ agentId: "agent-2", provider: "claude", reason: "finished", @@ -263,7 +264,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => { lastActivityAt: new Date(nowMs - 300_000), }); - await (server as unknown as WebSocketServerInternals).broadcastAgentAttention({ + await asInternals(server).broadcastAgentAttention({ agentId: "agent-X", provider: "claude", reason: "finished", @@ -278,7 +279,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => { const { server } = createServer(); const ws = connectClient(server, null); - await (server as unknown as WebSocketServerInternals).broadcastAgentAttention({ + await asInternals(server).broadcastAgentAttention({ agentId: "agent-no-heartbeat", provider: "claude", reason: "finished", @@ -292,7 +293,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => { const { server } = createServer(); const ws = connectClient(server, null); - await (server as unknown as WebSocketServerInternals).broadcastAgentAttention({ + await asInternals(server).broadcastAgentAttention({ agentId: "agent-no-heartbeat", provider: "claude", reason: "error", diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 0153bf4ca..31119a340 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -9,6 +9,7 @@ import type { FileBackedChatService } from "./chat/chat-service.js"; import type { LoopService } from "./loop-service.js"; import type { ScheduleService } from "./schedule/service.js"; import type { CheckoutDiffManager } from "./checkout-diff-manager.js"; +import { asInternals, createStub } from "./test-utils/class-mocks.js"; import { asUint8Array, decodeTerminalStreamFrame, @@ -191,10 +192,10 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu onChange: vi.fn(() => () => {}), }; return new VoiceAssistantWebSocketServer( - {} as unknown as HTTPServer, - createLogger() as unknown as pino.Logger, + createStub({}), + createStub(createLogger()), "srv_test", - { + createStub({ setAgentAttentionCallback: vi.fn(), getAgent: vi.fn(() => null), getMetricsSnapshot: vi.fn(() => ({ @@ -204,11 +205,11 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu pendingPermissionAgents: 0, erroredAgents: 0, })), - } as unknown as AgentManager, - {} as unknown as AgentStorage, - {} as unknown as DownloadTokenStore, + }), + createStub({}), + createStub({}), "/tmp/paseo-test", - daemonConfigStore as unknown as DaemonConfigStore, + createStub(daemonConfigStore), null, { allowedOrigins: new Set() }, undefined, @@ -227,10 +228,10 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu undefined, undefined, undefined, - {} as unknown as FileBackedChatService, - {} as unknown as LoopService, - {} as unknown as ScheduleService, - { + createStub({}), + createStub({}), + createStub({}), + createStub({ subscribe: vi.fn(), scheduleRefreshForCwd: vi.fn(), getMetrics: vi.fn(() => ({ @@ -240,7 +241,7 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu checkoutDiffFallbackRefreshTargetCount: 0, })), dispose: vi.fn(), - } as unknown as CheckoutDiffManager, + }), ); } @@ -366,7 +367,7 @@ async function attachDirectAndHello(params: { socket: MockSocket; clientId: string; }) { - await (params.server as unknown as WebSocketServerInternals).attachSocket( + await asInternals(params.server).attachSocket( params.socket, createDirectRequest(), ); @@ -426,10 +427,7 @@ describe("relay external socket reconnect behavior", () => { const server = createServer(); const socket = new MockSocket(); - await (server as unknown as WebSocketServerInternals).attachSocket( - socket, - createDirectRequest(), - ); + await asInternals(server).attachSocket(socket, createDirectRequest()); socket.emit( "message", JSON.stringify( @@ -460,10 +458,7 @@ describe("relay external socket reconnect behavior", () => { closeReason = typeof reason === "string" ? reason : ""; }); - await (server as unknown as WebSocketServerInternals).attachSocket( - socket, - createDirectRequest(), - ); + await asInternals(server).attachSocket(socket, createDirectRequest()); await vi.advanceTimersByTimeAsync(15_000); expect(closeCode).toBe(4001);