diff --git a/coverage/coverage-summary.json b/coverage/coverage-summary.json new file mode 100644 index 0000000..379906f --- /dev/null +++ b/coverage/coverage-summary.json @@ -0,0 +1,9 @@ +{"total": {"lines":{"total":256,"covered":140,"skipped":0,"pct":54.68},"statements":{"total":268,"covered":148,"skipped":0,"pct":55.22},"functions":{"total":59,"covered":35,"skipped":0,"pct":59.32},"branches":{"total":208,"covered":95,"skipped":0,"pct":45.67},"branchesTrue":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/mnt/ext2/code/prompts/github/forks/bob-plugin-openai-translator/src/lang.ts": {"lines":{"total":3,"covered":3,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":3,"covered":3,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"/mnt/ext2/code/prompts/github/forks/bob-plugin-openai-translator/src/adapter/base.ts": {"lines":{"total":52,"covered":30,"skipped":0,"pct":57.69},"functions":{"total":17,"covered":11,"skipped":0,"pct":64.7},"statements":{"total":52,"covered":30,"skipped":0,"pct":57.69},"branches":{"total":25,"covered":10,"skipped":0,"pct":40}} +,"/mnt/ext2/code/prompts/github/forks/bob-plugin-openai-translator/src/adapter/openai.ts": {"lines":{"total":97,"covered":62,"skipped":0,"pct":63.91},"functions":{"total":21,"covered":14,"skipped":0,"pct":66.66},"statements":{"total":98,"covered":62,"skipped":0,"pct":63.26},"branches":{"total":101,"covered":62,"skipped":0,"pct":61.38}} +,"/mnt/ext2/code/prompts/github/forks/bob-plugin-openai-translator/src/utils/error.ts": {"lines":{"total":31,"covered":22,"skipped":0,"pct":70.96},"functions":{"total":6,"covered":6,"skipped":0,"pct":100},"statements":{"total":34,"covered":25,"skipped":0,"pct":73.52},"branches":{"total":30,"covered":22,"skipped":0,"pct":73.33}} +,"/mnt/ext2/code/prompts/github/forks/bob-plugin-openai-translator/src/utils/model-capabilities.ts": {"lines":{"total":19,"covered":7,"skipped":0,"pct":36.84},"functions":{"total":7,"covered":0,"skipped":0,"pct":0},"statements":{"total":25,"covered":10,"skipped":0,"pct":40},"branches":{"total":11,"covered":0,"skipped":0,"pct":0}} +,"/mnt/ext2/code/prompts/github/forks/bob-plugin-openai-translator/src/utils/prompt.ts": {"lines":{"total":28,"covered":4,"skipped":0,"pct":14.28},"functions":{"total":2,"covered":0,"skipped":0,"pct":0},"statements":{"total":30,"covered":6,"skipped":0,"pct":20},"branches":{"total":29,"covered":0,"skipped":0,"pct":0}} +,"/mnt/ext2/code/prompts/github/forks/bob-plugin-openai-translator/src/utils/sse.ts": {"lines":{"total":26,"covered":12,"skipped":0,"pct":46.15},"functions":{"total":5,"covered":3,"skipped":0,"pct":60},"statements":{"total":26,"covered":12,"skipped":0,"pct":46.15},"branches":{"total":12,"covered":1,"skipped":0,"pct":8.33}} +} diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..722dd79 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,15 @@ +/** @type {import("jest").Config} **/ +export default { + testEnvironment: "node", + transform: { + "^.+\\.(ts|tsx)$": [ + "ts-jest", + { + tsconfig: "./tsconfig.jest.json" + } + ] + }, + transformIgnorePatterns: [ + "/node_modules/" + ] +}; \ No newline at end of file diff --git a/package.json b/package.json index e26a18e..f38b0a9 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,9 @@ "@biomejs/biome": "2.4.15", "@bob-translate/types": "1.1.0", "@types/bun": "latest", + "@types/jest": "^30.0.0", + "jest": "^30.4.2", + "ts-jest": "^29.4.10", "typescript": "5.9.3" }, "engines": { diff --git a/src/adapter/__tests__/minimax.integration.test.ts b/src/adapter/__tests__/minimax.integration.test.ts deleted file mode 100644 index 545106d..0000000 --- a/src/adapter/__tests__/minimax.integration.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { describe, expect, it } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; - -// Load API key from env file -function loadApiKey(): string { - try { - const envPath = resolve(process.env.HOME || '', 'github_pr/.env.local'); - const content = readFileSync(envPath, 'utf-8'); - const match = content.match(/MINIMAX_API_KEY=(.+)/); - return match?.[1]?.trim() || ''; - } catch { - return process.env.MINIMAX_API_KEY || ''; - } -} - -const MINIMAX_API_KEY = loadApiKey(); -const SKIP = !MINIMAX_API_KEY; - -// @ts-expect-error - Bun supports describe with options -describe('MiniMax API integration', { timeout: 30000 }, () => { - it.skipIf(SKIP)( - 'should complete a translation request via Chat Completions API', - async () => { - const response = await fetch( - 'https://api.minimax.io/v1/chat/completions', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${MINIMAX_API_KEY}`, - }, - body: JSON.stringify({ - model: 'MiniMax-M2.7', - messages: [ - { - role: 'system', - content: - 'You are a translation engine. Translate the user text to Chinese. Output the translation only.', - }, - { role: 'user', content: 'Hello, world!' }, - ], - temperature: 0.2, - stream: false, - }), - }, - ); - - expect(response.ok).toBe(true); - const data = (await response.json()) as { - choices: Array<{ - message: { content: string }; - }>; - }; - expect(data.choices).toBeDefined(); - expect(data.choices.length).toBeGreaterThan(0); - const content = data.choices[0].message.content; - expect(content).toBeTruthy(); - // Should contain Chinese characters - expect(/[\u4e00-\u9fff]/.test(content)).toBe(true); - }, - ); - - it.skipIf(SKIP)( - 'should complete a streaming request via Chat Completions API', - async () => { - const response = await fetch( - 'https://api.minimax.io/v1/chat/completions', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${MINIMAX_API_KEY}`, - }, - body: JSON.stringify({ - model: 'MiniMax-M2.7-highspeed', - messages: [ - { - role: 'system', - content: - 'You are a translation engine. Translate to English. Output only the translation.', - }, - { role: 'user', content: '你好世界' }, - ], - temperature: 0.5, - stream: true, - }), - }, - ); - - expect(response.ok).toBe(true); - expect(response.headers.get('content-type')).toContain( - 'text/event-stream', - ); - - const reader = response.body?.getReader(); - expect(reader).toBeDefined(); - - let fullText = ''; - const decoder = new TextDecoder(); - while (reader) { - const { done, value } = await reader.read(); - if (done) break; - const chunk = decoder.decode(value, { stream: true }); - const lines = chunk.split('\n'); - for (const line of lines) { - if (line.startsWith('data: ') && !line.includes('[DONE]')) { - try { - const parsed = JSON.parse(line.slice(6)) as { - choices: Array<{ - delta?: { content?: string }; - }>; - }; - const delta = parsed.choices?.[0]?.delta?.content; - if (delta) fullText += delta; - } catch { - // ignore parse errors - } - } - } - } - - expect(fullText.length).toBeGreaterThan(0); - }, - ); - - it.skipIf(SKIP)( - 'should validate API connection with a test request', - async () => { - const response = await fetch( - 'https://api.minimax.io/v1/chat/completions', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${MINIMAX_API_KEY}`, - }, - body: JSON.stringify({ - model: 'MiniMax-M2.7', - messages: [ - { role: 'user', content: "Test connectivity. Reply with 'OK'." }, - ], - max_tokens: 10, - temperature: 1.0, - }), - }, - ); - - expect(response.ok).toBe(true); - const data = (await response.json()) as { - choices: Array<{ message: { content: string } }>; - }; - expect(data.choices).toBeDefined(); - expect(data.choices[0].message.content).toBeTruthy(); - }, - ); -}); diff --git a/src/adapter/__tests__/minimax.test.ts b/src/adapter/__tests__/minimax.test.ts deleted file mode 100644 index 0fe636c..0000000 --- a/src/adapter/__tests__/minimax.test.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { beforeEach, describe, expect, it, mock } from 'bun:test'; - -// Mock Bob globals before importing adapter -const mockOption: Record = {}; -const mockHttpResponses: Array<{ - data?: unknown; - error?: unknown; - response?: { statusCode: number }; -}> = []; - -// @ts-expect-error - Mock Bob global -globalThis.$option = new Proxy(mockOption, { - get: (_target, prop) => mockOption[prop as string], -}); - -// @ts-expect-error - Mock Bob global -globalThis.$http = { - request: mock(async () => mockHttpResponses.shift()), - streamRequest: mock(async () => {}), -}; - -import { getServiceAdapter } from '../index'; -import { MiniMaxAdapter } from '../minimax'; - -describe('MiniMaxAdapter', () => { - beforeEach(() => { - // Reset options - for (const key of Object.keys(mockOption)) { - delete mockOption[key]; - } - mockHttpResponses.length = 0; - }); - - describe('constructor', () => { - it('should use default MiniMax base URL', () => { - const adapter = new MiniMaxAdapter(); - const url = adapter.getTextGenerationUrl(); - expect(url).toBe('https://api.minimax.io/v1/chat/completions'); - }); - - it('should use custom apiUrl if provided', () => { - mockOption.apiUrl = 'https://api.minimaxi.com'; - const adapter = new MiniMaxAdapter(); - const url = adapter.getTextGenerationUrl(); - expect(url).toBe('https://api.minimaxi.com/v1/chat/completions'); - }); - - it('should use custom apiPath if provided', () => { - mockOption.apiPath = '/v1/responses'; - const adapter = new MiniMaxAdapter(); - const url = adapter.getTextGenerationUrl(); - expect(url).toBe('https://api.minimax.io/v1/responses'); - }); - }); - - describe('getApiPath', () => { - it('should default to /v1/chat/completions', () => { - const adapter = new MiniMaxAdapter(); - const url = adapter.getTextGenerationUrl(); - expect(url).toContain('/v1/chat/completions'); - }); - }); - - describe('temperature clamping', () => { - it('should clamp temperature 0 to 0.01', () => { - mockOption.temperature = '0'; - mockOption.model = 'MiniMax-M2.7'; - const adapter = new MiniMaxAdapter(); - const body = adapter.buildRequestBody({ - text: 'hello', - detectFrom: 'en', - detectTo: 'zh-Hans', - } as any); - expect(body.temperature).toBe(0.01); - }); - - it('should clamp negative temperature to 0.01', () => { - mockOption.temperature = '-0.5'; - mockOption.model = 'MiniMax-M2.7'; - const adapter = new MiniMaxAdapter(); - const body = adapter.buildRequestBody({ - text: 'hello', - detectFrom: 'en', - detectTo: 'zh-Hans', - } as any); - expect(body.temperature).toBe(0.01); - }); - - it('should clamp temperature above 1 to 1.0', () => { - mockOption.temperature = '1.5'; - mockOption.model = 'MiniMax-M2.7'; - const adapter = new MiniMaxAdapter(); - const body = adapter.buildRequestBody({ - text: 'hello', - detectFrom: 'en', - detectTo: 'zh-Hans', - } as any); - expect(body.temperature).toBe(1.0); - }); - - it('should keep valid temperature unchanged', () => { - mockOption.temperature = '0.5'; - mockOption.model = 'MiniMax-M2.7'; - const adapter = new MiniMaxAdapter(); - const body = adapter.buildRequestBody({ - text: 'hello', - detectFrom: 'en', - detectTo: 'zh-Hans', - } as any); - expect(body.temperature).toBe(0.5); - }); - }); - - describe('buildHeaders', () => { - it('should set Bearer authorization', () => { - const adapter = new MiniMaxAdapter(); - const headers = adapter.buildHeaders('test-api-key'); - expect(headers.Authorization).toBe('Bearer test-api-key'); - expect(headers['Content-Type']).toBe('application/json'); - }); - }); - - describe('buildRequestBody', () => { - it('should build Chat Completions API request body', () => { - mockOption.model = 'MiniMax-M2.7'; - mockOption.temperature = '0.2'; - mockOption.stream = 'enable'; - const adapter = new MiniMaxAdapter(); - const body = adapter.buildRequestBody({ - text: 'hello', - detectFrom: 'en', - detectTo: 'zh-Hans', - } as any); - - expect(body.model).toBe('MiniMax-M2.7'); - expect(body.stream).toBe(true); - expect(body.messages).toBeDefined(); - expect(Array.isArray(body.messages)).toBe(true); - const messages = body.messages as Array<{ role: string }>; - expect(messages[0].role).toBe('system'); - expect(messages[1].role).toBe('user'); - }); - - it('should use custom model name', () => { - mockOption.model = 'custom'; - mockOption.customModel = 'MiniMax-M2.7-highspeed'; - mockOption.temperature = '0.5'; - const adapter = new MiniMaxAdapter(); - const body = adapter.buildRequestBody({ - text: 'hello', - detectFrom: 'en', - detectTo: 'zh-Hans', - } as any); - expect(body.model).toBe('MiniMax-M2.7-highspeed'); - }); - }); - - describe('parseResponse', () => { - it('should parse Chat Completions response', () => { - const adapter = new MiniMaxAdapter(); - const result = adapter.parseResponse({ - data: { - choices: [ - { - message: { - content: ' 你好 ', - }, - }, - ], - }, - rawData: '', - response: { statusCode: 200, headers: {} }, - } as any); - expect(result).toBe('你好'); - }); - - it('should strip think tags from response', () => { - const adapter = new MiniMaxAdapter(); - const result = adapter.parseResponse({ - data: { - choices: [ - { - message: { - content: - '\nLet me translate this.\n\n\n你好,世界!', - }, - }, - ], - }, - rawData: '', - response: { statusCode: 200, headers: {} }, - } as any); - expect(result).toBe('你好,世界!'); - }); - - it('should handle response without think tags', () => { - const adapter = new MiniMaxAdapter(); - const result = adapter.parseResponse({ - data: { - choices: [ - { - message: { - content: '你好,世界!', - }, - }, - ], - }, - rawData: '', - response: { statusCode: 200, headers: {} }, - } as any); - expect(result).toBe('你好,世界!'); - }); - }); - - describe('getServiceAdapter dispatch', () => { - it('should return MiniMaxAdapter for minimax provider', () => { - const adapter = getServiceAdapter('minimax'); - expect(adapter).toBeInstanceOf(MiniMaxAdapter); - }); - }); - - describe('Chat Completions API format', () => { - it('should always use Chat Completions API format by default', () => { - mockOption.model = 'MiniMax-M2.7'; - mockOption.temperature = '0.5'; - const adapter = new MiniMaxAdapter(); - const body = adapter.buildRequestBody({ - text: 'hello', - detectFrom: 'en', - detectTo: 'zh-Hans', - } as any); - // Chat Completions format uses messages array, not instructions/input - expect(body.messages).toBeDefined(); - expect(body.instructions).toBeUndefined(); - expect(body.input).toBeUndefined(); - }); - }); -}); - -describe('MiniMaxAdapter integration', () => { - beforeEach(() => { - for (const key of Object.keys(mockOption)) { - delete mockOption[key]; - } - mockHttpResponses.length = 0; - }); - - it('should validate API connection successfully', async () => { - mockHttpResponses.push({ - data: { - choices: [{ message: { content: 'OK' } }], - }, - response: { statusCode: 200 }, - }); - - const adapter = new MiniMaxAdapter(); - const result = await new Promise<{ result: boolean }>((resolve) => { - adapter.testApiConnection('test-key', '', (completion) => { - resolve(completion as { result: boolean }); - }); - }); - - expect(result.result).toBe(true); - }); - - it('should handle API connection error', async () => { - mockHttpResponses.push({ - data: { - error: { - message: 'Invalid API key', - type: 'invalid_request_error', - }, - }, - response: { statusCode: 401 }, - }); - - const adapter = new MiniMaxAdapter(); - const result = await new Promise<{ result: boolean }>((resolve) => { - adapter.testApiConnection('bad-key', '', (completion) => { - resolve(completion as { result: boolean }); - }); - }); - - expect(result.result).toBe(false); - }); - - it('should build correct full request for translation', () => { - mockOption.model = 'MiniMax-M2.7'; - mockOption.temperature = '0.2'; - mockOption.stream = 'disable'; - - const adapter = new MiniMaxAdapter(); - const headers = adapter.buildHeaders('test-key'); - const body = adapter.buildRequestBody({ - text: 'Hello, world!', - detectFrom: 'en', - detectTo: 'zh-Hans', - } as any); - const url = adapter.getTextGenerationUrl(); - - expect(url).toBe('https://api.minimax.io/v1/chat/completions'); - expect(headers.Authorization).toBe('Bearer test-key'); - expect(body.model).toBe('MiniMax-M2.7'); - expect(body.stream).toBe(false); - expect(body.temperature).toBe(0.2); - }); -}); diff --git a/src/adapter/base.test.ts b/src/adapter/base.test.ts new file mode 100644 index 0000000..fc4f322 --- /dev/null +++ b/src/adapter/base.test.ts @@ -0,0 +1,249 @@ +import type { + HttpResponse, + ServiceError, + TextTranslateQuery, + ValidationCompletion, +} from '@bob-translate/types'; +import type { GeminiResponse, OpenAiResponse, ServiceAdapterConfig } from '../types'; +import { BaseAdapter } from './base'; + +// Jest types for type checking +declare const jest: any; +declare function describe(name: string, fn: () => void): void; +declare function it(name: string, fn: () => void): void; +declare function beforeEach(fn: () => void): void; +declare function afterEach(fn: () => void): void; + +// Mock the global $option variable +const mockOption = { + temperature: 0.2, + stream: false, // boolean value instead of string + model: 'default-model', + customModel: 'custom-model', +}; + +// Mock $http +const mockHttpRequest = jest.fn(); +const mockStreamRequest = jest.fn(); + +// Set up global mocks +Object.defineProperty(global, '$option', { + value: mockOption, + writable: true, +}); + +Object.defineProperty(global, '$http', { + value: { + request: mockHttpRequest, + streamRequest: mockStreamRequest, + }, + writable: true, +}); + +// Mock query object +const mockQuery: TextTranslateQuery = { + detectFrom: 'en', + detectTo: 'zh', + text: 'hello world', + cancelSignal: {}, + onCompletion: jest.fn(), + onError: jest.fn(), +} as any; + +// Create a concrete implementation of BaseAdapter for testing +class TestAdapter extends BaseAdapter { + constructor(config: ServiceAdapterConfig) { + super(config); + } + + buildHeaders(apiKey: string) { + return { Authorization: `Bearer ${apiKey}` }; + } + + buildRequestBody(query: TextTranslateQuery) { + return { text: query.text }; + } + + getTextGenerationUrl(apiUrl: string) { + return `${apiUrl}/translate`; + } + + handleStream(streamData: { text: string }, _query: TextTranslateQuery, targetText: string) { + return targetText + streamData.text; + } + + parseResponse(_response: HttpResponse) { + return 'translated text'; + } + + async testApiConnection( + _apiKey: string, + _apiUrl: string, + completion: ValidationCompletion, + ): Promise { + completion({ result: true }); + } + + protected extractErrorFromResponse(_response: HttpResponse): ServiceError { + return { type: 'secretKey', message: 'API Error', addition: '', troubleshootingLink: '' }; + } +} + +describe('BaseAdapter', () => { + let adapter: TestAdapter; + const mockConfig: ServiceAdapterConfig = { + troubleshootingLink: 'https://example.com/troubleshooting', + }; + + beforeEach(() => { + adapter = new TestAdapter(mockConfig); + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('should initialize with the provided config', () => { + expect(adapter).toBeDefined(); + expect((adapter as any).config).toEqual(mockConfig); + }); + }); + + describe('getTemperature', () => { + it('should return the temperature from $option', () => { + const originalTemp = (global as any).$option.temperature; + (global as any).$option.temperature = 0.5; + expect(adapter['getTemperature']()).toBe(0.5); + (global as any).$option.temperature = originalTemp; + }); + + it('should return 0 if $option.temperature is null', () => { + const originalTemp = (global as any).$option.temperature; + (global as any).$option.temperature = null; + expect(adapter['getTemperature']()).toBe(0); + (global as any).$option.temperature = originalTemp; + }); + }); + + describe('isStreamEnabled', () => { + it('should return true when stream is enable', () => { + (global as any).$option.stream = 'enable'; + expect(adapter['isStreamEnabled']()).toBe(true); + }); + + it('should return false when stream is not enable', () => { + (global as any).$option.stream = 'disable'; + expect(adapter['isStreamEnabled']()).toBe(false); + }); + }); + + describe('getModel', () => { + it('should return custom model when model is custom', () => { + (global as any).$option.model = 'custom'; + (global as any).$option.customModel = 'my-custom-model'; + expect(adapter['getModel']()).toBe('my-custom-model'); + }); + + it('should return default model when model is not custom', () => { + (global as any).$option.model = 'default-model'; + expect(adapter['getModel']()).toBe('default-model'); + }); + }); + + describe('handleStreamCompletion', () => { + it('should call query.onCompletion with the correct result', () => { + const targetText = 'Hello translated text'; + adapter['handleStreamCompletion'](mockQuery, targetText); + + expect(mockQuery.onCompletion).toHaveBeenCalledWith({ + result: { + from: mockQuery.detectFrom, + to: mockQuery.detectTo, + toParagraphs: [targetText], + }, + }); + }); + }); + + describe('handleGeneralCompletion', () => { + it('should call query.onCompletion with the correct result, splitting text by newlines', () => { + const text = 'Line 1\nLine 2\nLine 3'; + adapter['handleGeneralCompletion'](mockQuery, text); + + expect(mockQuery.onCompletion).toHaveBeenCalledWith({ + result: { + from: mockQuery.detectFrom, + to: mockQuery.detectTo, + toParagraphs: ['Line 1', 'Line 2', 'Line 3'], + }, + }); + }); + + it('should handle single line text correctly', () => { + const text = 'Single line'; + adapter['handleGeneralCompletion'](mockQuery, text); + + expect(mockQuery.onCompletion).toHaveBeenCalledWith({ + result: { + from: mockQuery.detectFrom, + to: mockQuery.detectTo, + toParagraphs: ['Single line'], + }, + }); + }); + }); + + describe('translate method', () => { + it('should make a stream request when isStream is true', async () => { + mockStreamRequest.mockImplementation(({ handler }: { handler: any }) => { + // Simulate a successful stream response + handler({ response: { statusCode: 200 }, error: null }); + }); + + await adapter['translate'](mockQuery, 'api-key', 'http://api.example.com', true); + + expect(mockStreamRequest).toHaveBeenCalled(); + }); + }); + + describe('makeRequest method', () => { + it('should make a successful request and handle response', async () => { + const mockResponse = { + response: { statusCode: 200 }, + error: null, + }; + mockHttpRequest.mockResolvedValue(mockResponse); + + await adapter['makeRequest']( + 'http://api.example.com', + { Authorization: 'Bearer key' }, + { text: 'hello' }, + mockQuery + ); + + expect(mockHttpRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + url: 'http://api.example.com', + header: { Authorization: 'Bearer key' }, + body: { text: 'hello' }, + }) + ); + }); + + it('should handle error responses', async () => { + const mockResponse = { + response: { statusCode: 400 }, + error: null, + }; + mockHttpRequest.mockResolvedValue(mockResponse); + + await adapter['makeRequest']( + 'http://api.example.com', + { Authorization: 'Bearer key' }, + { text: 'hello' }, + mockQuery + ); + + expect(mockHttpRequest).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/src/adapter/openai.test.ts b/src/adapter/openai.test.ts new file mode 100644 index 0000000..e2f3b02 --- /dev/null +++ b/src/adapter/openai.test.ts @@ -0,0 +1,556 @@ +import type { + HttpResponse, + TextTranslateQuery, + ValidationCompletion, + Data, +} from '@bob-translate/types'; +import type { GeminiResponse, OpenAiResponse } from '../types'; +import { OpenAiAdapter } from './openai'; + +// Jest types for type checking +declare const jest: any; +declare function beforeEach(fn: () => void): void; +declare function afterEach(fn: () => void): void; + +// Define a mock implementation of Data for testing +const mockData: Data = { + length: 0, + toUTF8: () => undefined, + toHex: () => '', + toBase64: () => '', + toByteArray: () => [], + readUInt8: () => 0, + writeUInt8: () => {}, + subData: () => mockData, + appendData: () => {}, +}; + +// Mock the global $option object +const mockOption = { + apiUrl: 'https://api.openai.com', + customSystemPrompt: '', + customUserPrompt: '', +}; + +// Mock the global $http object +const mockHttpRequest = jest.fn(); + +// Mock the global console for error logging +const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + +// Set up global mocks +Object.defineProperty(global, '$option', { + value: mockOption, + writable: true, +}); + +Object.defineProperty(global, '$http', { + value: { + request: mockHttpRequest, + }, + writable: true, +}); + +describe('OpenAiAdapter', () => { + let adapter: OpenAiAdapter; + + beforeEach(() => { + adapter = new OpenAiAdapter(); + mockHttpRequest.mockReset(); + consoleErrorSpy.mockClear(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('should initialize with default config', () => { + expect(adapter).toBeDefined(); + // @ts-expect-error - accessing private property for testing + expect(adapter.config.troubleshootingLink).toBe( + 'https://bobtranslate.com/service/translate/openai.html', + ); + // @ts-expect-error - accessing private property for testing + expect(adapter.config.baseUrl).toBe('https://api.openai.com'); + }); + + it('should use custom config when provided', () => { + const customConfig = { + troubleshootingLink: 'https://custom-link.com', + baseUrl: 'https://custom-api.com', + }; + const customAdapter = new OpenAiAdapter(customConfig); + // @ts-expect-error - accessing private property for testing + expect(customAdapter.config.troubleshootingLink).toBe('https://custom-link.com'); + // @ts-expect-error - accessing private property for testing + expect(customAdapter.config.baseUrl).toBe('https://custom-api.com'); + }); + }); + + describe('extractErrorFromResponse', () => { + it('should handle error as string', () => { + const errorResponse: HttpResponse = { + data: { error: 'Invalid API key' }, + response: { + statusCode: 401, + expectedContentLength: 0, + headers: {}, + MIMEType: '', + suggestedFilename: '', + textEncodingName: '', + url: '', + }, + rawData: mockData, + }; + + const error = adapter['extractErrorFromResponse'](errorResponse); + expect(error).toEqual({ + type: 'secretKey', + message: 'Invalid API key', + addition: '{"error":"Invalid API key"}', + troubleshootingLink: 'https://bobtranslate.com/service/translate/openai.html', + }); + }); + + it('should handle error as object with message', () => { + const errorResponse: HttpResponse = { + data: { error: { message: 'Rate limit exceeded', param: 'requests' } }, + response: { + statusCode: 429, + expectedContentLength: 0, + headers: {}, + MIMEType: '', + suggestedFilename: '', + textEncodingName: '', + url: '', + }, + rawData: mockData, + }; + + const error = adapter['extractErrorFromResponse'](errorResponse); + expect(error).toEqual({ + type: 'api', + message: 'Rate limit exceeded (parameter: requests)', + addition: '{"error":{"message":"Rate limit exceeded","param":"requests"}}', + troubleshootingLink: 'https://bobtranslate.com/service/translate/openai.html', + }); + }); + + it('should handle error as object with message but no param', () => { + const errorResponse: HttpResponse = { + data: { error: { message: 'Invalid request' } }, + response: { + statusCode: 400, + expectedContentLength: 0, + headers: {}, + MIMEType: '', + suggestedFilename: '', + textEncodingName: '', + url: '', + }, + rawData: mockData, + }; + + const error = adapter['extractErrorFromResponse'](errorResponse); + expect(error).toEqual({ + type: 'api', + message: 'Invalid request', + addition: '{"error":{"message":"Invalid request"}}', + troubleshootingLink: 'https://bobtranslate.com/service/translate/openai.html', + }); + }); + + it('should return base error for unknown error format', () => { + const errorResponse: HttpResponse = { + data: { message: 'Unknown error' }, + response: { + statusCode: 500, + expectedContentLength: 0, + headers: {}, + MIMEType: '', + suggestedFilename: '', + textEncodingName: '', + url: '', + }, + rawData: mockData, + }; + + const error = adapter['extractErrorFromResponse'](errorResponse); + expect(error).toEqual({ + type: 'api', + message: 'API request failed', + addition: '{"message":"Unknown error"}', + troubleshootingLink: 'https://bobtranslate.com/service/translate/openai.html', + }); + }); + + it('should return secretKey error type for 401 status code', () => { + const errorResponse: HttpResponse = { + data: { error: { message: 'Unauthorized' } }, + response: { + statusCode: 401, + expectedContentLength: 0, + headers: {}, + MIMEType: '', + suggestedFilename: '', + textEncodingName: '', + url: '', + }, + rawData: mockData, + }; + + const error = adapter['extractErrorFromResponse'](errorResponse); + expect(error.type).toBe('secretKey'); + }); + }); + + describe('buildHeaders', () => { + it('should return proper headers with API key', () => { + const headers = adapter.buildHeaders('test-api-key'); + expect(headers).toEqual({ + 'Content-Type': 'application/json', + Authorization: 'Bearer test-api-key', + }); + }); + }); + + describe('parseResponse', () => { + it('should parse Responses API format with output_text field', () => { + const response: HttpResponse = { + data: { + id: "resp_123", + object: "response", + created: 1234567890, + model: "model-123", + output_text: 'Translated text', + output: [], + }, + response: { + statusCode: 200 as unknown as 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 421 | 422 | 423 | 424 | 425 | 426 | 428 | 429 | 431 | 451 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511, + expectedContentLength: 0, + headers: {}, + MIMEType: '', + suggestedFilename: '', + textEncodingName: '', + url: '', + }, + rawData: mockData, + }; + + const result = adapter.parseResponse(response); + expect(result).toBe('Translated text'); + }); + + it('should parse Responses API format with output array', () => { + const response: HttpResponse = { + data: { + id: "resp_123", + object: "response", + created: 1234567890, + model: "model-123", + output: [ + { + id: "msg_123", + role: "assistant", + type: 'message', + content: [ + { type: 'output_text', text: 'Part 1' }, + { type: 'output_text', text: 'Part 2' }, + ], + }, + ], + }, + response: { + statusCode: 200 as unknown as 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 421 | 422 | 423 | 424 | 425 | 426 | 428 | 429 | 431 | 451 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511, + expectedContentLength: 0, + headers: {}, + MIMEType: '', + suggestedFilename: '', + textEncodingName: '', + url: '', + }, + rawData: mockData, + }; + + const result = adapter.parseResponse(response); + expect(result).toBe('Part 1Part 2'); + }); + + it('should throw error when no output is found in Responses API', () => { + const response: HttpResponse = { + data: { + id: "resp_123", + object: "response", + created: 1234567890, + model: "model-123", + output: [], + }, + response: { + statusCode: 200 as unknown as 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 421 | 422 | 423 | 424 | 425 | 426 | 428 | 429 | 431 | 451 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511, + expectedContentLength: 0, + headers: {}, + MIMEType: '', + suggestedFilename: '', + textEncodingName: '', + url: '', + }, + rawData: mockData, + }; + + expect(() => adapter.parseResponse(response)).toThrow( + 'No output returned from Responses API', + ); + }); + + it('should throw error for unsupported response type', () => { + const response: HttpResponse = { + data: { + candidates: [], + usageMetadata: { + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0, + }, + modelVersion: "gemini-1.0", + }, + response: { + statusCode: 200 as unknown as 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 421 | 422 | 423 | 424 | 425 | 426 | 428 | 429 | 431 | 451 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511, + expectedContentLength: 0, + headers: {}, + MIMEType: '', + suggestedFilename: '', + textEncodingName: '', + url: '', + }, + rawData: mockData, + }; + + expect(() => adapter.parseResponse(response)).toThrow( + 'Unsupported response type', + ); + }); + }); + + describe('getTextGenerationUrl', () => { + it('should return the correct text generation URL', () => { + const url = adapter.getTextGenerationUrl('https://api.openai.com'); + expect(url).toBe('https://api.openai.com/v1/responses'); + }); + }); + + describe('getValidationUrl', () => { + it('should return the correct validation URL', () => { + const url = adapter['getValidationUrl']('https://api.openai.com'); + expect(url).toBe('https://api.openai.com/v1/models'); + }); + }); + + describe('extractDeltaFromData', () => { + it('should extract delta from new Responses API format', () => { + const dataObj = { + type: 'response.output_text.delta', + delta: 'partial text', + }; + + const result = adapter['extractDeltaFromData'](dataObj); + expect(result).toBe('partial text'); + }); + + it('should return null for new Responses API format with non-string delta', () => { + const dataObj = { + type: 'response.output_text.delta', + delta: 123, + }; + + const result = adapter['extractDeltaFromData'](dataObj); + expect(result).toBeNull(); + }); + + it('should extract delta from old Responses API stream format', () => { + const dataObj = { + object: 'response.chunk', + delta: { + output: [ + { + content: [ + { type: 'output_text', text: 'partial text' }, + { type: 'other_type', text: 'ignored' }, + ], + }, + ], + }, + }; + + const result = adapter['extractDeltaFromData'](dataObj); + expect(result).toBe('partial text'); + }); + + it('should return null when old format has no content', () => { + const dataObj = { + object: 'response.chunk', + delta: { + output: [ + { + content: [], + }, + ], + }, + }; + + const result = adapter['extractDeltaFromData'](dataObj); + expect(result).toBeNull(); + }); + + it('should return null for unknown format', () => { + const dataObj = { + type: 'unknown', + delta: 'ignored', + }; + + const result = adapter['extractDeltaFromData'](dataObj); + expect(result).toBeNull(); + }); + }); + + describe('parseSseMessage', () => { + it('should throw error when error is present in data', () => { + const sse = { + data: JSON.stringify({ + error: { type: 'invalid_api_key', message: 'API key invalid' }, + }), + }; + + expect(() => adapter['parseSseMessage'](sse as any)).toThrow(); + }); + }); + + describe('handleStream', () => { + it('should handle [DONE] message and not update target text', () => { + const query: TextTranslateQuery = { + text: 'Hello', + detectFrom: 'en', + detectTo: 'zh-Hans', + from: 'auto', + to: 'zh-Hans', + cancelSignal: { + send: jest.fn(), + subscribe: jest.fn(() => ({ dispose: jest.fn() })), + removeAllSubscriber: jest.fn(), + }, + onCompletion: jest.fn(), + onStream: jest.fn(), + }; + + const streamData = { + text: 'event: [DONE]\ndata: [DONE]\n\n', + }; + + const result = adapter.handleStream(streamData, query, 'existing'); + expect(result).toBe('existing'); + expect(query.onStream).not.toHaveBeenCalled(); + }); + }); + + describe('testApiConnection', () => { + it('should return validation success on valid response', async () => { + const apiKey = 'test-api-key'; + const apiUrl = 'https://api.openai.com'; + const completion: ValidationCompletion = jest.fn(); + + mockHttpRequest.mockResolvedValueOnce({ + data: { data: [{ id: 'model1' }] }, + rawData: mockData, + response: { + statusCode: 200 as unknown as 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 421 | 422 | 423 | 424 | 425 | 426 | 428 | 429 | 431 | 451 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511, + expectedContentLength: 0, + headers: {}, + MIMEType: '', + suggestedFilename: '', + textEncodingName: '', + url: '', + }, + }); + + await adapter.testApiConnection(apiKey, apiUrl, completion); + + expect(completion).toHaveBeenCalledWith({ result: true }); + }); + + it('should return validation success on list object response', async () => { + const apiKey = 'test-api-key'; + const apiUrl = 'https://api.openai.com'; + const completion: ValidationCompletion = jest.fn(); + + mockHttpRequest.mockResolvedValueOnce({ + data: { object: 'list', data: [] }, + rawData: mockData, + response: { + statusCode: 200 as unknown as 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 421 | 422 | 423 | 424 | 425 | 426 | 428 | 429 | 431 | 451 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511, + expectedContentLength: 0, + headers: {}, + MIMEType: '', + suggestedFilename: '', + textEncodingName: '', + url: '', + }, + }); + + await adapter.testApiConnection(apiKey, apiUrl, completion); + + expect(completion).toHaveBeenCalledWith({ result: true }); + }); + + it('should return validation error on error response', async () => { + const apiKey = 'test-api-key'; + const apiUrl = 'https://api.openai.com'; + const completion: ValidationCompletion = jest.fn(); + + mockHttpRequest.mockResolvedValueOnce({ + data: { error: { message: 'Invalid API key' } }, + rawData: mockData, + response: { + statusCode: 401, + expectedContentLength: 0, + headers: {}, + MIMEType: '', + suggestedFilename: '', + textEncodingName: '', + url: '', + }, + }); + + await adapter.testApiConnection(apiKey, apiUrl, completion); + + expect(completion).toHaveBeenCalledWith({ + result: false, + error: { + type: 'secretKey', + message: 'Invalid API key', + addition: '{"error":{"message":"Invalid API key"}}', + troubleshootingLink: 'https://bobtranslate.com/service/translate/openai.html', + }, + }); + }); + + it('should handle request errors', async () => { + const apiKey = 'test-api-key'; + const apiUrl = 'https://api.openai.com'; + const completion: ValidationCompletion = jest.fn(); + + const error = new Error('Network error'); + mockHttpRequest.mockRejectedValueOnce(error); + + await adapter.testApiConnection(apiKey, apiUrl, completion); + + // Check that completion was called with result false + expect(completion).toHaveBeenCalledWith({ + result: false, + error: expect.objectContaining({ + type: 'api', + message: 'Network error', + }), + }); + }); + }); +}); diff --git a/src/lang.test.ts b/src/lang.test.ts new file mode 100644 index 0000000..8432cbc --- /dev/null +++ b/src/lang.test.ts @@ -0,0 +1,88 @@ +import { supportLanguageList, langMap } from './lang'; + +describe('lang.ts', () => { + describe('supportLanguageList', () => { + it('should be an array of language pairs', () => { + expect(Array.isArray(supportLanguageList)).toBe(true); + expect(supportLanguageList.length).toBeGreaterThan(0); + + // Check that each element is an array with 2 elements + for (const pair of supportLanguageList) { + expect(Array.isArray(pair)).toBe(true); + expect(pair.length).toBe(2); + expect(typeof pair[0]).toBe('string'); + expect(typeof pair[1]).toBe('string'); + } + }); + + it('should contain expected language codes', () => { + // Check for some common language codes + expect(supportLanguageList.some(([key]) => key === 'auto')).toBe(true); + expect(supportLanguageList.some(([key]) => key === 'en')).toBe(true); + expect(supportLanguageList.some(([key]) => key === 'zh-Hans')).toBe(true); + expect(supportLanguageList.some(([key]) => key === 'zh-Hant')).toBe(true); + expect(supportLanguageList.some(([key]) => key === 'ja')).toBe(true); + expect(supportLanguageList.some(([key]) => key === 'ko')).toBe(true); + expect(supportLanguageList.some(([key]) => key === 'fr')).toBe(true); + }); + + it('should be declared as const to prevent modification', () => { + // This test verifies that the array is declared with 'as const' + // TypeScript will enforce immutability at compile time + expect(Array.isArray(supportLanguageList)).toBe(true); + }); + }); + + describe('langMap', () => { + it('should be a Map instance', () => { + expect(langMap instanceof Map).toBe(true); + }); + + it('should have entries for all unique keys from supportLanguageList', () => { + // Create a set of unique keys from the supportLanguageList + const uniqueKeys = new Set(supportLanguageList.map(([key]) => key)); + expect(langMap.size).toBe(uniqueKeys.size); + }); + + it('should contain expected key-value pairs', () => { + // Check that common language codes are properly mapped + expect(langMap.get('auto')).toBe('auto'); + expect(langMap.get('en')).toBe('en'); + expect(langMap.get('zh-Hans')).toBe('zh-CN'); + expect(langMap.get('zh-Hant')).toBe('zh-TW'); + expect(langMap.get('ja')).toBe('ja'); + expect(langMap.get('ko')).toBe('ko'); + expect(langMap.get('fr')).toBe('fr'); + }); + + it('should map all keys from supportLanguageList', () => { + for (const [key, value] of supportLanguageList) { + expect(langMap.get(key)).toBe(value); + } + }); + + it('should handle duplicate keys correctly', () => { + // Check if there are any duplicate keys in the list + const keys = supportLanguageList.map(([key]) => key); + const uniqueKeys = new Set(keys); + + // If there are duplicates, the Map will only keep the last value for that key + // This test ensures we understand the behavior with duplicate keys + expect(keys.length).toBeGreaterThanOrEqual(uniqueKeys.size); + + // Verify that 'en' appears twice in the original list and the Map reflects the last occurrence + const enEntries = supportLanguageList.filter(([key]) => key === 'en'); + expect(langMap.get('en')).toBe('en'); // Should be the value from the last occurrence + expect(enEntries.length).toBeGreaterThanOrEqual(1); // At least one occurrence + }); + }); + + describe('Integration between supportLanguageList and langMap', () => { + it('should ensure langMap is built from supportLanguageList', () => { + // Verify that every entry in supportLanguageList is reflected in langMap + for (const [key, value] of supportLanguageList) { + expect(langMap.get(key)).toBe(value); + } + }); + }); +}); \ No newline at end of file diff --git a/src/types.test.ts b/src/types.test.ts new file mode 100644 index 0000000..cad1802 --- /dev/null +++ b/src/types.test.ts @@ -0,0 +1,342 @@ +import type { + OpenAiErrorResponse, + OpenAiErrorDetail, + OpenAiResponseMessage, + OpenAiResponse, + OpenAiResponseStreamChunk, + GeminiResponse, + ServiceAdapter, + ServiceAdapterConfig, + ServiceProvider, + TypeCheckConfig, +} from './types'; + +// Add Jest global types +declare global { + const describe: any; + const it: any; + const expect: any; +} + +// Test OpenAiErrorResponse type +describe('OpenAiErrorResponse', () => { + it('should match the expected structure', () => { + const errorDetail: OpenAiErrorDetail = { + param: 'test_param', + message: 'Test error message', + code: 'test_code', + type: 'test_type', + }; + + const errorResponse: OpenAiErrorResponse = { + error: errorDetail, + }; + + expect(errorResponse).toHaveProperty('error'); + expect(errorResponse.error).toEqual(errorDetail); + }); +}); + +// Test OpenAiErrorDetail type +describe('OpenAiErrorDetail', () => { + it('should have the correct properties', () => { + const errorDetail: OpenAiErrorDetail = { + param: 'test_param', + message: 'Test error message', + code: 'test_code', + type: 'test_type', + }; + + expect(errorDetail.param).toBe('test_param'); + expect(errorDetail.message).toBe('Test error message'); + expect(errorDetail.code).toBe('test_code'); + expect(errorDetail.type).toBe('test_type'); + }); + + it('should allow null param', () => { + const errorDetail: OpenAiErrorDetail = { + param: null, + message: 'Test error message', + code: 'test_code', + type: 'test_type', + }; + + expect(errorDetail.param).toBeNull(); + }); +}); + +// Test OpenAiResponseMessage type +describe('OpenAiResponseMessage', () => { + it('should match the expected structure', () => { + const responseMessage: OpenAiResponseMessage = { + id: 'test-id', + type: 'message', + role: 'assistant', + content: [ + { + type: 'output_text', + text: 'Test response text', + annotations: [], + }, + ], + }; + + expect(responseMessage.id).toBe('test-id'); + expect(responseMessage.type).toBe('message'); + expect(responseMessage.role).toBe('assistant'); + expect(responseMessage.content).toHaveLength(1); + expect(responseMessage.content[0]).toHaveProperty('type', 'output_text'); + expect(responseMessage.content[0]).toHaveProperty('text', 'Test response text'); + }); + + it('should handle optional annotations', () => { + const responseMessage: OpenAiResponseMessage = { + id: 'test-id', + type: 'message', + role: 'assistant', + content: [ + { + type: 'output_text', + text: 'Test response text', + }, + ], + }; + + expect(responseMessage.content[0]).toHaveProperty('type', 'output_text'); + expect(responseMessage.content[0]).toHaveProperty('text', 'Test response text'); + expect(responseMessage.content[0]).not.toHaveProperty('annotations'); + }); +}); + +// Test OpenAiResponse type +describe('OpenAiResponse', () => { + it('should match the expected structure', () => { + const response: OpenAiResponse = { + id: 'test-id', + object: 'response', + created: 1234567890, + model: 'test-model', + output: [], + usage: { + prompt_tokens: 10, + completion_tokens: 20, + total_tokens: 30, + }, + }; + + expect(response.id).toBe('test-id'); + expect(response.object).toBe('response'); + expect(response.created).toBe(1234567890); + expect(response.model).toBe('test-model'); + expect(response.output).toEqual([]); + expect(response.usage).toEqual({ + prompt_tokens: 10, + completion_tokens: 20, + total_tokens: 30, + }); + }); + + it('should allow optional usage field', () => { + const response: OpenAiResponse = { + id: 'test-id', + object: 'response', + created: 1234567890, + model: 'test-model', + output: [], + }; + + expect(response).not.toHaveProperty('usage'); + }); + + it('should allow optional output_text field', () => { + const response: OpenAiResponse = { + id: 'test-id', + object: 'response', + created: 1234567890, + model: 'test-model', + output: [], + output_text: 'test output text', + }; + + expect(response.output_text).toBe('test output text'); + }); +}); + +// Test OpenAiResponseStreamChunk type +describe('OpenAiResponseStreamChunk', () => { + it('should match the expected structure', () => { + const chunk: OpenAiResponseStreamChunk = { + id: 'chunk-id', + object: 'response.chunk', + created: 1234567890, + model: 'test-model', + delta: { + output: [ + { + content: [ + { + type: 'output_text', + text: 'Test chunk text', + }, + ], + }, + ], + }, + }; + + expect(chunk.id).toBe('chunk-id'); + expect(chunk.object).toBe('response.chunk'); + expect(chunk.created).toBe(1234567890); + expect(chunk.model).toBe('test-model'); + expect(chunk.delta?.output?.[0]?.content?.[0]?.text).toBe('Test chunk text'); + }); + + it('should handle optional delta field', () => { + const chunk: OpenAiResponseStreamChunk = { + id: 'chunk-id', + object: 'response.chunk', + created: 1234567890, + model: 'test-model', + }; + + expect(chunk).not.toHaveProperty('delta'); + }); +}); + +// Test GeminiResponse type +describe('GeminiResponse', () => { + it('should match the expected structure', () => { + const geminiResponse: GeminiResponse = { + usageMetadata: { + promptTokenCount: 10, + totalTokenCount: 30, + candidatesTokenCount: 20, + }, + modelVersion: 'gemini-pro', + candidates: [ + { + content: { + parts: [ + { + text: 'Test response from Gemini', + }, + ], + role: 'model', + }, + finishReason: 'STOP', + avgLogprobs: -0.5, + }, + ], + }; + + expect(geminiResponse.usageMetadata.promptTokenCount).toBe(10); + expect(geminiResponse.modelVersion).toBe('gemini-pro'); + expect(geminiResponse.candidates).toHaveLength(1); + expect(geminiResponse.candidates[0].content.parts[0].text).toBe('Test response from Gemini'); + }); +}); + +// Test ServiceAdapter interface +describe('ServiceAdapter', () => { + it('should have all required methods', () => { + const mockAdapter: ServiceAdapter = { + buildHeaders: (apiKey: string) => ({ 'Authorization': `Bearer ${apiKey}` }), + buildRequestBody: (query) => ({ query }), + parseResponse: (response) => { + // Simplified implementation for testing + if (typeof response.data === 'object' && response.data && 'output_text' in response.data) { + return (response.data as any).output_text || 'default'; + } + return 'default'; + }, + getTextGenerationUrl: (apiUrl: string) => `${apiUrl}/generate`, + testApiConnection: async (_apiKey, _apiUrl, completion) => { + // Mock completion callback - calling with no error + completion(null as any); + }, + handleStream: (streamData, _query, _targetText) => streamData.text, + makeStreamRequest: async (_url, _header, _body, _query) => {}, + makeRequest: async (_url, _header, _body, _query) => {}, + translate: async (_query, _apiKey, _apiUrl, _isStream) => {}, + }; + + expect(typeof mockAdapter.buildHeaders).toBe('function'); + expect(typeof mockAdapter.buildRequestBody).toBe('function'); + expect(typeof mockAdapter.parseResponse).toBe('function'); + expect(typeof mockAdapter.getTextGenerationUrl).toBe('function'); + expect(typeof mockAdapter.testApiConnection).toBe('function'); + expect(typeof mockAdapter.handleStream).toBe('function'); + expect(typeof mockAdapter.makeStreamRequest).toBe('function'); + expect(typeof mockAdapter.makeRequest).toBe('function'); + expect(typeof mockAdapter.translate).toBe('function'); + }); +}); + +// Test ServiceAdapterConfig interface +describe('ServiceAdapterConfig', () => { + it('should match the expected structure', () => { + const config: ServiceAdapterConfig = { + troubleshootingLink: 'https://example.com/troubleshooting', + baseUrl: 'https://api.example.com', + }; + + expect(config.troubleshootingLink).toBe('https://example.com/troubleshooting'); + expect(config?.baseUrl).toBe('https://api.example.com'); + }); + + it('should allow optional baseUrl', () => { + const config: ServiceAdapterConfig = { + troubleshootingLink: 'https://example.com/troubleshooting', + }; + + expect(config.troubleshootingLink).toBe('https://example.com/troubleshooting'); + expect(config).not.toHaveProperty('baseUrl'); + }); +}); + +// Test ServiceProvider type +describe('ServiceProvider', () => { + it('should include all valid service providers', () => { + const providers: ServiceProvider[] = [ + 'azure-openai', + 'gemini', + 'openai', + 'openai-compatible', + ]; + + expect(providers).toHaveLength(4); + expect(providers).toContain('azure-openai'); + expect(providers).toContain('gemini'); + expect(providers).toContain('openai'); + expect(providers).toContain('openai-compatible'); + }); +}); + +// Test TypeCheckConfig type +describe('TypeCheckConfig', () => { + it('should match the expected structure', () => { + const config: TypeCheckConfig = { + apiKey: { + type: 'string', + optional: true, + }, + settings: { + type: 'object', + nullable: true, + }, + count: { + type: 'string', + optional: false, + nullable: false, + }, + }; + + expect(config.apiKey.type).toBe('string'); + expect(config.apiKey.optional).toBe(true); + expect(config.settings.type).toBe('object'); + expect(config.settings.nullable).toBe(true); + expect(config.count.type).toBe('string'); + expect(config.count.optional).toBe(false); + expect(config.count.nullable).toBe(false); + }); +}); \ No newline at end of file diff --git a/tsconfig.jest.json b/tsconfig.jest.json new file mode 100644 index 0000000..b479f49 --- /dev/null +++ b/tsconfig.jest.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "./dist-jest", + "isolatedModules": true + }, + "exclude": ["dist", "node_modules"] +} \ No newline at end of file