From 49cce8a15dd7c58664e6525b8048500cace4afc5 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 25 Aug 2025 16:21:47 +0200 Subject: [PATCH] chore(test): install and configure vitest eslint plugin (#3228) Co-authored-by: N. Taylor Mullen --- eslint.config.js | 12 + package-lock.json | 24 ++ package.json | 1 + packages/cli/src/config/config.test.ts | 22 +- .../ui/components/shared/text-buffer.test.ts | 4 +- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 355 +++--------------- packages/cli/src/utils/gitUtils.test.ts | 6 +- packages/core/src/core/client.test.ts | 170 ++++----- .../src/utils/memoryImportProcessor.test.ts | 195 ---------- packages/core/src/utils/retry.test.ts | 5 +- 10 files changed, 200 insertions(+), 594 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index e5ded380..b4515108 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -10,6 +10,7 @@ import reactPlugin from 'eslint-plugin-react'; import reactHooks from 'eslint-plugin-react-hooks'; import prettierConfig from 'eslint-config-prettier'; import importPlugin from 'eslint-plugin-import'; +import vitest from '@vitest/eslint-plugin'; import globals from 'globals'; import licenseHeader from 'eslint-plugin-license-header'; import path from 'node:path'; // Use node: prefix for built-ins @@ -159,6 +160,17 @@ export default tseslint.config( 'default-case': 'error', }, }, + { + files: ['packages/*/src/**/*.test.{ts,tsx}'], + plugins: { + vitest, + }, + rules: { + ...vitest.configs.recommended.rules, + 'vitest/expect-expect': 'off', + 'vitest/no-commented-out-tests': 'off', + }, + }, { files: ['./**/*.{tsx,ts,js}'], plugins: { diff --git a/package-lock.json b/package-lock.json index dea1f524..bb8cb5d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@types/mock-fs": "^4.13.4", "@types/shell-quote": "^1.7.5", "@vitest/coverage-v8": "^3.1.1", + "@vitest/eslint-plugin": "^1.3.4", "concurrently": "^9.2.0", "cross-env": "^7.0.3", "esbuild": "^0.25.0", @@ -3386,6 +3387,29 @@ } } }, + "node_modules/@vitest/eslint-plugin": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.3.4.tgz", + "integrity": "sha512-EOg8d0jn3BAiKnR55WkFxmxfWA3nmzrbIIuOXyTe6A72duryNgyU+bdBEauA97Aab3ho9kLmAwgPX63Ckj4QEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.24.1" + }, + "peerDependencies": { + "eslint": ">= 8.57.0", + "typescript": ">= 5.0.0", + "vitest": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", diff --git a/package.json b/package.json index 92386a60..ede883c6 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@types/mock-fs": "^4.13.4", "@types/shell-quote": "^1.7.5", "@vitest/coverage-v8": "^3.1.1", + "@vitest/eslint-plugin": "^1.3.4", "concurrently": "^9.2.0", "cross-env": "^7.0.3", "esbuild": "^0.25.0", diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 2f7c574c..ac10a1fb 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -720,19 +720,23 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { // readability, and content based on paths derived from the mocked os.homedir(). // 3. Spies on console functions (for logger output) are correctly set up if needed. // Example of a previously failing test structure: - /* - it('should correctly use mocked homedir for global path', async () => { + it.skip('should correctly use mocked homedir for global path', async () => { const MOCK_GEMINI_DIR_LOCAL = path.join('/mock/home/user', '.gemini'); - const MOCK_GLOBAL_PATH_LOCAL = path.join(MOCK_GEMINI_DIR_LOCAL, 'GEMINI.md'); + const MOCK_GLOBAL_PATH_LOCAL = path.join( + MOCK_GEMINI_DIR_LOCAL, + 'GEMINI.md', + ); mockFs({ - [MOCK_GLOBAL_PATH_LOCAL]: { type: 'file', content: 'GlobalContentOnly' } + [MOCK_GLOBAL_PATH_LOCAL]: { type: 'file', content: 'GlobalContentOnly' }, }); - const memory = await loadHierarchicalGeminiMemory("/some/other/cwd", false); + const memory = await loadHierarchicalGeminiMemory('/some/other/cwd', false); expect(memory).toBe('GlobalContentOnly'); expect(vi.mocked(os.homedir)).toHaveBeenCalled(); - expect(fsPromises.readFile).toHaveBeenCalledWith(MOCK_GLOBAL_PATH_LOCAL, 'utf-8'); + expect(fsPromises.readFile).toHaveBeenCalledWith( + MOCK_GLOBAL_PATH_LOCAL, + 'utf-8', + ); }); - */ }); describe('mergeMcpServers', () => { @@ -1294,7 +1298,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { }); }); - it('should override allowMCPServers with excludeMCPServers if overlapping ', async () => { + it('should override allowMCPServers with excludeMCPServers if overlapping', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(); const settings: Settings = { @@ -1308,7 +1312,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { }); }); - it('should prioritize mcp server flag if set ', async () => { + it('should prioritize mcp server flag if set', async () => { process.argv = [ 'node', 'script.js', diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts index b5f2d8c0..61d335c6 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -893,7 +893,7 @@ describe('useTextBuffer', () => { expect(getBufferState(result).cursor).toEqual([0, 2]); }); - it('should handle inserts that contain delete characters ', () => { + it('should handle inserts that contain delete characters', () => { const { result } = renderHook(() => useTextBuffer({ initialText: 'abcde', @@ -911,7 +911,7 @@ describe('useTextBuffer', () => { expect(getBufferState(result).cursor).toEqual([0, 2]); }); - it('should handle inserts with a mix of regular and delete characters ', () => { + it('should handle inserts with a mix of regular and delete characters', () => { const { result } = renderHook(() => useTextBuffer({ initialText: 'abcde', diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index fe6223af..97a442ef 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -1464,6 +1464,64 @@ describe('useGeminiStream', () => { }); }); + it('should process @include commands, adding user turn after processing to prevent race conditions', async () => { + const rawQuery = '@include file.txt Summarize this.'; + const processedQueryParts = [ + { text: 'Summarize this with content from @file.txt' }, + { text: 'File content...' }, + ]; + const userMessageTimestamp = Date.now(); + vi.spyOn(Date, 'now').mockReturnValue(userMessageTimestamp); + + handleAtCommandSpy.mockResolvedValue({ + processedQuery: processedQueryParts, + shouldProceed: true, + }); + + const { result } = renderHook(() => + useGeminiStream( + mockConfig.getGeminiClient() as GeminiClient, + [], + mockAddItem, + mockConfig, + mockOnDebugMessage, + mockHandleSlashCommand, + false, // shellModeActive + vi.fn(), // getPreferredEditor + vi.fn(), // onAuthError + vi.fn(), // performMemoryRefresh + false, // modelSwitched + vi.fn(), // setModelSwitched + vi.fn(), // onEditorClose + vi.fn(), // onCancelSubmit + ), + ); + + await act(async () => { + await result.current.submitQuery(rawQuery); + }); + + expect(handleAtCommandSpy).toHaveBeenCalledWith( + expect.objectContaining({ + query: rawQuery, + }), + ); + + expect(mockAddItem).toHaveBeenCalledWith( + { + type: MessageType.USER, + text: rawQuery, + }, + userMessageTimestamp, + ); + + // FIX: The expectation now matches the actual call signature. + expect(mockSendMessageStream).toHaveBeenCalledWith( + processedQueryParts, // Argument 1: The parts array directly + expect.any(AbortSignal), // Argument 2: An AbortSignal + expect.any(String), // Argument 3: The prompt_id string + ); + }); describe('Thought Reset', () => { it('should reset thought to null when starting a new prompt', async () => { // First, simulate a response with a thought @@ -1660,301 +1718,4 @@ describe('useGeminiStream', () => { ); }); }); - - it('should process @include commands, adding user turn after processing to prevent race conditions', async () => { - const rawQuery = '@include file.txt Summarize this.'; - const processedQueryParts = [ - { text: 'Summarize this with content from @file.txt' }, - { text: 'File content...' }, - ]; - const userMessageTimestamp = Date.now(); - vi.spyOn(Date, 'now').mockReturnValue(userMessageTimestamp); - - handleAtCommandSpy.mockResolvedValue({ - processedQuery: processedQueryParts, - shouldProceed: true, - }); - - const { result } = renderHook(() => - useGeminiStream( - mockConfig.getGeminiClient() as GeminiClient, - [], - mockAddItem, - mockConfig, - mockOnDebugMessage, - mockHandleSlashCommand, - false, // shellModeActive - vi.fn(), // getPreferredEditor - vi.fn(), // onAuthError - vi.fn(), // performMemoryRefresh - false, // modelSwitched - vi.fn(), // setModelSwitched - vi.fn(), // onEditorClose - vi.fn(), // onCancelSubmit - ), - ); - - await act(async () => { - await result.current.submitQuery(rawQuery); - }); - - expect(handleAtCommandSpy).toHaveBeenCalledWith( - expect.objectContaining({ - query: rawQuery, - }), - ); - - expect(mockAddItem).toHaveBeenCalledWith( - { - type: MessageType.USER, - text: rawQuery, - }, - userMessageTimestamp, - ); - - // FIX: The expectation now matches the actual call signature. - expect(mockSendMessageStream).toHaveBeenCalledWith( - processedQueryParts, // Argument 1: The parts array directly - expect.any(AbortSignal), // Argument 2: An AbortSignal - expect.any(String), // Argument 3: The prompt_id string - ); - }); - describe('Thought Reset', () => { - it('should reset thought to null when starting a new prompt', async () => { - mockSendMessageStream.mockReturnValue( - (async function* () { - yield { - type: ServerGeminiEventType.Thought, - value: { - subject: 'Previous thought', - description: 'Old description', - }, - }; - yield { - type: ServerGeminiEventType.Content, - value: 'Some response content', - }; - yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; - })(), - ); - - const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), - [], - mockAddItem, - mockConfig, - mockOnDebugMessage, - mockHandleSlashCommand, - false, - () => 'vscode' as EditorType, - () => {}, - () => Promise.resolve(), - false, - () => {}, - () => {}, - () => {}, - ), - ); - - await act(async () => { - await result.current.submitQuery('First query'); - }); - - await waitFor(() => { - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'gemini', - text: 'Some response content', - }), - expect.any(Number), - ); - }); - - mockSendMessageStream.mockReturnValue( - (async function* () { - yield { - type: ServerGeminiEventType.Content, - value: 'New response content', - }; - yield { type: ServerGeminiEventType.Finished, value: 'STOP' }; - })(), - ); - - await act(async () => { - await result.current.submitQuery('Second query'); - }); - - await waitFor(() => { - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'gemini', - text: 'New response content', - }), - expect.any(Number), - ); - }); - }); - - it('should reset thought to null when user cancels', async () => { - mockSendMessageStream.mockReturnValue( - (async function* () { - yield { - type: ServerGeminiEventType.Thought, - value: { subject: 'Some thought', description: 'Description' }, - }; - yield { type: ServerGeminiEventType.UserCancelled }; - })(), - ); - - const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), - [], - mockAddItem, - mockConfig, - mockOnDebugMessage, - mockHandleSlashCommand, - false, - () => 'vscode' as EditorType, - () => {}, - () => Promise.resolve(), - false, - () => {}, - () => {}, - () => {}, - ), - ); - - await act(async () => { - await result.current.submitQuery('Test query'); - }); - - await waitFor(() => { - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'info', - text: 'User cancelled the request.', - }), - expect.any(Number), - ); - }); - - expect(result.current.streamingState).toBe(StreamingState.Idle); - }); - - it('should reset thought to null when there is an error', async () => { - mockSendMessageStream.mockReturnValue( - (async function* () { - yield { - type: ServerGeminiEventType.Thought, - value: { subject: 'Some thought', description: 'Description' }, - }; - yield { - type: ServerGeminiEventType.Error, - value: { error: { message: 'Test error' } }, - }; - })(), - ); - - const { result } = renderHook(() => - useGeminiStream( - new MockedGeminiClientClass(mockConfig), - [], - mockAddItem, - mockConfig, - mockOnDebugMessage, - mockHandleSlashCommand, - false, - () => 'vscode' as EditorType, - () => {}, - () => Promise.resolve(), - false, - () => {}, - () => {}, - () => {}, - ), - ); - - await act(async () => { - await result.current.submitQuery('Test query'); - }); - - await waitFor(() => { - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - }), - expect.any(Number), - ); - }); - - expect(mockParseAndFormatApiError).toHaveBeenCalledWith( - { message: 'Test error' }, - expect.any(String), - undefined, - 'gemini-2.5-pro', - 'gemini-2.5-flash', - ); - }); - }); - - it('should process @include commands, adding user turn after processing to prevent race conditions', async () => { - const rawQuery = '@include file.txt Summarize this.'; - const processedQueryParts = [ - { text: 'Summarize this with content from @file.txt' }, - { text: 'File content...' }, - ]; - const userMessageTimestamp = Date.now(); - vi.spyOn(Date, 'now').mockReturnValue(userMessageTimestamp); - - handleAtCommandSpy.mockResolvedValue({ - processedQuery: processedQueryParts, - shouldProceed: true, - }); - - const { result } = renderHook(() => - useGeminiStream( - mockConfig.getGeminiClient() as GeminiClient, - [], - mockAddItem, - mockConfig, - mockOnDebugMessage, - mockHandleSlashCommand, - false, - vi.fn(), - vi.fn(), - vi.fn(), - false, - vi.fn(), - vi.fn(), - vi.fn(), - ), - ); - - await act(async () => { - await result.current.submitQuery(rawQuery); - }); - - expect(handleAtCommandSpy).toHaveBeenCalledWith( - expect.objectContaining({ - query: rawQuery, - }), - ); - - expect(mockAddItem).toHaveBeenCalledWith( - { - type: MessageType.USER, - text: rawQuery, - }, - userMessageTimestamp, - ); - - // FIX: This expectation now correctly matches the actual function call signature. - expect(mockSendMessageStream).toHaveBeenCalledWith( - processedQueryParts, // Argument 1: The parts array directly - expect.any(AbortSignal), // Argument 2: An AbortSignal - expect.any(String), // Argument 3: The prompt_id string - ); - }); }); diff --git a/packages/cli/src/utils/gitUtils.test.ts b/packages/cli/src/utils/gitUtils.test.ts index 7a5f210c..d6ac911e 100644 --- a/packages/cli/src/utils/gitUtils.test.ts +++ b/packages/cli/src/utils/gitUtils.test.ts @@ -120,7 +120,7 @@ describe('getLatestRelease', async () => { it('throws an error if the fetch fails', async () => { global.fetch = vi.fn(() => Promise.reject('nope')); - expect(getLatestGitHubRelease()).rejects.toThrowError( + await expect(getLatestGitHubRelease()).rejects.toThrowError( /Unable to determine the latest/, ); }); @@ -132,7 +132,7 @@ describe('getLatestRelease', async () => { json: () => Promise.resolve({ foo: 'bar' }), } as Response), ); - expect(getLatestGitHubRelease()).rejects.toThrowError( + await expect(getLatestGitHubRelease()).rejects.toThrowError( /Unable to determine the latest/, ); }); @@ -144,6 +144,6 @@ describe('getLatestRelease', async () => { json: () => Promise.resolve({ tag_name: 'v1.2.3' }), } as Response), ); - expect(getLatestGitHubRelease()).resolves.toBe('v1.2.3'); + await expect(getLatestGitHubRelease()).resolves.toBe('v1.2.3'); }); }); diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 5e3f8eb7..f6dfa8ec 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -344,37 +344,6 @@ describe('Gemini Client (client.ts)', () => { }); }); - describe('generateContent', () => { - it('should call generateContent with the correct parameters', async () => { - const contents = [{ role: 'user', parts: [{ text: 'hello' }] }]; - const generationConfig = { temperature: 0.5 }; - const abortSignal = new AbortController().signal; - - // Mock countTokens - const mockGenerator: Partial = { - countTokens: vi.fn().mockResolvedValue({ totalTokens: 1 }), - generateContent: mockGenerateContentFn, - }; - client['contentGenerator'] = mockGenerator as ContentGenerator; - - await client.generateContent(contents, generationConfig, abortSignal); - - expect(mockGenerateContentFn).toHaveBeenCalledWith( - { - model: 'test-model', - config: { - abortSignal, - systemInstruction: getCoreSystemPrompt(''), - temperature: 0.5, - topP: 1, - }, - contents, - }, - 'test-session-id', - ); - }); - }); - describe('generateJson', () => { it('should call generateContent with the correct parameters', async () => { const contents = [{ role: 'user', parts: [{ text: 'hello' }] }]; @@ -705,6 +674,60 @@ describe('Gemini Client (client.ts)', () => { // Assert that the chat was reset expect(newChat).not.toBe(initialChat); }); + + it('should use current model from config for token counting after sendMessage', async () => { + const initialModel = client['config'].getModel(); + + const mockCountTokens = vi + .fn() + .mockResolvedValueOnce({ totalTokens: 100000 }) + .mockResolvedValueOnce({ totalTokens: 5000 }); + + const mockSendMessage = vi.fn().mockResolvedValue({ text: 'Summary' }); + + const mockChatHistory = [ + { role: 'user', parts: [{ text: 'Long conversation' }] }, + { role: 'model', parts: [{ text: 'Long response' }] }, + ]; + + const mockChat: Partial = { + getHistory: vi.fn().mockReturnValue(mockChatHistory), + setHistory: vi.fn(), + sendMessage: mockSendMessage, + }; + + const mockGenerator: Partial = { + countTokens: mockCountTokens, + }; + + // mock the model has been changed between calls of `countTokens` + const firstCurrentModel = initialModel + '-changed-1'; + const secondCurrentModel = initialModel + '-changed-2'; + vi.spyOn(client['config'], 'getModel') + .mockReturnValueOnce(firstCurrentModel) + .mockReturnValueOnce(secondCurrentModel); + + client['chat'] = mockChat as GeminiChat; + client['contentGenerator'] = mockGenerator as ContentGenerator; + client['startChat'] = vi.fn().mockResolvedValue(mockChat); + + const result = await client.tryCompressChat('prompt-id-4', true); + + expect(mockCountTokens).toHaveBeenCalledTimes(2); + expect(mockCountTokens).toHaveBeenNthCalledWith(1, { + model: firstCurrentModel, + contents: mockChatHistory, + }); + expect(mockCountTokens).toHaveBeenNthCalledWith(2, { + model: secondCurrentModel, + contents: expect.any(Array), + }); + + expect(result).toEqual({ + originalTokenCount: 100000, + newTokenCount: 5000, + }); + }); }); describe('sendMessageStream', () => { @@ -1866,6 +1889,35 @@ ${JSON.stringify( }); describe('generateContent', () => { + it('should call generateContent with the correct parameters', async () => { + const contents = [{ role: 'user', parts: [{ text: 'hello' }] }]; + const generationConfig = { temperature: 0.5 }; + const abortSignal = new AbortController().signal; + + // Mock countTokens + const mockGenerator: Partial = { + countTokens: vi.fn().mockResolvedValue({ totalTokens: 1 }), + generateContent: mockGenerateContentFn, + }; + client['contentGenerator'] = mockGenerator as ContentGenerator; + + await client.generateContent(contents, generationConfig, abortSignal); + + expect(mockGenerateContentFn).toHaveBeenCalledWith( + { + model: 'test-model', + config: { + abortSignal, + systemInstruction: getCoreSystemPrompt(''), + temperature: 0.5, + topP: 1, + }, + contents, + }, + 'test-session-id', + ); + }); + it('should use current model from config for content generation', async () => { const initialModel = client['config'].getModel(); const contents = [{ role: 'user', parts: [{ text: 'test' }] }]; @@ -1897,62 +1949,6 @@ ${JSON.stringify( }); }); - describe('tryCompressChat', () => { - it('should use current model from config for token counting after sendMessage', async () => { - const initialModel = client['config'].getModel(); - - const mockCountTokens = vi - .fn() - .mockResolvedValueOnce({ totalTokens: 100000 }) - .mockResolvedValueOnce({ totalTokens: 5000 }); - - const mockSendMessage = vi.fn().mockResolvedValue({ text: 'Summary' }); - - const mockChatHistory = [ - { role: 'user', parts: [{ text: 'Long conversation' }] }, - { role: 'model', parts: [{ text: 'Long response' }] }, - ]; - - const mockChat: Partial = { - getHistory: vi.fn().mockReturnValue(mockChatHistory), - setHistory: vi.fn(), - sendMessage: mockSendMessage, - }; - - const mockGenerator: Partial = { - countTokens: mockCountTokens, - }; - - // mock the model has been changed between calls of `countTokens` - const firstCurrentModel = initialModel + '-changed-1'; - const secondCurrentModel = initialModel + '-changed-2'; - vi.spyOn(client['config'], 'getModel') - .mockReturnValueOnce(firstCurrentModel) - .mockReturnValueOnce(secondCurrentModel); - - client['chat'] = mockChat as GeminiChat; - client['contentGenerator'] = mockGenerator as ContentGenerator; - client['startChat'] = vi.fn().mockResolvedValue(mockChat); - - const result = await client.tryCompressChat('prompt-id-4', true); - - expect(mockCountTokens).toHaveBeenCalledTimes(2); - expect(mockCountTokens).toHaveBeenNthCalledWith(1, { - model: firstCurrentModel, - contents: mockChatHistory, - }); - expect(mockCountTokens).toHaveBeenNthCalledWith(2, { - model: secondCurrentModel, - contents: expect.any(Array), - }); - - expect(result).toEqual({ - originalTokenCount: 100000, - newTokenCount: 5000, - }); - }); - }); - describe('handleFlashFallback', () => { it('should use current model from config when checking for fallback', async () => { const initialModel = client['config'].getModel(); diff --git a/packages/core/src/utils/memoryImportProcessor.test.ts b/packages/core/src/utils/memoryImportProcessor.test.ts index 300d44fb..a08b5969 100644 --- a/packages/core/src/utils/memoryImportProcessor.test.ts +++ b/packages/core/src/utils/memoryImportProcessor.test.ts @@ -675,201 +675,6 @@ describe('memoryImportProcessor', () => { expect(result.content).toContain('A @./b.md'); expect(result.content).toContain('B content'); }); - - it('should build import tree structure', async () => { - const content = 'Main content @./nested.md @./simple.md'; - const projectRoot = testPath('test', 'project'); - const basePath = testPath(projectRoot, 'src'); - const nestedContent = 'Nested @./inner.md content'; - const simpleContent = 'Simple content'; - const innerContent = 'Inner content'; - - mockedFs.access.mockResolvedValue(undefined); - mockedFs.readFile - .mockResolvedValueOnce(nestedContent) - .mockResolvedValueOnce(simpleContent) - .mockResolvedValueOnce(innerContent); - - const result = await processImports(content, basePath, true); - - // Use marked to find and validate import comments - const comments = findMarkdownComments(result.content); - const importComments = comments.filter((c) => - c.includes('Imported from:'), - ); - - expect(importComments.some((c) => c.includes('./nested.md'))).toBe(true); - expect(importComments.some((c) => c.includes('./simple.md'))).toBe(true); - expect(importComments.some((c) => c.includes('./inner.md'))).toBe(true); - - // Use marked to validate the markdown structure is well-formed - const tokens = parseMarkdown(result.content); - expect(tokens).toBeDefined(); - expect(tokens.length).toBeGreaterThan(0); - - // Verify the content contains expected text using marked parsing - const textContent = tokens - .filter((token) => token.type === 'paragraph') - .map((token) => token.raw) - .join(' '); - - expect(textContent).toContain('Main content'); - expect(textContent).toContain('Nested'); - expect(textContent).toContain('Simple content'); - expect(textContent).toContain('Inner content'); - - // Verify import tree structure - expect(result.importTree.path).toBe('unknown'); // No currentFile set in test - expect(result.importTree.imports).toHaveLength(2); - - // First import: nested.md - const expectedNestedPath = testPath(projectRoot, 'src', 'nested.md'); - const expectedInnerPath = testPath(projectRoot, 'src', 'inner.md'); - const expectedSimplePath = testPath(projectRoot, 'src', 'simple.md'); - - // Check that the paths match using includes to handle potential absolute/relative differences - expect(result.importTree.imports![0].path).toContain(expectedNestedPath); - expect(result.importTree.imports![0].imports).toHaveLength(1); - expect(result.importTree.imports![0].imports![0].path).toContain( - expectedInnerPath, - ); - expect(result.importTree.imports![0].imports![0].imports).toBeUndefined(); - - // Second import: simple.md - expect(result.importTree.imports![1].path).toContain(expectedSimplePath); - expect(result.importTree.imports![1].imports).toBeUndefined(); - }); - - it('should produce flat output in Claude-style with unique files in order', async () => { - const content = 'Main @./nested.md content @./simple.md'; - const projectRoot = testPath('test', 'project'); - const basePath = testPath(projectRoot, 'src'); - const nestedContent = 'Nested @./inner.md content'; - const simpleContent = 'Simple content'; - const innerContent = 'Inner content'; - - mockedFs.access.mockResolvedValue(undefined); - mockedFs.readFile - .mockResolvedValueOnce(nestedContent) - .mockResolvedValueOnce(simpleContent) - .mockResolvedValueOnce(innerContent); - - const result = await processImports( - content, - basePath, - true, - undefined, - projectRoot, - 'flat', - ); - - // Verify all expected files are present by checking for their basenames - expect(result.content).toContain('nested.md'); - expect(result.content).toContain('simple.md'); - expect(result.content).toContain('inner.md'); - - // Verify content is present - expect(result.content).toContain('Nested @./inner.md content'); - expect(result.content).toContain('Simple content'); - expect(result.content).toContain('Inner content'); - }); - - it('should not duplicate files in flat output if imported multiple times', async () => { - const content = 'Main @./dup.md again @./dup.md'; - const projectRoot = testPath('test', 'project'); - const basePath = testPath(projectRoot, 'src'); - const dupContent = 'Duplicated content'; - - // Create a normalized path for the duplicate file - const dupFilePath = path.normalize(path.join(basePath, 'dup.md')); - - // Mock the file system access - mockedFs.access.mockImplementation((filePath) => { - const pathStr = filePath.toString(); - if (path.normalize(pathStr) === dupFilePath) { - return Promise.resolve(); - } - return Promise.reject(new Error(`File not found: ${pathStr}`)); - }); - - // Mock the file reading - mockedFs.readFile.mockImplementation((filePath) => { - const pathStr = filePath.toString(); - if (path.normalize(pathStr) === dupFilePath) { - return Promise.resolve(dupContent); - } - return Promise.reject(new Error(`File not found: ${pathStr}`)); - }); - - const result = await processImports( - content, - basePath, - true, // debugMode - undefined, // importState - projectRoot, - 'flat', - ); - - // In flat mode, the output should only contain the main file content with import markers - // The imported file content should not be included in the flat output - expect(result.content).toContain('Main @./dup.md again @./dup.md'); - - // The imported file content should not appear in the output - // This is the current behavior of the implementation - expect(result.content).not.toContain(dupContent); - - // The file marker should not appear in the output - // since the imported file content is not included in flat mode - const fileMarker = `--- File: ${dupFilePath} ---`; - expect(result.content).not.toContain(fileMarker); - expect(result.content).not.toContain('--- End of File: ' + dupFilePath); - - // The main file path should be in the output - // Since we didn't pass an importState, it will use the basePath as the file path - const mainFilePath = path.normalize(path.resolve(basePath)); - expect(result.content).toContain(`--- File: ${mainFilePath} ---`); - expect(result.content).toContain(`--- End of File: ${mainFilePath}`); - }); - - it('should handle nested imports in flat output', async () => { - const content = 'Root @./a.md'; - const projectRoot = testPath('test', 'project'); - const basePath = testPath(projectRoot, 'src'); - const aContent = 'A @./b.md'; - const bContent = 'B content'; - - mockedFs.access.mockResolvedValue(undefined); - mockedFs.readFile - .mockResolvedValueOnce(aContent) - .mockResolvedValueOnce(bContent); - - const result = await processImports( - content, - basePath, - true, - undefined, - projectRoot, - 'flat', - ); - - // Verify all files are present by checking for their basenames - expect(result.content).toContain('a.md'); - expect(result.content).toContain('b.md'); - - // Verify content is in the correct order - const contentStr = result.content; - const aIndex = contentStr.indexOf('a.md'); - const bIndex = contentStr.indexOf('b.md'); - const rootIndex = contentStr.indexOf('Root @./a.md'); - - expect(rootIndex).toBeLessThan(aIndex); - expect(aIndex).toBeLessThan(bIndex); - - // Verify content is present - expect(result.content).toContain('Root @./a.md'); - expect(result.content).toContain('A @./b.md'); - expect(result.content).toContain('B content'); - }); }); describe('validateImportPath', () => { diff --git a/packages/core/src/utils/retry.test.ts b/packages/core/src/utils/retry.test.ts index 196e7341..98992af3 100644 --- a/packages/core/src/utils/retry.test.ts +++ b/packages/core/src/utils/retry.test.ts @@ -82,6 +82,7 @@ describe('retryWithBackoff', () => { // 2. IMPORTANT: Attach the rejection expectation to the promise *immediately*. // This ensures a 'catch' handler is present before the promise can reject. // The result is a new promise that resolves when the assertion is met. + // eslint-disable-next-line vitest/valid-expect const assertionPromise = expect(promise).rejects.toThrow( 'Simulated error attempt 3', ); @@ -126,7 +127,7 @@ describe('retryWithBackoff', () => { // Attach the rejection expectation *before* running timers const assertionPromise = - expect(promise).rejects.toThrow('Too Many Requests'); + expect(promise).rejects.toThrow('Too Many Requests'); // eslint-disable-line vitest/valid-expect // Run timers to trigger retries and eventual rejection await vi.runAllTimersAsync(); @@ -194,6 +195,7 @@ describe('retryWithBackoff', () => { // We expect rejections as mockFn fails 5 times const promise1 = runRetry(); // Attach the rejection expectation *before* running timers + // eslint-disable-next-line vitest/valid-expect const assertionPromise1 = expect(promise1).rejects.toThrow(); await vi.runAllTimersAsync(); // Advance for the delay in the first runRetry await assertionPromise1; @@ -208,6 +210,7 @@ describe('retryWithBackoff', () => { const promise2 = runRetry(); // Attach the rejection expectation *before* running timers + // eslint-disable-next-line vitest/valid-expect const assertionPromise2 = expect(promise2).rejects.toThrow(); await vi.runAllTimersAsync(); // Advance for the delay in the second runRetry await assertionPromise2;