@kb-labs/shared-testing 1.0.0

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.
package/README.md ADDED
@@ -0,0 +1,430 @@
1
+ # @kb-labs/shared-testing
2
+
3
+ Test utilities for KB Labs plugin development — mock builders, platform setup, test context.
4
+
5
+ ## Problem
6
+
7
+ Testing plugins is hard because of the **singleton gap**: composables like `useLLM()`, `useCache()`, `useLogger()` read from the global platform singleton (`@kb-labs/core-runtime`), but `createTestContext()` only populates `ctx.platform`. Code inside handlers that uses composables gets the uninitialized singleton instead of test mocks.
8
+
9
+ Additionally, existing mocks are noop functions — no `vi.fn()` spies, no way to configure specific responses, no call tracking.
10
+
11
+ ## Solution
12
+
13
+ This package provides:
14
+
15
+ - **`setupTestPlatform()`** — bridges `ctx.platform` and the global singleton
16
+ - **`mockLLM()`** — fluent builder with prompt matching, streaming, error simulation, tool calls
17
+ - **`mockCache()`** — working in-memory cache with TTL and sorted sets
18
+ - **`mockStorage()`** — virtual filesystem on `Map<string, Buffer>`
19
+ - **`mockLogger()`** — logger with message recording
20
+ - **`createTestContext()`** — enhanced context factory that syncs everything automatically
21
+
22
+ All methods are `vi.fn()` spies — you get full assertion capabilities out of the box.
23
+
24
+ ## Installation
25
+
26
+ Available as workspace dependency:
27
+
28
+ ```json
29
+ {
30
+ "devDependencies": {
31
+ "@kb-labs/shared-testing": "link:../../../kb-labs-shared/packages/shared-testing"
32
+ }
33
+ }
34
+ ```
35
+
36
+ Or via SDK re-export:
37
+
38
+ ```typescript
39
+ import { mockLLM, createTestContext } from '@kb-labs/sdk/testing';
40
+ ```
41
+
42
+ ## Quick Start
43
+
44
+ ```typescript
45
+ import { createTestContext, mockLLM } from '@kb-labs/sdk/testing';
46
+ import { useLLM } from '@kb-labs/sdk';
47
+ import { describe, it, expect, afterEach } from 'vitest';
48
+
49
+ describe('my handler', () => {
50
+ let cleanup: () => void;
51
+
52
+ afterEach(() => cleanup());
53
+
54
+ it('generates a commit message', async () => {
55
+ const llm = mockLLM()
56
+ .onComplete('commit').respondWith('feat: add login page')
57
+ .onAnyComplete().respondWith('ok');
58
+
59
+ const { ctx, cleanup: c } = createTestContext({ platform: { llm } });
60
+ cleanup = c;
61
+
62
+ // Both work — same mock instance:
63
+ expect(ctx.platform.llm).toBe(llm);
64
+ expect(useLLM()).toBe(llm); // singleton gap solved!
65
+
66
+ const res = await ctx.platform.llm.complete('Generate commit message');
67
+ expect(res.content).toBe('feat: add login page');
68
+ expect(llm.complete).toHaveBeenCalledOnce();
69
+ });
70
+ });
71
+ ```
72
+
73
+ ## API Reference
74
+
75
+ ### `setupTestPlatform(options)`
76
+
77
+ Sets mock adapters into the global platform singleton. Call `cleanup()` in `afterEach()`.
78
+
79
+ ```typescript
80
+ import { setupTestPlatform, mockLLM, mockCache } from '@kb-labs/shared-testing';
81
+
82
+ const { platform, cleanup } = setupTestPlatform({
83
+ llm: mockLLM(),
84
+ cache: mockCache(),
85
+ });
86
+
87
+ // Now useLLM() and useCache() return these mocks
88
+ ```
89
+
90
+ **Options:** `llm`, `cache`, `embeddings`, `vectorStore`, `storage`, `analytics`, `logger`, `eventBus` — all optional.
91
+
92
+ ### `mockLLM()`
93
+
94
+ Fluent builder that creates an `ILLM` instance with `vi.fn()` spies and call tracking.
95
+
96
+ #### Prompt matching
97
+
98
+ ```typescript
99
+ const llm = mockLLM()
100
+ // String match (substring)
101
+ .onComplete('commit').respondWith('feat: add feature')
102
+ // Regex match
103
+ .onComplete(/explain/i).respondWith('This code does X')
104
+ // Function match
105
+ .onComplete(p => p.length > 100).respondWith('Long prompt handled')
106
+ // Default fallback
107
+ .onAnyComplete().respondWith('default answer');
108
+ ```
109
+
110
+ #### Dynamic responses
111
+
112
+ ```typescript
113
+ const llm = mockLLM()
114
+ .onAnyComplete().respondWith(prompt => `Echo: ${prompt}`);
115
+
116
+ // Or return a full LLMResponse object
117
+ const llm = mockLLM()
118
+ .onAnyComplete().respondWith({
119
+ content: 'hello',
120
+ model: 'gpt-4',
121
+ usage: { promptTokens: 10, completionTokens: 5 },
122
+ });
123
+ ```
124
+
125
+ #### Streaming
126
+
127
+ ```typescript
128
+ const llm = mockLLM().streaming(['chunk1', ' ', 'chunk2']);
129
+
130
+ for await (const chunk of llm.stream('test')) {
131
+ console.log(chunk); // 'chunk1', ' ', 'chunk2'
132
+ }
133
+ ```
134
+
135
+ #### Error simulation
136
+
137
+ ```typescript
138
+ const llm = mockLLM().failing(new Error('rate limit exceeded'));
139
+
140
+ await llm.complete('test'); // throws Error('rate limit exceeded')
141
+ ```
142
+
143
+ #### Tool calling
144
+
145
+ ```typescript
146
+ const llm = mockLLM().withToolCalls([
147
+ { id: 'call-1', name: 'search', input: { query: 'test' } },
148
+ ]);
149
+
150
+ const res = await llm.chatWithTools!(messages, { tools });
151
+ expect(res.toolCalls).toHaveLength(1);
152
+ expect(res.toolCalls![0].name).toBe('search');
153
+ ```
154
+
155
+ #### Call tracking
156
+
157
+ ```typescript
158
+ const llm = mockLLM();
159
+ await llm.complete('hello');
160
+ await llm.complete('world');
161
+
162
+ // vi.fn() assertions
163
+ expect(llm.complete).toHaveBeenCalledTimes(2);
164
+ expect(llm.complete).toHaveBeenCalledWith('hello');
165
+
166
+ // Structured call history
167
+ expect(llm.calls).toHaveLength(2);
168
+ expect(llm.calls[0].prompt).toBe('hello');
169
+ expect(llm.calls[0].response.content).toBe('mock response');
170
+ expect(llm.lastCall?.prompt).toBe('world');
171
+
172
+ // Tool call history
173
+ expect(llm.toolCalls).toHaveLength(0);
174
+
175
+ // Reset everything
176
+ llm.resetCalls();
177
+ expect(llm.calls).toHaveLength(0);
178
+ expect(llm.complete).not.toHaveBeenCalled();
179
+ ```
180
+
181
+ ### `mockCache()`
182
+
183
+ Working in-memory cache with TTL support and sorted sets. All methods are `vi.fn()` spies.
184
+
185
+ ```typescript
186
+ const cache = mockCache();
187
+
188
+ await cache.set('key', { data: 123 }, 5000); // TTL: 5 seconds
189
+ const val = await cache.get('key');
190
+ expect(val).toEqual({ data: 123 });
191
+
192
+ // Sorted sets
193
+ await cache.zadd('scores', 100, 'alice');
194
+ await cache.zadd('scores', 200, 'bob');
195
+ const top = await cache.zrangebyscore('scores', 0, 150);
196
+ expect(top).toEqual(['alice']);
197
+
198
+ // Spy assertions
199
+ expect(cache.set).toHaveBeenCalledWith('key', { data: 123 }, 5000);
200
+
201
+ // Direct state access
202
+ expect(cache.data.size).toBe(1);
203
+
204
+ // Reset
205
+ cache.reset();
206
+ expect(cache.data.size).toBe(0);
207
+ ```
208
+
209
+ ### `mockStorage()`
210
+
211
+ Virtual filesystem backed by `Map<string, Buffer>`.
212
+
213
+ ```typescript
214
+ // Initialize with files
215
+ const storage = mockStorage({
216
+ 'config.json': '{"key": "value"}',
217
+ 'data.bin': Buffer.from([0x01, 0x02]),
218
+ });
219
+
220
+ const content = await storage.read('config.json');
221
+ expect(content).toBe('{"key": "value"}');
222
+
223
+ await storage.write('new.txt', 'hello');
224
+ expect(await storage.exists('new.txt')).toBe(true);
225
+
226
+ const files = await storage.list('/');
227
+ expect(files).toContain('new.txt');
228
+
229
+ // Direct state access
230
+ expect(storage.files.size).toBe(3);
231
+ ```
232
+
233
+ ### `mockLogger()`
234
+
235
+ Logger with message recording and `vi.fn()` spies.
236
+
237
+ ```typescript
238
+ const logger = mockLogger();
239
+
240
+ logger.info('Server started', { port: 3000 });
241
+ logger.error('Connection failed', new Error('timeout'));
242
+ logger.warn('Deprecated API');
243
+
244
+ // Message history
245
+ expect(logger.messages).toHaveLength(3);
246
+ expect(logger.messages[0]).toEqual({
247
+ level: 'info',
248
+ message: 'Server started',
249
+ meta: { port: 3000 },
250
+ });
251
+
252
+ // Spy assertions
253
+ expect(logger.info).toHaveBeenCalledWith('Server started', { port: 3000 });
254
+ expect(logger.error).toHaveBeenCalledWith('Connection failed', expect.any(Error));
255
+
256
+ // Child loggers share the same messages array
257
+ const child = logger.child({ service: 'db' });
258
+ child.info('Connected');
259
+ expect(logger.messages).toHaveLength(4);
260
+ ```
261
+
262
+ ### `testCommand(handler, options)`
263
+
264
+ Single-function test runner for plugin command handlers. No boilerplate — import your handler, call `testCommand()`, assert on result.
265
+
266
+ Works with any handler type: `CommandHandlerV3` (from `defineCommand`), `RouteHandler` (from `defineRoute`), `Handler` (from `defineHandler`), or any object with `execute(ctx, input)`.
267
+
268
+ #### CLI command
269
+
270
+ ```typescript
271
+ import { testCommand } from '@kb-labs/sdk/testing';
272
+ import handler from '../src/commands/greet.js';
273
+
274
+ it('greets the user', async () => {
275
+ const result = await testCommand(handler, {
276
+ flags: { name: 'Alice' },
277
+ });
278
+
279
+ expect(result.exitCode).toBe(0);
280
+ expect(result.result).toEqual({ message: 'Hello, Alice!' });
281
+ expect(result.ui.success).toHaveBeenCalledWith('Hello, Alice!');
282
+ result.cleanup();
283
+ });
284
+ ```
285
+
286
+ #### With LLM mock
287
+
288
+ ```typescript
289
+ import { testCommand, mockLLM } from '@kb-labs/sdk/testing';
290
+ import handler from '../src/commands/analyze.js';
291
+
292
+ it('analyzes file with LLM', async () => {
293
+ const llm = mockLLM().onAnyComplete().respondWith('looks good');
294
+
295
+ const result = await testCommand(handler, {
296
+ flags: { file: 'index.ts' },
297
+ platform: { llm },
298
+ });
299
+
300
+ expect(result.exitCode).toBe(0);
301
+ expect(llm.complete).toHaveBeenCalled();
302
+ result.cleanup();
303
+ });
304
+ ```
305
+
306
+ #### REST handler
307
+
308
+ ```typescript
309
+ import { testCommand } from '@kb-labs/sdk/testing';
310
+ import handler from '../src/routes/create-plan.js';
311
+
312
+ it('creates a plan', async () => {
313
+ const result = await testCommand(handler, {
314
+ host: 'rest',
315
+ body: { name: 'v2.0' },
316
+ query: { workspace: 'root' },
317
+ });
318
+
319
+ expect(result.result).toMatchObject({ name: 'v2.0' });
320
+ result.cleanup();
321
+ });
322
+ ```
323
+
324
+ #### Raw input (custom shape)
325
+
326
+ ```typescript
327
+ const result = await testCommand(handler, {
328
+ input: { custom: 'data', items: [1, 2, 3] },
329
+ });
330
+ ```
331
+
332
+ **Options:**
333
+
334
+ | Option | Type | Default | Description |
335
+ |--------|------|---------|-------------|
336
+ | `flags` | `Record<string, unknown>` | `{}` | CLI flags (builds `{ flags, argv }` input) |
337
+ | `argv` | `string[]` | `[]` | CLI positional arguments |
338
+ | `query` | `Record<string, unknown>` | — | REST query params (builds `{ query, body, params }` input) |
339
+ | `body` | `unknown` | — | REST request body |
340
+ | `params` | `Record<string, unknown>` | — | REST route params |
341
+ | `input` | `unknown` | — | Raw input (overrides flags/query/body) |
342
+ | `host` | `'cli' \| 'rest' \| 'workflow' \| 'webhook'` | `'cli'` | Host type |
343
+ | `config` | `TConfig` | `undefined` | Plugin config (`ctx.config`) |
344
+ | `cwd` | `string` | `process.cwd()` | Working directory |
345
+ | `tenantId` | `string` | `undefined` | Tenant identifier |
346
+ | `signal` | `AbortSignal` | `undefined` | Abort signal |
347
+ | `platform` | `Partial<PlatformServices>` | all mocks | Override platform services |
348
+ | `ui` | `Partial<UIFacade>` | all vi.fn() | Override UI methods |
349
+ | `syncSingleton` | `boolean` | `true` | Sync to global singleton |
350
+
351
+ **Result (`TestCommandResult`):**
352
+
353
+ | Field | Type | Description |
354
+ |-------|------|-------------|
355
+ | `exitCode` | `number` | Exit code (0 = success), extracted from `CommandResult` or 0 for raw data |
356
+ | `result` | `TResult \| undefined` | Data from `CommandResult.result` or raw return value |
357
+ | `meta` | `Record<string, unknown> \| undefined` | Custom metadata from `CommandResult.meta` |
358
+ | `raw` | `unknown` | Unprocessed return value from `handler.execute()` |
359
+ | `ui` | `UIFacade` | UI facade with `vi.fn()` spies on all methods |
360
+ | `ctx` | `PluginContextV3` | Full context passed to the handler |
361
+ | `cleanup` | `() => void` | Call in `afterEach()` to reset global singleton |
362
+
363
+ ### `createTestContext(options)`
364
+
365
+ Enhanced test context factory. Replaces the legacy SDK `createTestContext()`.
366
+
367
+ ```typescript
368
+ const { ctx, cleanup } = createTestContext({
369
+ pluginId: 'my-plugin',
370
+ host: 'cli',
371
+ config: { apiKey: 'test' },
372
+ platform: {
373
+ llm: mockLLM().onAnyComplete().respondWith('ok'),
374
+ cache: mockCache(),
375
+ },
376
+ });
377
+
378
+ // ctx.platform has the mocks
379
+ await ctx.platform.llm.complete('test');
380
+
381
+ // Global singleton also has them (syncSingleton defaults to true)
382
+ const llm = useLLM();
383
+ expect(llm).toBe(ctx.platform.llm);
384
+
385
+ // UI methods are vi.fn() spies too
386
+ ctx.ui.info('hello');
387
+ expect(ctx.ui.info).toHaveBeenCalledWith('hello');
388
+
389
+ // Runtime, API, trace — all mocked
390
+ await ctx.runtime.fs.readFile('test.txt');
391
+ expect(ctx.runtime.fs.readFile).toHaveBeenCalled();
392
+
393
+ // ALWAYS call cleanup in afterEach()
394
+ cleanup();
395
+ ```
396
+
397
+ **Options:**
398
+
399
+ | Option | Type | Default | Description |
400
+ |--------|------|---------|-------------|
401
+ | `pluginId` | `string` | `'test-plugin'` | Plugin identifier |
402
+ | `pluginVersion` | `string` | `'0.0.0'` | Plugin version |
403
+ | `host` | `'cli' \| 'rest' \| 'workflow' \| 'webhook'` | `'cli'` | Host type |
404
+ | `hostContext` | `HostContext` | auto-generated | Override host context |
405
+ | `config` | `unknown` | `undefined` | Plugin config |
406
+ | `cwd` | `string` | `process.cwd()` | Working directory |
407
+ | `outdir` | `string` | `{cwd}/.kb/output` | Output directory |
408
+ | `tenantId` | `string` | `undefined` | Tenant identifier |
409
+ | `signal` | `AbortSignal` | `undefined` | Abort signal |
410
+ | `platform` | `Partial<PlatformServices>` | all mocks | Override platform adapters |
411
+ | `ui` | `Partial<UIFacade>` | all vi.fn() | Override UI methods |
412
+ | `syncSingleton` | `boolean` | `true` | Sync to global singleton |
413
+
414
+ ## Architecture
415
+
416
+ ```
417
+ @kb-labs/shared-testing
418
+ ├── setup-platform.ts ← resetPlatform() + setAdapter() for each mock
419
+ ├── mock-llm.ts ← Proxy-based builder + ILLM instance
420
+ ├── mock-cache.ts ← In-memory Map with TTL + sorted sets
421
+ ├── mock-storage.ts ← Virtual FS on Map<string, Buffer>
422
+ ├── mock-logger.ts ← Message recording + child logger support
423
+ ├── create-test-context.ts ← PluginContextV3 factory + singleton sync
424
+ ├── test-command.ts ← testCommand() — single-function handler runner
425
+ └── index.ts ← Barrel exports
426
+
427
+ @kb-labs/sdk/testing ← Thin re-export layer
428
+ ```
429
+
430
+ The key insight: `setupTestPlatform()` calls `resetPlatform()` (clears the global singleton) then `platform.setAdapter()` for each provided mock. This ensures `useLLM()`, `useCache()`, etc. return the test mocks. `createTestContext()` calls `setupTestPlatform()` automatically when `syncSingleton: true` (the default).