refactor(server/tests): solve class-stub mock pattern (T1.e) (#757)
Introduce createStub<T> (Proxy-based) and asInternals<T> (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.
This commit is contained in:
@@ -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<ACPConfiguredOverrideInternals>(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<typeof logger>(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<ACPModelSelectionInternals>(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<typeof createTestLogger>,
|
||||
logger: asInternals<ReturnType<typeof createTestLogger>>(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<typeof createTestLogger>,
|
||||
logger: asInternals<ReturnType<typeof createTestLogger>>(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<ACPSessionInternals>(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<typeof logger>(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<ACPSessionInternals>(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<ACPSessionInternals>(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<ACPSessionInternals>(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<ACPModelSelectionInternals>(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<ACPModelSelectionInternals>(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<ACPModelSelectionInternals>(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<ACPSessionInternals>(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<void> {}
|
||||
@@ -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<void> {}
|
||||
@@ -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<void> {}
|
||||
@@ -1007,7 +1008,7 @@ describe("ACPAgentClient listModes", () => {
|
||||
}),
|
||||
},
|
||||
initialize: { agentCapabilities: {} },
|
||||
} as unknown as SpawnedACPProcess;
|
||||
} as SpawnedACPProcess;
|
||||
}
|
||||
|
||||
protected override async closeProbe(): Promise<void> {}
|
||||
@@ -1111,7 +1112,7 @@ describe("ACPAgentSession slash commands", () => {
|
||||
|
||||
const listCommandsPromise = session.listCommands();
|
||||
|
||||
(session as unknown as ACPSessionInternals).translateSessionUpdate({
|
||||
asInternals<ACPSessionInternals>(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<ACPSessionInternals>(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<ACPSessionInternals>(session).sessionId = "session-1";
|
||||
asInternals<ACPSessionInternals>(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<ACPSessionInternals>(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<ACPSessionInternals>(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<ACPSessionInternals>(session).sessionId = "session-1";
|
||||
asInternals<ACPSessionInternals>(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<ACPSessionInternals>(session).activeForegroundTurnId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<SmokeLogger>(logger);
|
||||
}
|
||||
|
||||
function createTrace(config: WrapperSmokeConfig): SmokeTrace {
|
||||
@@ -365,7 +366,7 @@ async function bootSession(
|
||||
}
|
||||
|
||||
async function captureFinalState(session: ACPAgentSession, trace: SmokeTrace): Promise<void> {
|
||||
const internals = session as unknown as SessionInternals;
|
||||
const internals = asInternals<SessionInternals>(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<SessionInternals>(session);
|
||||
return internals.availableModels?.map((model) => model.modelId) ?? [];
|
||||
}
|
||||
|
||||
@@ -384,7 +385,7 @@ function getSelectOption(
|
||||
session: ACPAgentSession,
|
||||
category: string,
|
||||
): Extract<SessionConfigOption, { type: "select" }> | null {
|
||||
const internals = session as unknown as SessionInternals;
|
||||
const internals = asInternals<SessionInternals>(session);
|
||||
return (
|
||||
internals.configOptions.find(
|
||||
(option): option is Extract<SessionConfigOption, { type: "select" }> =>
|
||||
@@ -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<SessionInternals>(session).toolCalls.size).toBeGreaterThan(0);
|
||||
trace.criteria.toolSnapshot = {
|
||||
outcome: "PASS",
|
||||
detail:
|
||||
|
||||
@@ -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<Logger>(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<void>;
|
||||
return?: () => Promise<void>;
|
||||
@@ -270,7 +271,7 @@ test("interruptActiveTurn only interrupts the active query without info logs", a
|
||||
input: { end: () => void } | null;
|
||||
queryRestartNeeded: boolean;
|
||||
interruptActiveTurn: () => Promise<void>;
|
||||
};
|
||||
} = 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<typeof readEventIdentifiers>[0],
|
||||
asInternals<Parameters<typeof readEventIdentifiers>[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<string, unknown>) => {
|
||||
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<string, unknown>) => AgentTimelineItem[];
|
||||
toolUseCache: Map<string, { input?: Record<string, unknown> }>;
|
||||
toolUseIndexToId: Map<number, string>;
|
||||
toolUseInputBuffers: Map<string, string>;
|
||||
};
|
||||
} = 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<string, unknown>);
|
||||
internal.mapPartialEvent(asInternals<Record<string, unknown>>(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<string, unknown>) => AgentTimelineItem[];
|
||||
toolUseCache: Map<string, { input?: Record<string, unknown> }>;
|
||||
};
|
||||
} = 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<string, unknown>,
|
||||
entry: Record<string, unknown> | undefined,
|
||||
) => Record<string, unknown> | 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<string, unknown>,
|
||||
entry: Record<string, unknown> | undefined,
|
||||
) => Record<string, unknown> | 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<string, unknown>,
|
||||
options: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
} = 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<string, unknown>) => 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<string, unknown> };
|
||||
};
|
||||
} = asInternals(session);
|
||||
expect(assembler.timelineAssembler.messages.size).toBe(0);
|
||||
|
||||
await session.close();
|
||||
|
||||
@@ -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<AgentSessionConfig> = {}): 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<AgentSessionConfig> = {}): Codex
|
||||
}
|
||||
|
||||
function asInternals(session: CodexTestSession): CodexSessionTestAccess {
|
||||
return session as unknown as CodexSessionTestAccess;
|
||||
return castInternals<CodexSessionTestAccess>(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<void> }).ensureThread();
|
||||
await castInternals<{ ensureThread: () => Promise<void> }>(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<void> }).ensureThread();
|
||||
await castInternals<{ ensureThread: () => Promise<void> }>(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<CodexClientLike>({ 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<CodexClientLike>({ 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<CodexClientLike>({ request });
|
||||
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
@@ -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<pino.Logger>(logger),
|
||||
downloadTokenStore: createStub<SessionOptions["downloadTokenStore"]>({}),
|
||||
pushTokenStore: createStub<SessionOptions["pushTokenStore"]>({}),
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
agentManager: {
|
||||
agentManager: createStub<SessionOptions["agentManager"]>({
|
||||
subscribe: () => () => {},
|
||||
listAgents: () => [],
|
||||
getAgent: () => null,
|
||||
} as unknown as SessionOptions["agentManager"],
|
||||
agentStorage: {
|
||||
}),
|
||||
agentStorage: createStub<SessionOptions["agentStorage"]>({
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
} as unknown as SessionOptions["agentStorage"],
|
||||
projectRegistry: {
|
||||
}),
|
||||
projectRegistry: createStub<SessionOptions["projectRegistry"]>({
|
||||
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<SessionOptions["workspaceRegistry"]>({
|
||||
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<SessionOptions["checkoutDiffManager"]>({
|
||||
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<SessionInternals>(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<SessionInternals>(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<SessionInternals>(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<SessionInternals>(session);
|
||||
seedGitWorkspace({
|
||||
projects,
|
||||
workspaces,
|
||||
|
||||
@@ -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<SessionOptions["logger"]>(logger),
|
||||
downloadTokenStore: createStub<SessionOptions["downloadTokenStore"]>({}),
|
||||
pushTokenStore: createStub<SessionOptions["pushTokenStore"]>({}),
|
||||
paseoHome: mkdtempSync(path.join(tmpdir(), "paseo-invariant-test-")),
|
||||
agentManager: {
|
||||
agentManager: createStub<SessionOptions["agentManager"]>({
|
||||
subscribe: () => () => {},
|
||||
listAgents: () => [],
|
||||
getAgent: () => null,
|
||||
@@ -99,12 +100,12 @@ function createHarness(input: {
|
||||
archiveSnapshot: async () => ({}),
|
||||
clearAgentAttention: async () => {},
|
||||
notifyAgentState: () => {},
|
||||
} as unknown as SessionOptions["agentManager"],
|
||||
agentStorage: {
|
||||
}),
|
||||
agentStorage: createStub<SessionOptions["agentStorage"]>({
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
} as unknown as SessionOptions["agentStorage"],
|
||||
projectRegistry: {
|
||||
}),
|
||||
projectRegistry: createStub<SessionOptions["projectRegistry"]>({
|
||||
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<SessionOptions["workspaceRegistry"]>({
|
||||
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<SessionOptions["chatService"]>({}),
|
||||
scheduleService: createStub<SessionOptions["scheduleService"]>({}),
|
||||
loopService: createStub<SessionOptions["loopService"]>({}),
|
||||
checkoutDiffManager: createStub<SessionOptions["checkoutDiffManager"]>({
|
||||
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<SessionOptions["daemonConfigStore"]>({
|
||||
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<unknown> }).handleMessage({
|
||||
await asInternals<{ handleMessage(m: unknown): Promise<unknown> }>(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<unknown>;
|
||||
}
|
||||
).findWorkspaceByDirectory("/parent/child");
|
||||
const found = await asInternals<{
|
||||
findWorkspaceByDirectory(cwd: string): Promise<unknown>;
|
||||
}>(h.session).findWorkspaceByDirectory("/parent/child");
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<SessionInternals>(
|
||||
new Session({
|
||||
clientId: "test-client",
|
||||
onMessage,
|
||||
logger: createStub<SessionOptions["logger"]>(logger),
|
||||
downloadTokenStore: createStub<SessionOptions["downloadTokenStore"]>({}),
|
||||
pushTokenStore: createStub<SessionOptions["pushTokenStore"]>({}),
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
agentManager: createStub<SessionOptions["agentManager"]>({
|
||||
subscribe: () => () => {},
|
||||
listAgents: () => [],
|
||||
getAgent: () => null,
|
||||
archiveSnapshot,
|
||||
updateAgentMetadata,
|
||||
}),
|
||||
agentStorage: createStub<SessionOptions["agentStorage"]>({
|
||||
list: async () => [],
|
||||
get: async () => storedRecord,
|
||||
applySnapshot: directStorageWrite,
|
||||
upsert: directStorageWrite,
|
||||
}),
|
||||
projectRegistry: createStub<SessionOptions["projectRegistry"]>({
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
}),
|
||||
workspaceRegistry: createStub<SessionOptions["workspaceRegistry"]>({
|
||||
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);
|
||||
|
||||
@@ -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<T> 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<T>(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<T extends object>(stubs: { [K in keyof T]?: unknown }): T {
|
||||
return new Proxy(stubs as Record<string | symbol, unknown>, {
|
||||
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<WebSocketServerInternals>(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<T>(obj: unknown): T {
|
||||
return obj as T;
|
||||
}
|
||||
@@ -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<SessionOptions["logger"]>(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<SessionOptions["agentManager"]>(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<SessionOptions["agentStorage"]>(stub);
|
||||
}
|
||||
|
||||
export function asDownloadTokenStore(): SessionOptions["downloadTokenStore"] {
|
||||
return {} as unknown as SessionOptions["downloadTokenStore"];
|
||||
return createStub<SessionOptions["downloadTokenStore"]>({});
|
||||
}
|
||||
|
||||
export function asPushTokenStore(): SessionOptions["pushTokenStore"] {
|
||||
return {} as unknown as SessionOptions["pushTokenStore"];
|
||||
return createStub<SessionOptions["pushTokenStore"]>({});
|
||||
}
|
||||
|
||||
export function asChatService(): SessionOptions["chatService"] {
|
||||
return {} as unknown as SessionOptions["chatService"];
|
||||
return createStub<SessionOptions["chatService"]>({});
|
||||
}
|
||||
|
||||
export function asScheduleService(): SessionOptions["scheduleService"] {
|
||||
return {} as unknown as SessionOptions["scheduleService"];
|
||||
return createStub<SessionOptions["scheduleService"]>({});
|
||||
}
|
||||
|
||||
export function asLoopService(): SessionOptions["loopService"] {
|
||||
return {} as unknown as SessionOptions["loopService"];
|
||||
return createStub<SessionOptions["loopService"]>({});
|
||||
}
|
||||
|
||||
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<SessionOptions["checkoutDiffManager"]>(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<SessionOptions["daemonConfigStore"]>(stub);
|
||||
}
|
||||
|
||||
export function asTerminalManager(stub: object): NonNullable<SessionOptions["terminalManager"]> {
|
||||
return stub as unknown as NonNullable<SessionOptions["terminalManager"]>;
|
||||
export function asTerminalManager(stub: {
|
||||
[K in keyof NonNullable<SessionOptions["terminalManager"]>]?: unknown;
|
||||
}): NonNullable<SessionOptions["terminalManager"]> {
|
||||
return createStub<NonNullable<SessionOptions["terminalManager"]>>(stub);
|
||||
}
|
||||
|
||||
export function asGitHubService(stub: object): NonNullable<SessionOptions["github"]> {
|
||||
return stub as unknown as NonNullable<SessionOptions["github"]>;
|
||||
export function asGitHubService(stub: {
|
||||
[K in keyof NonNullable<SessionOptions["github"]>]?: unknown;
|
||||
}): NonNullable<SessionOptions["github"]> {
|
||||
return createStub<NonNullable<SessionOptions["github"]>>(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<SessionOptions["workspaceGitService"]>(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<SessionOptions["scriptRouteStore"]>(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<SessionOptions["scriptRuntimeStore"]>(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<T>(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<ProviderSnapshotManager>(stub);
|
||||
return {
|
||||
manager,
|
||||
getSnapshot,
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
};
|
||||
|
||||
const server = new VoiceAssistantWebSocketServer(
|
||||
{} as unknown as HTTPServer,
|
||||
createLogger() as unknown as pino.Logger,
|
||||
createStub<HTTPServer>({}),
|
||||
createStub<pino.Logger>(createLogger()),
|
||||
"srv-test",
|
||||
agentManager as unknown as AgentManager,
|
||||
{} as unknown as AgentStorage,
|
||||
{} as unknown as DownloadTokenStore,
|
||||
createStub<AgentManager>(agentManager),
|
||||
createStub<AgentStorage>({}),
|
||||
createStub<DownloadTokenStore>({}),
|
||||
"/tmp/paseo-test",
|
||||
daemonConfigStore as unknown as DaemonConfigStore,
|
||||
createStub<DaemonConfigStore>(daemonConfigStore),
|
||||
null,
|
||||
{ allowedOrigins: new Set() },
|
||||
undefined,
|
||||
@@ -113,10 +114,10 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{} as unknown as FileBackedChatService,
|
||||
{} as unknown as LoopService,
|
||||
{} as unknown as ScheduleService,
|
||||
{
|
||||
createStub<FileBackedChatService>({}),
|
||||
createStub<LoopService>({}),
|
||||
createStub<ScheduleService>({}),
|
||||
createStub<CheckoutDiffManager>({
|
||||
subscribe: vi.fn(),
|
||||
scheduleRefreshForCwd: vi.fn(),
|
||||
getMetrics: vi.fn(() => ({
|
||||
@@ -126,7 +127,7 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
|
||||
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<WebSocketServerInternals>(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<WebSocketServerInternals>(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<WebSocketServerInternals>(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<WebSocketServerInternals>(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<WebSocketServerInternals>(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<WebSocketServerInternals>(server).broadcastAgentAttention({
|
||||
agentId: "agent-no-heartbeat",
|
||||
provider: "claude",
|
||||
reason: "error",
|
||||
|
||||
@@ -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<HTTPServer>({}),
|
||||
createStub<pino.Logger>(createLogger()),
|
||||
"srv_test",
|
||||
{
|
||||
createStub<AgentManager>({
|
||||
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<AgentStorage>({}),
|
||||
createStub<DownloadTokenStore>({}),
|
||||
"/tmp/paseo-test",
|
||||
daemonConfigStore as unknown as DaemonConfigStore,
|
||||
createStub<DaemonConfigStore>(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<FileBackedChatService>({}),
|
||||
createStub<LoopService>({}),
|
||||
createStub<ScheduleService>({}),
|
||||
createStub<CheckoutDiffManager>({
|
||||
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<WebSocketServerInternals>(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<WebSocketServerInternals>(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<WebSocketServerInternals>(server).attachSocket(socket, createDirectRequest());
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
|
||||
expect(closeCode).toBe(4001);
|
||||
|
||||
Reference in New Issue
Block a user