From 4d7bf5b24586119436ead7d4189fbef6b2c5d678 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Mon, 29 Dec 2025 09:50:30 +0800 Subject: [PATCH] test(embedding-client): add unit tests for embedding generation Solution-ID: SOL-1735386000004 Issue-ID: ISS-1766921318981-18 Task-ID: T2 --- ccw/tests/embedding-client.test.ts | 196 +++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 ccw/tests/embedding-client.test.ts diff --git a/ccw/tests/embedding-client.test.ts b/ccw/tests/embedding-client.test.ts new file mode 100644 index 00000000..d9720622 --- /dev/null +++ b/ccw/tests/embedding-client.test.ts @@ -0,0 +1,196 @@ +/** + * Unit tests for LiteLLM embedding client bridge (ccw/dist/tools/litellm-client.js). + * + * Notes: + * - Targets the runtime implementation shipped in `ccw/dist`. + * - Stubs `child_process.spawn` to prevent real Python execution. + * - Uses Node's built-in test runner (node:test). + */ + +import { after, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const childProcess = require('node:child_process') as typeof import('node:child_process'); + +type SpawnBehavior = + | { type: 'close'; code?: number; stdout?: string; stderr?: string } + | { type: 'error'; error: Error } + | { type: 'hang' }; + +type SpawnCall = { + command: string; + args: string[]; + options: unknown; + proc: FakeChildProcess; +}; + +class FakeChildProcess extends EventEmitter { + stdout = new EventEmitter(); + stderr = new EventEmitter(); + killCalls: Array = []; + + kill(signal?: string): boolean { + this.killCalls.push(signal); + return true; + } +} + +const spawnCalls: SpawnCall[] = []; +const spawnPlan: SpawnBehavior[] = []; + +const originalSpawn = childProcess.spawn; + +childProcess.spawn = ((command: string, args: string[] = [], options: unknown = {}) => { + const proc = new FakeChildProcess(); + const call: SpawnCall = { + command: String(command), + args: args.map(String), + options, + proc, + }; + spawnCalls.push(call); + + const next = spawnPlan.shift() ?? { type: 'close', code: 0, stdout: '' }; + + queueMicrotask(() => { + if (next.type === 'error') { + proc.emit('error', next.error); + return; + } + + if (next.type === 'close') { + if (next.stdout !== undefined) proc.stdout.emit('data', next.stdout); + if (next.stderr !== undefined) proc.stderr.emit('data', next.stderr); + proc.emit('close', next.code ?? 0); + return; + } + + // hang: intentionally do nothing (used for timeout tests) + }); + + return proc as any; +}) as any; + +const litellmClientUrl = new URL('../dist/tools/litellm-client.js', import.meta.url); +litellmClientUrl.searchParams.set('t', String(Date.now())); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let mod: any; + +beforeEach(async () => { + spawnCalls.length = 0; + spawnPlan.length = 0; + + mod = await import(litellmClientUrl.href); +}); + +describe('LiteLLMClient (embedding)', () => { + it('embed constructs the expected Python CLI invocation and parses vectors', async () => { + spawnPlan.push({ type: 'close', code: 0, stdout: JSON.stringify([[1, 2, 3]]) }); + + const client = new mod.LiteLLMClient({ pythonPath: 'python', timeout: 123 }); + const res = await client.embed(['hello'], 'test-model'); + + assert.equal(res.model, 'test-model'); + assert.equal(res.dimensions, 3); + assert.deepEqual(res.vectors, [[1, 2, 3]]); + + assert.equal(spawnCalls.length, 1); + assert.equal(spawnCalls[0].command, 'python'); + assert.deepEqual(spawnCalls[0].args, [ + '-m', + 'ccw_litellm.cli', + 'embed', + '--model', + 'test-model', + '--output', + 'json', + 'hello', + ]); + }); + + it('embed supports multiple texts (batch via CLI args)', async () => { + spawnPlan.push({ type: 'close', code: 0, stdout: JSON.stringify([[0, 0], [1, 1]]) }); + + const client = new mod.LiteLLMClient({ pythonPath: 'py' }); + const res = await client.embed(['a', 'b']); + + assert.equal(res.model, 'default'); + assert.equal(res.dimensions, 2); + assert.deepEqual(res.vectors, [[0, 0], [1, 1]]); + + assert.equal(spawnCalls.length, 1); + assert.equal(spawnCalls[0].command, 'py'); + assert.ok(spawnCalls[0].args.includes('a')); + assert.ok(spawnCalls[0].args.includes('b')); + }); + + it('isAvailable returns true when version succeeds', async () => { + spawnPlan.push({ type: 'close', code: 0, stdout: '1.2.3' }); + + const client = new mod.LiteLLMClient({ pythonPath: 'python' }); + assert.equal(await client.isAvailable(), true); + + assert.equal(spawnCalls.length, 1); + assert.deepEqual(spawnCalls[0].args, ['-m', 'ccw_litellm.cli', 'version']); + }); + + it('getStatus returns version when available and returns error when unavailable', async () => { + spawnPlan.push({ type: 'close', code: 0, stdout: '9.9.9' }); + + const client = new mod.LiteLLMClient({ pythonPath: 'python' }); + const ok = await client.getStatus(); + assert.deepEqual(ok, { available: true, version: '9.9.9' }); + + spawnPlan.push({ type: 'error', error: new Error('spawn ENOENT') }); + const bad = await client.getStatus(); + assert.equal(bad.available, false); + assert.ok(String(bad.error).includes('Failed to spawn Python process: spawn ENOENT')); + }); + + it('embed throws clear errors for invalid input', async () => { + const client = new mod.LiteLLMClient(); + + await assert.rejects( + client.embed([]), + (err: any) => err instanceof Error && err.message.includes('texts array cannot be empty'), + ); + + await assert.rejects( + client.embed(null as any), + (err: any) => err instanceof Error && err.message.includes('texts array cannot be empty'), + ); + }); + + it('embed times out and terminates the spawned process', async () => { + spawnPlan.push({ type: 'hang' }); + + const client = new mod.LiteLLMClient({ pythonPath: 'python', timeout: 5 }); + await assert.rejects( + client.embed(['slow']), + (err: any) => err instanceof Error && err.message.includes('Command timed out after 10ms'), + ); + + assert.equal(spawnCalls.length, 1); + assert.deepEqual(spawnCalls[0].proc.killCalls, ['SIGTERM']); + }); + + it('propagates stderr when the Python process exits non-zero', async () => { + spawnPlan.push({ type: 'close', code: 2, stderr: 'boom' }); + + const client = new mod.LiteLLMClient({ pythonPath: 'python', timeout: 50 }); + await assert.rejects( + client.embed(['x']), + (err: any) => err instanceof Error && err.message.includes('boom'), + ); + }); +}); + +after(() => { + childProcess.spawn = originalSpawn; +}); +