Skip to content

Commit 7315fff

Browse files
committed
refactor: add shared runLogic test helper
Adds a shared runLogic function to test-helpers.ts that wraps tool logic execution in a mock handler context and returns a ToolResponse- shaped result. This will replace the identical helper duplicated across 49 test files in subsequent PRs.
1 parent d5ec5f8 commit 7315fff

File tree

1 file changed

+204
-0
lines changed

1 file changed

+204
-0
lines changed

src/test-utils/test-helpers.ts

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
/**
2+
* Shared test helpers for extracting text content from tool responses.
3+
*/
4+
5+
import { expect } from 'vitest';
6+
import type { ToolHandlerContext, ImageAttachment } from '../rendering/types.ts';
7+
import type { PipelineEvent } from '../types/pipeline-events.ts';
8+
import type { ToolResponse, NextStepParamsMap } from '../types/common.ts';
9+
import type { ToolHandler } from '../utils/typed-tool-factory.ts';
10+
import { renderEvents } from '../rendering/render.ts';
11+
import { createRenderSession } from '../rendering/render.ts';
12+
import { handlerContextStorage } from '../utils/typed-tool-factory.ts';
13+
14+
/**
15+
* Extract and join all text content items from a tool response.
16+
*/
17+
export function allText(result: {
18+
content: ReadonlyArray<{ type: string; text?: string; [key: string]: unknown }>;
19+
}): string {
20+
return result.content
21+
.filter(
22+
(c): c is { type: 'text'; text: string } => c.type === 'text' && typeof c.text === 'string',
23+
)
24+
.map((c) => c.text)
25+
.join('\n');
26+
}
27+
28+
/**
29+
* Assert that a tool response represents a pending xcodebuild result
30+
* with an optional next-step tool reference.
31+
*/
32+
export interface MockToolHandlerResult {
33+
events: PipelineEvent[];
34+
attachments: ImageAttachment[];
35+
nextStepParams?: NextStepParamsMap;
36+
text(): string;
37+
isError(): boolean;
38+
}
39+
40+
export function createMockToolHandlerContext(): {
41+
ctx: ToolHandlerContext;
42+
result: MockToolHandlerResult;
43+
run: <T>(fn: () => Promise<T>) => Promise<T>;
44+
} {
45+
const events: PipelineEvent[] = [];
46+
const attachments: ImageAttachment[] = [];
47+
const ctx: ToolHandlerContext = {
48+
emit: (event) => {
49+
events.push(event);
50+
},
51+
attach: (image) => {
52+
attachments.push(image);
53+
},
54+
};
55+
const resultObj: MockToolHandlerResult = {
56+
events,
57+
attachments,
58+
get nextStepParams() {
59+
return ctx.nextStepParams;
60+
},
61+
text() {
62+
return renderEvents(events, 'text');
63+
},
64+
isError() {
65+
return events.some(
66+
(e) =>
67+
(e.type === 'status-line' && e.level === 'error') ||
68+
(e.type === 'summary' && e.status === 'FAILED'),
69+
);
70+
},
71+
};
72+
return {
73+
ctx,
74+
result: resultObj,
75+
run: async <T>(fn: () => Promise<T>): Promise<T> => {
76+
return handlerContextStorage.run(ctx, fn);
77+
},
78+
};
79+
}
80+
81+
export async function runToolLogic<T>(logic: () => Promise<T>): Promise<{
82+
response: T;
83+
result: MockToolHandlerResult;
84+
}> {
85+
const { result, run } = createMockToolHandlerContext();
86+
const response = await run(logic);
87+
return { response, result };
88+
}
89+
90+
export interface RunLogicResult {
91+
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
92+
isError?: boolean;
93+
nextStepParams?: NextStepParamsMap;
94+
attachments?: ImageAttachment[];
95+
text?: string;
96+
}
97+
98+
/**
99+
* Run a tool's logic function in a mock handler context and return a
100+
* ToolResponse-shaped result for backward-compatible test assertions.
101+
*/
102+
export async function runLogic(logic: () => Promise<unknown>): Promise<RunLogicResult> {
103+
const { result, run } = createMockToolHandlerContext();
104+
const response = await run(logic);
105+
106+
if (
107+
response &&
108+
typeof response === 'object' &&
109+
'content' in (response as Record<string, unknown>)
110+
) {
111+
return response as RunLogicResult;
112+
}
113+
114+
const text = result.text();
115+
const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : [];
116+
const imageContent = result.attachments.map((attachment) => ({
117+
type: 'image' as const,
118+
data: attachment.data,
119+
mimeType: attachment.mimeType,
120+
}));
121+
122+
return {
123+
content: [...textContent, ...imageContent],
124+
isError: result.isError() ? true : undefined,
125+
nextStepParams: result.nextStepParams,
126+
attachments: result.attachments,
127+
text,
128+
};
129+
}
130+
131+
export interface CallHandlerResult {
132+
content: Array<{ type: 'text'; text: string }>;
133+
isError?: boolean;
134+
nextStepParams?: NextStepParamsMap;
135+
}
136+
137+
/**
138+
* Call a tool handler in test mode, providing a session context and
139+
* returning a ToolResponse-shaped result for backward-compatible assertions.
140+
*/
141+
export async function callHandler(
142+
handler:
143+
| ToolHandler
144+
| ((args: Record<string, unknown>, ctx?: ToolHandlerContext) => Promise<void>),
145+
args: Record<string, unknown>,
146+
): Promise<CallHandlerResult> {
147+
const session = createRenderSession('text');
148+
const ctx: ToolHandlerContext = {
149+
emit: (event) => session.emit(event),
150+
attach: (image) => session.attach(image),
151+
};
152+
await handler(args, ctx);
153+
const text = session.finalize();
154+
return {
155+
content: text ? [{ type: 'text' as const, text }] : [],
156+
isError: session.isError() || undefined,
157+
nextStepParams: ctx.nextStepParams,
158+
};
159+
}
160+
161+
function isMockToolHandlerResult(
162+
result: ToolResponse | MockToolHandlerResult,
163+
): result is MockToolHandlerResult {
164+
return 'events' in result && Array.isArray(result.events) && typeof result.text === 'function';
165+
}
166+
167+
export function expectPendingBuildResponse(
168+
result: ToolResponse | MockToolHandlerResult,
169+
nextStepToolId?: string,
170+
): void {
171+
if (isMockToolHandlerResult(result)) {
172+
expect(result.events.some((event) => event.type === 'summary')).toBe(true);
173+
174+
if (nextStepToolId) {
175+
expect(result.nextStepParams).toEqual(
176+
expect.objectContaining({
177+
[nextStepToolId]: expect.any(Object),
178+
}),
179+
);
180+
} else {
181+
expect(result.nextStepParams).toBeUndefined();
182+
}
183+
return;
184+
}
185+
186+
expect(result.content).toEqual([]);
187+
expect(result._meta).toEqual(
188+
expect.objectContaining({
189+
pendingXcodebuild: expect.objectContaining({
190+
kind: 'pending-xcodebuild',
191+
}),
192+
}),
193+
);
194+
195+
if (nextStepToolId) {
196+
expect(result.nextStepParams).toEqual(
197+
expect.objectContaining({
198+
[nextStepToolId]: expect.any(Object),
199+
}),
200+
);
201+
} else {
202+
expect(result.nextStepParams).toBeUndefined();
203+
}
204+
}

0 commit comments

Comments
 (0)