@flink-app/test-utils 1.0.0 → 2.0.0-alpha.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,313 @@
1
+ import { z } from "zod";
2
+ import { mockTool } from "../../src/ai/mockTool";
3
+ import { mockToolContext } from "../../src/ai/mockContext";
4
+
5
+ describe("mockTool", () => {
6
+ describe("canned responses", () => {
7
+ it("should return canned success response", async () => {
8
+ const tool = mockTool({
9
+ name: "get_weather",
10
+ inputSchema: z.object({ city: z.string() }),
11
+ response: { temperature: 22, conditions: "sunny" },
12
+ });
13
+
14
+ const ctx = mockToolContext();
15
+ const result = await tool.fn({
16
+ input: { city: "Stockholm" },
17
+ ctx,
18
+ });
19
+
20
+ expect(result.success).toBe(true);
21
+ if (result.success) {
22
+ expect(result.data).toEqual({ temperature: 22, conditions: "sunny" });
23
+ }
24
+ });
25
+
26
+ it("should return canned error response", async () => {
27
+ const tool = mockTool({
28
+ name: "failing_tool",
29
+ inputSchema: z.object({}),
30
+ error: { error: "Tool failed", code: "MOCK_ERROR" },
31
+ });
32
+
33
+ const ctx = mockToolContext();
34
+ const result = await tool.fn({ input: {}, ctx });
35
+
36
+ expect(result.success).toBe(false);
37
+ if (!result.success) {
38
+ expect(result.error).toBe("Tool failed");
39
+ expect(result.code).toBe("MOCK_ERROR");
40
+ }
41
+ });
42
+
43
+ it("should return empty object when no response configured", async () => {
44
+ const tool = mockTool({
45
+ name: "empty_tool",
46
+ inputSchema: z.object({}),
47
+ });
48
+
49
+ const ctx = mockToolContext();
50
+ const result = await tool.fn({ input: {}, ctx });
51
+
52
+ expect(result.success).toBe(true);
53
+ if (result.success) {
54
+ expect(result.data).toEqual({});
55
+ }
56
+ });
57
+ });
58
+
59
+ describe("custom functions", () => {
60
+ it("should execute custom function", async () => {
61
+ const tool = mockTool({
62
+ name: "calculate",
63
+ inputSchema: z.object({ a: z.number(), b: z.number() }),
64
+ fn: async ({ input }) => ({
65
+ success: true,
66
+ data: { result: input.a + input.b },
67
+ }),
68
+ });
69
+
70
+ const ctx = mockToolContext();
71
+ const result = await tool.fn({
72
+ input: { a: 5, b: 3 },
73
+ ctx,
74
+ });
75
+
76
+ expect(result.success).toBe(true);
77
+ if (result.success) {
78
+ expect(result.data.result).toBe(8);
79
+ }
80
+ });
81
+
82
+ it("should pass context to custom function", async () => {
83
+ const mockRepo = { findById: jasmine.createSpy("findById") };
84
+ const tool = mockTool({
85
+ name: "get_car",
86
+ inputSchema: z.object({ id: z.string() }),
87
+ fn: async ({ input, ctx }) => {
88
+ (ctx.repos as any).carRepo.findById(input.id);
89
+ return { success: true, data: { car: "data" } };
90
+ },
91
+ });
92
+
93
+ const ctx = mockToolContext({ repos: { carRepo: mockRepo as any } });
94
+ await tool.fn({ input: { id: "123" }, ctx });
95
+
96
+ expect(mockRepo.findById).toHaveBeenCalledWith("123");
97
+ });
98
+
99
+ it("should pass user to custom function", async () => {
100
+ const tool = mockTool({
101
+ name: "user_tool",
102
+ inputSchema: z.object({}),
103
+ fn: async ({ user }) => ({
104
+ success: true,
105
+ data: { userId: user?.id },
106
+ }),
107
+ });
108
+
109
+ const ctx = mockToolContext();
110
+ const result = await tool.fn({
111
+ input: {},
112
+ ctx,
113
+ user: { id: "user-123" },
114
+ });
115
+
116
+ expect(result.success).toBe(true);
117
+ if (result.success) {
118
+ expect(result.data.userId).toBe("user-123");
119
+ }
120
+ });
121
+ });
122
+
123
+ describe("invocation tracking", () => {
124
+ it("should track invocations", async () => {
125
+ const tool = mockTool({
126
+ name: "test_tool",
127
+ inputSchema: z.object({ value: z.string() }),
128
+ response: { ok: true },
129
+ });
130
+
131
+ const ctx = mockToolContext();
132
+ await tool.fn({ input: { value: "first" }, ctx });
133
+ await tool.fn({ input: { value: "second" }, ctx });
134
+ await tool.fn({ input: { value: "third" }, ctx });
135
+
136
+ expect(tool.invocations.length).toBe(3);
137
+ expect(tool.invocations[0].input).toEqual({ value: "first" });
138
+ expect(tool.invocations[1].input).toEqual({ value: "second" });
139
+ expect(tool.invocations[2].input).toEqual({ value: "third" });
140
+ });
141
+
142
+ it("should track user in invocations", async () => {
143
+ const tool = mockTool({
144
+ name: "test_tool",
145
+ inputSchema: z.object({}),
146
+ response: { ok: true },
147
+ });
148
+
149
+ const ctx = mockToolContext();
150
+ const user = { id: "user-123", permissions: ["admin"] };
151
+ await tool.fn({ input: {}, ctx, user });
152
+
153
+ expect(tool.invocations.length).toBe(1);
154
+ expect(tool.invocations[0].user).toEqual(user);
155
+ });
156
+
157
+ it("should get last invocation", async () => {
158
+ const tool = mockTool({
159
+ name: "test_tool",
160
+ inputSchema: z.object({ value: z.string() }),
161
+ response: { ok: true },
162
+ });
163
+
164
+ const ctx = mockToolContext();
165
+ await tool.fn({ input: { value: "first" }, ctx });
166
+ await tool.fn({ input: { value: "last" }, ctx });
167
+
168
+ const last = tool.getLastInvocation();
169
+ expect(last?.input).toEqual({ value: "last" });
170
+ });
171
+
172
+ it("should return undefined for last invocation when empty", () => {
173
+ const tool = mockTool({
174
+ name: "test_tool",
175
+ inputSchema: z.object({}),
176
+ response: {},
177
+ });
178
+
179
+ expect(tool.getLastInvocation()).toBeUndefined();
180
+ });
181
+
182
+ it("should reset invocations", async () => {
183
+ const tool = mockTool({
184
+ name: "test_tool",
185
+ inputSchema: z.object({}),
186
+ response: { ok: true },
187
+ });
188
+
189
+ const ctx = mockToolContext();
190
+ await tool.fn({ input: {}, ctx });
191
+ await tool.fn({ input: {}, ctx });
192
+
193
+ expect(tool.invocations.length).toBe(2);
194
+
195
+ tool.reset();
196
+
197
+ expect(tool.invocations.length).toBe(0);
198
+ expect(tool.getLastInvocation()).toBeUndefined();
199
+ });
200
+ });
201
+
202
+ describe("tool props", () => {
203
+ it("should create correct tool props", () => {
204
+ const inputSchema = z.object({ city: z.string() });
205
+ const outputSchema = z.object({ temp: z.number() });
206
+
207
+ const tool = mockTool({
208
+ name: "get_weather",
209
+ description: "Get weather for a city",
210
+ inputSchema,
211
+ outputSchema,
212
+ response: { temp: 22 },
213
+ });
214
+
215
+ expect(tool.props.id).toBe("get_weather");
216
+ expect(tool.props.description).toBe("Get weather for a city");
217
+ expect(tool.props.inputSchema).toBe(inputSchema);
218
+ expect(tool.props.outputSchema).toBe(outputSchema);
219
+ });
220
+
221
+ it("should use default description when not provided", () => {
222
+ const tool = mockTool({
223
+ name: "test_tool",
224
+ inputSchema: z.object({}),
225
+ response: {},
226
+ });
227
+
228
+ expect(tool.props.description).toBe("Mock tool: test_tool");
229
+ });
230
+
231
+ it("should include permissions in props", () => {
232
+ const tool = mockTool({
233
+ name: "admin_tool",
234
+ inputSchema: z.object({}),
235
+ permissions: "admin",
236
+ response: {},
237
+ });
238
+
239
+ expect(tool.props.permissions).toBe("admin");
240
+ });
241
+
242
+ it("should handle array permissions", () => {
243
+ const tool = mockTool({
244
+ name: "multi_perm_tool",
245
+ inputSchema: z.object({}),
246
+ permissions: ["admin", "moderator"],
247
+ response: {},
248
+ });
249
+
250
+ expect(tool.props.permissions).toEqual(["admin", "moderator"]);
251
+ });
252
+ });
253
+
254
+ describe("integration with custom function", () => {
255
+ it("should track invocations for custom function", async () => {
256
+ const tool = mockTool({
257
+ name: "custom_tool",
258
+ inputSchema: z.object({ value: z.number() }),
259
+ fn: async ({ input }) => ({
260
+ success: true,
261
+ data: { doubled: input.value * 2 },
262
+ }),
263
+ });
264
+
265
+ const ctx = mockToolContext();
266
+ await tool.fn({ input: { value: 5 }, ctx });
267
+ await tool.fn({ input: { value: 10 }, ctx });
268
+
269
+ expect(tool.invocations.length).toBe(2);
270
+ expect(tool.invocations[0].input.value).toBe(5);
271
+ expect(tool.invocations[1].input.value).toBe(10);
272
+ });
273
+
274
+ it("should prioritize custom function over canned response", async () => {
275
+ const tool = mockTool<{}, { canned?: boolean; custom?: boolean }>({
276
+ name: "priority_tool",
277
+ inputSchema: z.object({}),
278
+ response: { canned: true },
279
+ fn: async () => ({ success: true, data: { custom: true } }),
280
+ });
281
+
282
+ const ctx = mockToolContext();
283
+ const result = await tool.fn({ input: {}, ctx });
284
+
285
+ expect(result.success).toBe(true);
286
+ if (result.success) {
287
+ expect(result.data).toEqual({ custom: true });
288
+ }
289
+ });
290
+ });
291
+
292
+ describe("type inference", () => {
293
+ it("should infer input and output types", async () => {
294
+ const tool = mockTool({
295
+ name: "typed_tool",
296
+ inputSchema: z.object({
297
+ city: z.string(),
298
+ country: z.string().optional(),
299
+ }),
300
+ response: { temperature: 22, conditions: "sunny" },
301
+ });
302
+
303
+ const ctx = mockToolContext();
304
+ const result = await tool.fn({
305
+ input: { city: "Stockholm" },
306
+ ctx,
307
+ });
308
+
309
+ // TypeScript should enforce these types
310
+ expect(result.success).toBe(true);
311
+ });
312
+ });
313
+ });
@@ -0,0 +1,7 @@
1
+ {
2
+ "spec_dir": "spec",
3
+ "spec_files": ["**/*[sS]pec.ts"],
4
+ "helpers": ["helpers/**/*.ts"],
5
+ "stopSpecOnExpectationFailure": false,
6
+ "random": false
7
+ }
@@ -0,0 +1,54 @@
1
+ import type { Message, ToolCall } from "@flink-app/flink/ai";
2
+
3
+ /**
4
+ * Fluent builder for multi-turn conversation testing
5
+ * Simplifies creating conversation histories
6
+ *
7
+ * @example
8
+ * const conversation = createConversation()
9
+ * .user("Book a flight to Paris")
10
+ * .assistant("When would you like to travel?")
11
+ * .user("Next Monday")
12
+ * .assistant("Let me search", [
13
+ * { id: "1", name: "search_flights", input: { destination: "Paris" } }
14
+ * ])
15
+ * .tool("1", "search_flights", JSON.stringify({ flights: [...] }))
16
+ * .assistant("I found these flights...")
17
+ * .build();
18
+ */
19
+ export interface ConversationBuilder {
20
+ user(content: string): ConversationBuilder;
21
+ assistant(content: string, toolCalls?: ToolCall[]): ConversationBuilder;
22
+ tool(toolCallId: string, toolName: string, result: string): ConversationBuilder;
23
+ build(): Message[];
24
+ }
25
+
26
+ class ConversationBuilderImpl implements ConversationBuilder {
27
+ private messages: Message[] = [];
28
+
29
+ user(content: string): ConversationBuilder {
30
+ this.messages.push({ role: "user", content });
31
+ return this;
32
+ }
33
+
34
+ assistant(content: string, toolCalls?: ToolCall[]): ConversationBuilder {
35
+ this.messages.push({ role: "assistant", content, toolCalls });
36
+ return this;
37
+ }
38
+
39
+ tool(toolCallId: string, toolName: string, result: string): ConversationBuilder {
40
+ this.messages.push({ role: "tool", toolCallId, toolName, result });
41
+ return this;
42
+ }
43
+
44
+ build(): Message[] {
45
+ return this.messages;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Creates a new conversation builder
51
+ */
52
+ export function createConversation(): ConversationBuilder {
53
+ return new ConversationBuilderImpl();
54
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./mockLLMAdapter";
2
+ export * from "./mockTool";
3
+ export * from "./mockContext";
4
+ export * from "./conversation";
@@ -0,0 +1,41 @@
1
+ import { FlinkContext } from "@flink-app/flink";
2
+
3
+ /**
4
+ * Creates a mock FlinkContext for tool testing
5
+ * Provides sensible defaults for all required properties
6
+ *
7
+ * @example
8
+ * const ctx = mockToolContext({
9
+ * repos: { carRepo: mockCarRepo },
10
+ * plugins: { email: mockEmailPlugin }
11
+ * });
12
+ *
13
+ * @example
14
+ * // Minimal usage with defaults
15
+ * const ctx = mockToolContext();
16
+ */
17
+ export function mockToolContext<Ctx extends FlinkContext = FlinkContext>(overrides: Partial<Ctx> = {}): Ctx {
18
+ const defaultContext: FlinkContext = {
19
+ repos: {},
20
+ plugins: {},
21
+ agents: {},
22
+ };
23
+
24
+ return {
25
+ ...defaultContext,
26
+ ...overrides,
27
+ // Deep merge repos, plugins, and agents
28
+ repos: {
29
+ ...defaultContext.repos,
30
+ ...(overrides.repos || {}),
31
+ },
32
+ plugins: {
33
+ ...defaultContext.plugins,
34
+ ...(overrides.plugins || {}),
35
+ },
36
+ agents: {
37
+ ...(defaultContext.agents || {}),
38
+ ...((overrides as any).agents || {}),
39
+ },
40
+ } as Ctx;
41
+ }
@@ -0,0 +1,238 @@
1
+ import { LLMAdapter, LLMMessage, LLMStreamChunk } from "@flink-app/flink/ai";
2
+
3
+ export interface MockLLMConfig {
4
+ // Simple single response
5
+ response?: string;
6
+
7
+ // Multiple responses for multi-turn (queued)
8
+ responses?: Array<{
9
+ text?: string;
10
+ toolCalls?: Array<{ id: string; name: string; input: any }>;
11
+ stopReason?: "end_turn" | "tool_use" | "max_tokens";
12
+ streamChunks?: LLMStreamChunk[]; // Explicitly define streaming chunks
13
+ }>;
14
+
15
+ // Error simulation
16
+ error?: Error;
17
+
18
+ // Token usage simulation
19
+ usage?: { inputTokens: number; outputTokens: number };
20
+
21
+ // Default stream chunks if not specified per response
22
+ defaultStreamChunks?: LLMStreamChunk[];
23
+ }
24
+
25
+ export interface MockLLMAdapterInvocation {
26
+ instructions: string;
27
+ messages: LLMMessage[];
28
+ tools: any[];
29
+ maxTokens: number;
30
+ temperature: number;
31
+ }
32
+
33
+ export interface MockLLMAdapter extends LLMAdapter {
34
+ // Automatic invocation tracking
35
+ invocations: MockLLMAdapterInvocation[];
36
+
37
+ // Utilities
38
+ reset(): void;
39
+ getLastInvocation(): MockLLMAdapterInvocation | undefined;
40
+ getInvocationCount(): number;
41
+ }
42
+
43
+ /**
44
+ * Creates a mock LLM adapter for testing agents without API calls
45
+ *
46
+ * Features:
47
+ * - Canned responses (single or multi-turn)
48
+ * - Tool call simulation
49
+ * - Error simulation
50
+ * - Automatic invocation tracking
51
+ * - Token usage simulation
52
+ *
53
+ * @example
54
+ * // Simple response
55
+ * const adapter = mockLLMAdapter({ response: "Hello!" });
56
+ *
57
+ * @example
58
+ * // Multi-turn with tool calls
59
+ * const adapter = mockLLMAdapter({
60
+ * responses: [
61
+ * {
62
+ * text: "Let me check",
63
+ * toolCalls: [{ id: "1", name: "get_weather", input: { city: "Stockholm" } }]
64
+ * },
65
+ * { text: "It's sunny and 22°C" }
66
+ * ]
67
+ * });
68
+ *
69
+ * @example
70
+ * // Error simulation
71
+ * const adapter = mockLLMAdapter({
72
+ * error: new Error("Rate limit exceeded")
73
+ * });
74
+ */
75
+ export function mockLLMAdapter(config: MockLLMConfig = {}): MockLLMAdapter {
76
+ const invocations: MockLLMAdapterInvocation[] = [];
77
+ let responseQueue: Array<{
78
+ text?: string;
79
+ toolCalls?: Array<{ id: string; name: string; input: any }>;
80
+ stopReason?: "end_turn" | "tool_use" | "max_tokens";
81
+ streamChunks?: LLMStreamChunk[];
82
+ }> = [];
83
+ let currentResponseIndex = 0;
84
+
85
+ // Initialize response queue
86
+ if (config.response) {
87
+ responseQueue = [{ text: config.response, stopReason: "end_turn" }];
88
+ } else if (config.responses) {
89
+ responseQueue = config.responses;
90
+ }
91
+
92
+ const defaultUsage = config.usage || { inputTokens: 10, outputTokens: 20 };
93
+
94
+ const stream = async function* (params: {
95
+ instructions: string;
96
+ messages: LLMMessage[];
97
+ tools: any[];
98
+ maxTokens: number;
99
+ temperature: number;
100
+ }): AsyncGenerator<LLMStreamChunk> {
101
+ // Track invocation
102
+ invocations.push({
103
+ instructions: params.instructions,
104
+ messages: params.messages,
105
+ tools: params.tools,
106
+ maxTokens: params.maxTokens,
107
+ temperature: params.temperature,
108
+ });
109
+
110
+ // Simulate error if configured
111
+ if (config.error) {
112
+ throw config.error;
113
+ }
114
+
115
+ // Get next response from queue
116
+ const responseConfig = responseQueue[currentResponseIndex] || responseQueue[responseQueue.length - 1];
117
+ if (!responseConfig) {
118
+ throw new Error("No responses configured for mockLLMAdapter");
119
+ }
120
+
121
+ currentResponseIndex++;
122
+
123
+ // Use explicit stream chunks if provided
124
+ if (responseConfig.streamChunks) {
125
+ for (const chunk of responseConfig.streamChunks) {
126
+ yield chunk;
127
+ }
128
+ return;
129
+ }
130
+
131
+ // Use default stream chunks if provided
132
+ if (config.defaultStreamChunks) {
133
+ for (const chunk of config.defaultStreamChunks) {
134
+ yield chunk;
135
+ }
136
+ return;
137
+ }
138
+
139
+ // Otherwise, generate chunks from response
140
+ if (responseConfig.text) {
141
+ yield {
142
+ type: "text",
143
+ delta: responseConfig.text
144
+ };
145
+ }
146
+
147
+ for (const toolCall of responseConfig.toolCalls || []) {
148
+ yield { type: "tool_call", toolCall };
149
+ }
150
+
151
+ yield {
152
+ type: "usage",
153
+ usage: defaultUsage,
154
+ };
155
+
156
+ // Determine stop reason
157
+ let stopReason: "end_turn" | "tool_use" | "max_tokens" = "end_turn";
158
+ if (responseConfig.stopReason) {
159
+ stopReason = responseConfig.stopReason;
160
+ } else if (responseConfig.toolCalls && responseConfig.toolCalls.length > 0) {
161
+ stopReason = "tool_use";
162
+ }
163
+
164
+ yield {
165
+ type: "done",
166
+ stopReason,
167
+ };
168
+ };
169
+
170
+ const reset = (): void => {
171
+ invocations.length = 0;
172
+ currentResponseIndex = 0;
173
+ };
174
+
175
+ const getLastInvocation = (): MockLLMAdapterInvocation | undefined => {
176
+ return invocations[invocations.length - 1];
177
+ };
178
+
179
+ const getInvocationCount = (): number => {
180
+ return invocations.length;
181
+ };
182
+
183
+ return {
184
+ stream,
185
+ invocations,
186
+ reset,
187
+ getLastInvocation,
188
+ getInvocationCount,
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Helper function to create streaming chunks for testing
194
+ *
195
+ * @example
196
+ * const chunks = createStreamingChunks({
197
+ * text: "Hello world",
198
+ * chunkText: true,
199
+ * toolCalls: [{ id: "1", name: "search", input: { query: "test" } }]
200
+ * });
201
+ */
202
+ export function createStreamingChunks(config: {
203
+ text?: string;
204
+ toolCalls?: Array<{ id: string; name: string; input: any }>;
205
+ chunkText?: boolean; // Split text into multiple deltas
206
+ usage?: { inputTokens: number; outputTokens: number };
207
+ stopReason?: "end_turn" | "tool_use" | "max_tokens";
208
+ }): LLMStreamChunk[] {
209
+ const chunks: LLMStreamChunk[] = [];
210
+
211
+ if (config.text) {
212
+ if (config.chunkText) {
213
+ // Split into multiple text deltas (by words)
214
+ const words = config.text.split(' ');
215
+ for (const word of words) {
216
+ chunks.push({ type: "text", delta: word + ' ' });
217
+ }
218
+ } else {
219
+ chunks.push({ type: "text", delta: config.text });
220
+ }
221
+ }
222
+
223
+ for (const toolCall of config.toolCalls || []) {
224
+ chunks.push({ type: "tool_call", toolCall });
225
+ }
226
+
227
+ chunks.push({
228
+ type: "usage",
229
+ usage: config.usage || { inputTokens: 100, outputTokens: 50 },
230
+ });
231
+
232
+ chunks.push({
233
+ type: "done",
234
+ stopReason: config.stopReason || (config.toolCalls && config.toolCalls.length > 0 ? "tool_use" : "end_turn"),
235
+ });
236
+
237
+ return chunks;
238
+ }