diff --git a/package.json b/package.json index de89b4514..304785117 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "test": "vitest run --silent", "test:main": "vitest run --project main", "test:renderer": "vitest run --project renderer", + "test:aicore": "vitest run --project aiCore", "test:update": "yarn test:renderer --update", "test:coverage": "vitest run --coverage --silent", "test:ui": "vitest --ui", diff --git a/packages/aiCore/src/__tests__/fixtures/mock-responses.ts b/packages/aiCore/src/__tests__/fixtures/mock-responses.ts index 9855cfb36..388a4f7fd 100644 --- a/packages/aiCore/src/__tests__/fixtures/mock-responses.ts +++ b/packages/aiCore/src/__tests__/fixtures/mock-responses.ts @@ -3,12 +3,13 @@ * Provides realistic mock responses for all provider types */ -import { jsonSchema, type ModelMessage, type Tool } from 'ai' +import type { ModelMessage, Tool } from 'ai' +import { jsonSchema } from 'ai' /** * Standard test messages for all scenarios */ -export const testMessages = { +export const testMessages: Record = { simple: [{ role: 'user' as const, content: 'Hello, how are you?' }], conversation: [ @@ -45,7 +46,7 @@ export const testMessages = { { role: 'assistant' as const, content: '15 * 23 = 345' }, { role: 'user' as const, content: 'Now divide that by 5' } ] -} satisfies Record +} /** * Standard test tools for tool calling scenarios @@ -138,68 +139,17 @@ export const testTools: Record = { } } -/** - * Mock streaming chunks for different providers - */ -export const mockStreamingChunks = { - text: [ - { type: 'text-delta' as const, textDelta: 'Hello' }, - { type: 'text-delta' as const, textDelta: ', ' }, - { type: 'text-delta' as const, textDelta: 'this ' }, - { type: 'text-delta' as const, textDelta: 'is ' }, - { type: 'text-delta' as const, textDelta: 'a ' }, - { type: 'text-delta' as const, textDelta: 'test.' } - ], - - withToolCall: [ - { type: 'text-delta' as const, textDelta: 'Let me check the weather for you.' }, - { - type: 'tool-call-delta' as const, - toolCallType: 'function' as const, - toolCallId: 'call_123', - toolName: 'getWeather', - argsTextDelta: '{"location":' - }, - { - type: 'tool-call-delta' as const, - toolCallType: 'function' as const, - toolCallId: 'call_123', - toolName: 'getWeather', - argsTextDelta: ' "San Francisco, CA"}' - }, - { - type: 'tool-call' as const, - toolCallType: 'function' as const, - toolCallId: 'call_123', - toolName: 'getWeather', - args: { location: 'San Francisco, CA' } - } - ], - - withFinish: [ - { type: 'text-delta' as const, textDelta: 'Complete response.' }, - { - type: 'finish' as const, - finishReason: 'stop' as const, - usage: { - promptTokens: 10, - completionTokens: 5, - totalTokens: 15 - } - } - ] -} - /** * Mock complete responses for non-streaming scenarios + * Note: AI SDK v5 uses inputTokens/outputTokens instead of promptTokens/completionTokens */ export const mockCompleteResponses = { simple: { text: 'This is a simple response.', finishReason: 'stop' as const, usage: { - promptTokens: 15, - completionTokens: 8, + inputTokens: 15, + outputTokens: 8, totalTokens: 23 } }, @@ -215,8 +165,8 @@ export const mockCompleteResponses = { ], finishReason: 'tool-calls' as const, usage: { - promptTokens: 25, - completionTokens: 12, + inputTokens: 25, + outputTokens: 12, totalTokens: 37 } }, @@ -225,14 +175,15 @@ export const mockCompleteResponses = { text: 'Response with warnings.', finishReason: 'stop' as const, usage: { - promptTokens: 10, - completionTokens: 5, + inputTokens: 10, + outputTokens: 5, totalTokens: 15 }, warnings: [ { type: 'unsupported-setting' as const, - message: 'Temperature parameter not supported for this model' + setting: 'temperature', + details: 'Temperature parameter not supported for this model' } ] } @@ -285,47 +236,3 @@ export const mockImageResponses = { warnings: [] } } - -/** - * Mock error responses - */ -export const mockErrors = { - invalidApiKey: { - name: 'APIError', - message: 'Invalid API key provided', - statusCode: 401 - }, - - rateLimitExceeded: { - name: 'RateLimitError', - message: 'Rate limit exceeded. Please try again later.', - statusCode: 429, - headers: { - 'retry-after': '60' - } - }, - - modelNotFound: { - name: 'ModelNotFoundError', - message: 'The requested model was not found', - statusCode: 404 - }, - - contextLengthExceeded: { - name: 'ContextLengthError', - message: "This model's maximum context length is 4096 tokens", - statusCode: 400 - }, - - timeout: { - name: 'TimeoutError', - message: 'Request timed out after 30000ms', - code: 'ETIMEDOUT' - }, - - networkError: { - name: 'NetworkError', - message: 'Network connection failed', - code: 'ECONNREFUSED' - } -} diff --git a/packages/aiCore/src/__tests__/mocks/ai-sdk-provider.ts b/packages/aiCore/src/__tests__/mocks/ai-sdk-provider.ts new file mode 100644 index 000000000..57dcdd0fd --- /dev/null +++ b/packages/aiCore/src/__tests__/mocks/ai-sdk-provider.ts @@ -0,0 +1,35 @@ +/** + * Mock for @cherrystudio/ai-sdk-provider + * This mock is used in tests to avoid importing the actual package + */ + +export type CherryInProviderSettings = { + apiKey?: string + baseURL?: string +} + +// oxlint-disable-next-line no-unused-vars +export const createCherryIn = (_options?: CherryInProviderSettings) => ({ + // oxlint-disable-next-line no-unused-vars + languageModel: (_modelId: string) => ({ + specificationVersion: 'v1', + provider: 'cherryin', + modelId: 'mock-model', + doGenerate: async () => ({ text: 'mock response' }), + doStream: async () => ({ stream: (async function* () {})() }) + }), + // oxlint-disable-next-line no-unused-vars + chat: (_modelId: string) => ({ + specificationVersion: 'v1', + provider: 'cherryin-chat', + modelId: 'mock-model', + doGenerate: async () => ({ text: 'mock response' }), + doStream: async () => ({ stream: (async function* () {})() }) + }), + // oxlint-disable-next-line no-unused-vars + textEmbeddingModel: (_modelId: string) => ({ + specificationVersion: 'v1', + provider: 'cherryin', + modelId: 'mock-embedding-model' + }) +}) diff --git a/packages/aiCore/src/__tests__/setup.ts b/packages/aiCore/src/__tests__/setup.ts new file mode 100644 index 000000000..1e35458ad --- /dev/null +++ b/packages/aiCore/src/__tests__/setup.ts @@ -0,0 +1,9 @@ +/** + * Vitest Setup File + * Global test configuration and mocks for @cherrystudio/ai-core package + */ + +// Mock Vite SSR helper to avoid Node environment errors +;(globalThis as any).__vite_ssr_exportName__ = (_name: string, value: any) => value + +// Note: @cherrystudio/ai-sdk-provider is mocked via alias in vitest.config.ts diff --git a/packages/aiCore/src/core/options/__tests__/factory.test.ts b/packages/aiCore/src/core/options/__tests__/factory.test.ts new file mode 100644 index 000000000..86f801781 --- /dev/null +++ b/packages/aiCore/src/core/options/__tests__/factory.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' + +import { createOpenAIOptions, createOpenRouterOptions, mergeProviderOptions } from '../factory' + +describe('mergeProviderOptions', () => { + it('deep merges provider options for the same provider', () => { + const reasoningOptions = createOpenRouterOptions({ + reasoning: { + enabled: true, + effort: 'medium' + } + }) + const webSearchOptions = createOpenRouterOptions({ + plugins: [{ id: 'web', max_results: 5 }] + }) + + const merged = mergeProviderOptions(reasoningOptions, webSearchOptions) + + expect(merged.openrouter).toEqual({ + reasoning: { + enabled: true, + effort: 'medium' + }, + plugins: [{ id: 'web', max_results: 5 }] + }) + }) + + it('preserves options from other providers while merging', () => { + const openRouter = createOpenRouterOptions({ + reasoning: { enabled: true } + }) + const openAI = createOpenAIOptions({ + reasoningEffort: 'low' + }) + const merged = mergeProviderOptions(openRouter, openAI) + + expect(merged.openrouter).toEqual({ reasoning: { enabled: true } }) + expect(merged.openai).toEqual({ reasoningEffort: 'low' }) + }) + + it('overwrites primitive values with later values', () => { + const first = createOpenAIOptions({ + reasoningEffort: 'low', + user: 'user-123' + }) + const second = createOpenAIOptions({ + reasoningEffort: 'high', + maxToolCalls: 5 + }) + + const merged = mergeProviderOptions(first, second) + + expect(merged.openai).toEqual({ + reasoningEffort: 'high', // overwritten by second + user: 'user-123', // preserved from first + maxToolCalls: 5 // added from second + }) + }) + + it('overwrites arrays with later values instead of merging', () => { + const first = createOpenRouterOptions({ + models: ['gpt-4', 'gpt-3.5-turbo'] + }) + const second = createOpenRouterOptions({ + models: ['claude-3-opus', 'claude-3-sonnet'] + }) + + const merged = mergeProviderOptions(first, second) + + // Array is completely replaced, not merged + expect(merged.openrouter?.models).toEqual(['claude-3-opus', 'claude-3-sonnet']) + }) + + it('deeply merges nested objects while overwriting primitives', () => { + const first = createOpenRouterOptions({ + reasoning: { + enabled: true, + effort: 'low' + }, + user: 'user-123' + }) + const second = createOpenRouterOptions({ + reasoning: { + effort: 'high', + max_tokens: 500 + }, + user: 'user-456' + }) + + const merged = mergeProviderOptions(first, second) + + expect(merged.openrouter).toEqual({ + reasoning: { + enabled: true, // preserved from first + effort: 'high', // overwritten by second + max_tokens: 500 // added from second + }, + user: 'user-456' // overwritten by second + }) + }) + + it('replaces arrays instead of merging them', () => { + const first = createOpenRouterOptions({ plugins: [{ id: 'old' }] }) + const second = createOpenRouterOptions({ plugins: [{ id: 'new' }] }) + const merged = mergeProviderOptions(first, second) + // @ts-expect-error type-check for openrouter options is skipped. see function signature of createOpenRouterOptions + expect(merged.openrouter?.plugins).toEqual([{ id: 'new' }]) + }) +}) diff --git a/packages/aiCore/src/core/options/factory.ts b/packages/aiCore/src/core/options/factory.ts index ecd53e633..1e493b233 100644 --- a/packages/aiCore/src/core/options/factory.ts +++ b/packages/aiCore/src/core/options/factory.ts @@ -26,13 +26,65 @@ export function createGenericProviderOptions( return { [provider]: options } as Record> } +type PlainObject = Record + +const isPlainObject = (value: unknown): value is PlainObject => { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function deepMergeObjects(target: T, source: PlainObject): T { + const result: PlainObject = { ...target } + Object.entries(source).forEach(([key, value]) => { + if (isPlainObject(value) && isPlainObject(result[key])) { + result[key] = deepMergeObjects(result[key], value) + } else { + result[key] = value + } + }) + return result as T +} + /** - * 合并多个供应商的options - * @param optionsMap 包含多个供应商选项的对象 - * @returns 合并后的TypedProviderOptions + * Deep-merge multiple provider-specific options. + * Nested objects are recursively merged; primitive values are overwritten. + * + * When the same key appears in multiple options: + * - If both values are plain objects: they are deeply merged (recursive merge) + * - If values are primitives/arrays: the later value overwrites the earlier one + * + * @example + * mergeProviderOptions( + * { openrouter: { reasoning: { enabled: true, effort: 'low' }, user: 'user-123' } }, + * { openrouter: { reasoning: { effort: 'high', max_tokens: 500 }, models: ['gpt-4'] } } + * ) + * // Result: { + * // openrouter: { + * // reasoning: { enabled: true, effort: 'high', max_tokens: 500 }, + * // user: 'user-123', + * // models: ['gpt-4'] + * // } + * // } + * + * @param optionsMap Objects containing options for multiple providers + * @returns Fully merged TypedProviderOptions */ export function mergeProviderOptions(...optionsMap: Partial[]): TypedProviderOptions { - return Object.assign({}, ...optionsMap) + return optionsMap.reduce((acc, options) => { + if (!options) { + return acc + } + Object.entries(options).forEach(([providerId, providerOptions]) => { + if (!providerOptions) { + return + } + if (acc[providerId]) { + acc[providerId] = deepMergeObjects(acc[providerId] as PlainObject, providerOptions as PlainObject) + } else { + acc[providerId] = providerOptions as any + } + }) + return acc + }, {} as TypedProviderOptions) } /** diff --git a/packages/aiCore/src/core/providers/__tests__/schemas.test.ts b/packages/aiCore/src/core/providers/__tests__/schemas.test.ts index 82b390ba0..02fe21889 100644 --- a/packages/aiCore/src/core/providers/__tests__/schemas.test.ts +++ b/packages/aiCore/src/core/providers/__tests__/schemas.test.ts @@ -19,15 +19,20 @@ describe('Provider Schemas', () => { expect(Array.isArray(baseProviders)).toBe(true) expect(baseProviders.length).toBeGreaterThan(0) + // These are the actual base providers defined in schemas.ts const expectedIds = [ 'openai', - 'openai-responses', + 'openai-chat', 'openai-compatible', 'anthropic', 'google', 'xai', 'azure', - 'deepseek' + 'azure-responses', + 'deepseek', + 'openrouter', + 'cherryin', + 'cherryin-chat' ] const actualIds = baseProviders.map((p) => p.id) expectedIds.forEach((id) => { diff --git a/packages/aiCore/src/core/runtime/__tests__/generateImage.test.ts b/packages/aiCore/src/core/runtime/__tests__/generateImage.test.ts index 217319aac..56ab87dbc 100644 --- a/packages/aiCore/src/core/runtime/__tests__/generateImage.test.ts +++ b/packages/aiCore/src/core/runtime/__tests__/generateImage.test.ts @@ -232,11 +232,13 @@ describe('RuntimeExecutor.generateImage', () => { expect(pluginCallOrder).toEqual(['onRequestStart', 'transformParams', 'transformResult', 'onRequestEnd']) + // transformParams receives params without model (model is handled separately) + // and context with core fields + dynamic fields (requestId, startTime, etc.) expect(testPlugin.transformParams).toHaveBeenCalledWith( - { prompt: 'A test image' }, + expect.objectContaining({ prompt: 'A test image' }), expect.objectContaining({ providerId: 'openai', - modelId: 'dall-e-3' + model: 'dall-e-3' }) ) @@ -273,11 +275,12 @@ describe('RuntimeExecutor.generateImage', () => { await executorWithPlugin.generateImage({ model: 'dall-e-3', prompt: 'A test image' }) + // resolveModel receives model id and context with core fields expect(modelResolutionPlugin.resolveModel).toHaveBeenCalledWith( 'dall-e-3', expect.objectContaining({ providerId: 'openai', - modelId: 'dall-e-3' + model: 'dall-e-3' }) ) @@ -339,12 +342,11 @@ describe('RuntimeExecutor.generateImage', () => { .generateImage({ model: 'invalid-model', prompt: 'A test image' }) .catch((error) => error) - expect(thrownError).toBeInstanceOf(ImageGenerationError) - expect(thrownError.message).toContain('Failed to generate image:') + // Error is thrown from pluginEngine directly as ImageModelResolutionError + expect(thrownError).toBeInstanceOf(ImageModelResolutionError) + expect(thrownError.message).toContain('Failed to resolve image model: invalid-model') expect(thrownError.providerId).toBe('openai') expect(thrownError.modelId).toBe('invalid-model') - expect(thrownError.cause).toBeInstanceOf(ImageModelResolutionError) - expect(thrownError.cause.message).toContain('Failed to resolve image model: invalid-model') }) it('should handle ImageModelResolutionError without provider', async () => { @@ -362,8 +364,9 @@ describe('RuntimeExecutor.generateImage', () => { const apiError = new Error('API request failed') vi.mocked(aiGenerateImage).mockRejectedValue(apiError) + // Error propagates directly from pluginEngine without wrapping await expect(executor.generateImage({ model: 'dall-e-3', prompt: 'A test image' })).rejects.toThrow( - 'Failed to generate image:' + 'API request failed' ) }) @@ -376,8 +379,9 @@ describe('RuntimeExecutor.generateImage', () => { vi.mocked(aiGenerateImage).mockRejectedValue(noImageError) vi.mocked(NoImageGeneratedError.isInstance).mockReturnValue(true) + // Error propagates directly from pluginEngine await expect(executor.generateImage({ model: 'dall-e-3', prompt: 'A test image' })).rejects.toThrow( - 'Failed to generate image:' + 'No image generated' ) }) @@ -398,15 +402,17 @@ describe('RuntimeExecutor.generateImage', () => { [errorPlugin] ) + // Error propagates directly from pluginEngine await expect(executorWithPlugin.generateImage({ model: 'dall-e-3', prompt: 'A test image' })).rejects.toThrow( - 'Failed to generate image:' + 'Generation failed' ) + // onError receives the original error and context with core fields expect(errorPlugin.onError).toHaveBeenCalledWith( error, expect.objectContaining({ providerId: 'openai', - modelId: 'dall-e-3' + model: 'dall-e-3' }) ) }) @@ -419,9 +425,10 @@ describe('RuntimeExecutor.generateImage', () => { const abortController = new AbortController() setTimeout(() => abortController.abort(), 10) + // Error propagates directly from pluginEngine await expect( executor.generateImage({ model: 'dall-e-3', prompt: 'A test image', abortSignal: abortController.signal }) - ).rejects.toThrow('Failed to generate image:') + ).rejects.toThrow('Operation was aborted') }) }) diff --git a/packages/aiCore/src/core/runtime/__tests__/generateText.test.ts b/packages/aiCore/src/core/runtime/__tests__/generateText.test.ts index 9a0f20415..cb1d1d671 100644 --- a/packages/aiCore/src/core/runtime/__tests__/generateText.test.ts +++ b/packages/aiCore/src/core/runtime/__tests__/generateText.test.ts @@ -17,10 +17,14 @@ import type { AiPlugin } from '../../plugins' import { globalRegistryManagement } from '../../providers/RegistryManagement' import { RuntimeExecutor } from '../executor' -// Mock AI SDK -vi.mock('ai', () => ({ - generateText: vi.fn() -})) +// Mock AI SDK - use importOriginal to keep jsonSchema and other non-mocked exports +vi.mock('ai', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + generateText: vi.fn() + } +}) vi.mock('../../providers/RegistryManagement', () => ({ globalRegistryManagement: { @@ -409,11 +413,12 @@ describe('RuntimeExecutor.generateText', () => { }) ).rejects.toThrow('Generation failed') + // onError receives the original error and context with core fields expect(errorPlugin.onError).toHaveBeenCalledWith( error, expect.objectContaining({ providerId: 'openai', - modelId: 'gpt-4' + model: 'gpt-4' }) ) }) diff --git a/packages/aiCore/src/core/runtime/__tests__/streamText.test.ts b/packages/aiCore/src/core/runtime/__tests__/streamText.test.ts index eae04783b..49253594c 100644 --- a/packages/aiCore/src/core/runtime/__tests__/streamText.test.ts +++ b/packages/aiCore/src/core/runtime/__tests__/streamText.test.ts @@ -11,10 +11,14 @@ import type { AiPlugin } from '../../plugins' import { globalRegistryManagement } from '../../providers/RegistryManagement' import { RuntimeExecutor } from '../executor' -// Mock AI SDK -vi.mock('ai', () => ({ - streamText: vi.fn() -})) +// Mock AI SDK - use importOriginal to keep jsonSchema and other non-mocked exports +vi.mock('ai', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + streamText: vi.fn() + } +}) vi.mock('../../providers/RegistryManagement', () => ({ globalRegistryManagement: { @@ -153,7 +157,7 @@ describe('RuntimeExecutor.streamText', () => { describe('Max Tokens Parameter', () => { const maxTokensValues = [10, 50, 100, 500, 1000, 2000, 4000] - it.each(maxTokensValues)('should support maxTokens=%s', async (maxTokens) => { + it.each(maxTokensValues)('should support maxOutputTokens=%s', async (maxOutputTokens) => { const mockStream = { textStream: (async function* () { yield 'Response' @@ -168,12 +172,13 @@ describe('RuntimeExecutor.streamText', () => { await executor.streamText({ model: 'gpt-4', messages: testMessages.simple, - maxOutputTokens: maxTokens + maxOutputTokens }) + // Parameters are passed through without transformation expect(streamText).toHaveBeenCalledWith( expect.objectContaining({ - maxTokens + maxOutputTokens }) ) }) @@ -513,11 +518,12 @@ describe('RuntimeExecutor.streamText', () => { }) ).rejects.toThrow('Stream error') + // onError receives the original error and context with core fields expect(errorPlugin.onError).toHaveBeenCalledWith( error, expect.objectContaining({ providerId: 'openai', - modelId: 'gpt-4' + model: 'gpt-4' }) ) }) diff --git a/packages/aiCore/vitest.config.ts b/packages/aiCore/vitest.config.ts index 0cc6b51df..2f520ea96 100644 --- a/packages/aiCore/vitest.config.ts +++ b/packages/aiCore/vitest.config.ts @@ -1,12 +1,20 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' + import { defineConfig } from 'vitest/config' +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + export default defineConfig({ test: { - globals: true + globals: true, + setupFiles: [path.resolve(__dirname, './src/__tests__/setup.ts')] }, resolve: { alias: { - '@': './src' + '@': path.resolve(__dirname, './src'), + // Mock external packages that may not be available in test environment + '@cherrystudio/ai-sdk-provider': path.resolve(__dirname, './src/__tests__/mocks/ai-sdk-provider.ts') } }, esbuild: { diff --git a/tsconfig.node.json b/tsconfig.node.json index 83c3f2b46..6953fa7b3 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -2,14 +2,14 @@ "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", "include": [ "electron.vite.config.*", - "src/main/**/*", - "src/preload/**/*", - "src/main/env.d.ts", - "src/renderer/src/types/*", - "packages/shared/**/*", "scripts", + "src/main/**/*", + "src/main/env.d.ts", + "src/preload/**/*", + "src/renderer/src/services/traceApi.ts", + "src/renderer/src/types/*", "packages/mcp-trace/**/*", - "src/renderer/src/services/traceApi.ts" + "packages/shared/**/*", ], "compilerOptions": { "composite": true, diff --git a/tsconfig.web.json b/tsconfig.web.json index 2d91fe026..b09020a20 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -1,16 +1,16 @@ { "extends": "@electron-toolkit/tsconfig/tsconfig.web.json", "include": [ - "src/renderer/src/**/*", - "src/preload/*.d.ts", "local/src/renderer/**/*", - "packages/shared/**/*", - "tests/__mocks__/**/*", - "packages/mcp-trace/**/*", - "packages/aiCore/src/**/*", + "src/renderer/src/**/*", "src/main/integration/cherryai/index.js", + "src/preload/*.d.ts", + "tests/__mocks__/**/*", + "packages/aiCore/src/**/*", + "packages/ai-sdk-provider/**/*", "packages/extension-table-plus/**/*", - "packages/ai-sdk-provider/**/*" + "packages/mcp-trace/**/*", + "packages/shared/**/*", ], "compilerOptions": { "composite": true, diff --git a/vitest.config.ts b/vitest.config.ts index b4440a246..a245f7a41 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -44,6 +44,18 @@ export default defineConfig({ environment: 'node', include: ['scripts/**/*.{test,spec}.{ts,tsx}', 'scripts/**/__tests__/**/*.{test,spec}.{ts,tsx}'] } + }, + // aiCore 包单元测试配置 + { + extends: 'packages/aiCore/vitest.config.ts', + test: { + name: 'aiCore', + environment: 'node', + include: [ + 'packages/aiCore/**/*.{test,spec}.{ts,tsx}', + 'packages/aiCore/**/__tests__/**/*.{test,spec}.{ts,tsx}' + ] + } } ], // 全局共享配置