import { beforeEach, describe, expect, it, vi } from 'vitest'; import { cleanupLanguageParser, parseFile } from '../../../src/core/treeSitter/parseFile.js'; import { logger } from '../../../src/shared/logger.js'; import { createMockConfig } from '../../testing/testUtils.js'; // A single mock LanguageParser instance reused via the module singleton in // parseFile.ts. Each test reconfigures its methods to simulate failures at // different stages of the compression pipeline. const mockParser = { init: vi.fn(), guessTheLang: vi.fn(), getQueryForLang: vi.fn(), getParserForLang: vi.fn(), getStrategyForLang: vi.fn(), dispose: vi.fn(), }; vi.mock('../../../src/core/treeSitter/languageParser.js', () => ({ // A class so `new LanguageParser()` works; it delegates to the shared // mockParser whose method behaviors each test reconfigures. LanguageParser: class { init = mockParser.init; guessTheLang = mockParser.guessTheLang; getQueryForLang = mockParser.getQueryForLang; getParserForLang = mockParser.getParserForLang; getStrategyForLang = mockParser.getStrategyForLang; dispose = mockParser.dispose; }, })); vi.mock('../../../src/shared/logger.js', () => ({ logger: { warn: vi.fn(), debug: vi.fn(), trace: vi.fn(), log: vi.fn(), error: vi.fn(), }, })); // Shared mock tree so tests can assert tree.delete() is invoked for WASM cleanup. // vi.clearAllMocks() in beforeEach resets its call history between tests. const mockTree = { rootNode: {}, delete: vi.fn() }; const makeTree = () => mockTree; const makeWorkingQuery = () => ({ captures: vi.fn().mockReturnValue([]) }); const makeWorkingParser = () => ({ parse: vi.fn().mockReturnValue(makeTree()) }); const makeWorkingStrategy = () => ({ parseCapture: vi.fn().mockReturnValue(null) }); const config = createMockConfig({}); describe('parseFile error handling', () => { beforeEach(async () => { vi.clearAllMocks(); // Reset the module singleton so each test starts from a clean init() state. mockParser.dispose.mockResolvedValue(undefined); await cleanupLanguageParser(); vi.clearAllMocks(); // Working defaults; individual tests override one stage to make it fail. mockParser.init.mockResolvedValue(undefined); mockParser.guessTheLang.mockReturnValue('javascript'); mockParser.getQueryForLang.mockResolvedValue(makeWorkingQuery()); mockParser.getParserForLang.mockResolvedValue(makeWorkingParser()); mockParser.getStrategyForLang.mockResolvedValue(makeWorkingStrategy()); }); it('returns undefined (not an empty string) and warns when language preparation throws', async () => { // Mirrors a tree-sitter WASM abort surfacing as a prepare-time error. mockParser.getQueryForLang.mockRejectedValue( new Error('Failed to prepare language cpp: table index is out of bounds'), ); const result = await parseFile('int main() { return 0; }', 'main.cpp', config); expect(result).toBeUndefined(); expect(result).not.toBe(''); expect(logger.warn).toHaveBeenCalledTimes(1); }); it('returns undefined when the parser throws during parse', async () => { mockParser.getParserForLang.mockResolvedValue({ parse: vi.fn(() => { throw new Error('Aborted(). Build with -sASSERTIONS for more info.'); }), }); const result = await parseFile('int main() { return 0; }', 'main.cpp', config); expect(result).toBeUndefined(); expect(logger.warn).toHaveBeenCalledTimes(1); }); it('returns undefined when query.captures throws', async () => { mockParser.getQueryForLang.mockResolvedValue({ captures: vi.fn(() => { throw new Error('captures failed'); }), }); const result = await parseFile('const x = 1;', 'main.js', config); expect(result).toBeUndefined(); expect(logger.warn).toHaveBeenCalledTimes(1); }); it('returns undefined when the parse strategy throws', async () => { const capture = { node: { startPosition: { row: 0 }, endPosition: { row: 0 } } }; mockParser.getQueryForLang.mockResolvedValue({ captures: vi.fn().mockReturnValue([capture]) }); mockParser.getStrategyForLang.mockResolvedValue({ parseCapture: vi.fn(() => { throw new Error('strategy failed'); }), }); const result = await parseFile('const x = 1;', 'main.js', config); expect(result).toBeUndefined(); expect(logger.warn).toHaveBeenCalledTimes(1); // The tree was created before the strategy threw, so it must still be freed. expect(mockTree.delete).toHaveBeenCalledTimes(1); }); it('returns an empty string (not undefined) when a parseable file yields no captures', async () => { // Contract boundary: '' means "compressed to nothing, keep it" while // undefined means "could not compress, fall back". The default mock yields // zero captures, exercising the success-with-empty-result path; the caller // preserves '' via `parsedContent ?? processedContent`. const result = await parseFile('const x = 1;', 'main.js', config); expect(result).toBe(''); expect(logger.warn).not.toHaveBeenCalled(); }); it('returns undefined without warning when the parser yields no tree', async () => { // parser.parse() can return null; this is an expected non-result, logged at // debug level (not warn), and falls back to uncompressed content. mockParser.getParserForLang.mockResolvedValue({ parse: vi.fn().mockReturnValue(null) }); const result = await parseFile('const x = 1;', 'main.js', config); expect(result).toBeUndefined(); expect(logger.warn).not.toHaveBeenCalled(); }); it('returns undefined without warning for unsupported languages', async () => { mockParser.guessTheLang.mockReturnValue(undefined); const result = await parseFile('some content', 'file.unknown', config); expect(result).toBeUndefined(); expect(logger.warn).not.toHaveBeenCalled(); }); it('returns the compressed string on the success path', async () => { const capture = { node: { startPosition: { row: 0 }, endPosition: { row: 0 } } }; mockParser.getQueryForLang.mockResolvedValue({ captures: vi.fn().mockReturnValue([capture]) }); mockParser.getStrategyForLang.mockResolvedValue({ parseCapture: vi.fn().mockReturnValue('function foo()'), }); const result = await parseFile('function foo() {}', 'foo.js', config); expect(result).toBe('function foo()'); expect(logger.warn).not.toHaveBeenCalled(); // The parsed tree is freed on the success path too. expect(mockTree.delete).toHaveBeenCalledTimes(1); }); it('retries initialization on the next call after a failed init', async () => { // A failed init must not cache an uninitialized parser. The first call // degrades to undefined; the next call re-attempts init and succeeds. mockParser.init.mockRejectedValueOnce(new Error('init failed')).mockResolvedValue(undefined); const capture = { node: { startPosition: { row: 0 }, endPosition: { row: 0 } } }; mockParser.getQueryForLang.mockResolvedValue({ captures: vi.fn().mockReturnValue([capture]) }); mockParser.getStrategyForLang.mockResolvedValue({ parseCapture: vi.fn().mockReturnValue('function foo()'), }); const firstResult = await parseFile('function foo() {}', 'foo.js', config); expect(firstResult).toBeUndefined(); expect(logger.warn).toHaveBeenCalledTimes(1); const secondResult = await parseFile('function foo() {}', 'foo.js', config); expect(secondResult).toBe('function foo()'); expect(mockParser.init).toHaveBeenCalledTimes(2); }); it('reuses the initialized singleton across repeated post-init aborts (does not re-init per file)', async () => { // The #1668 trigger is a post-init WASM abort surfacing during language // preparation. init() has already succeeded, so the singleton is cached and // reused: subsequent files degrade to uncompressed without re-running // Parser.init(). This documents the bounded per-worker degradation; full // runtime recovery would require recycling the worker. mockParser.getQueryForLang.mockRejectedValue( new Error('Failed to prepare language cpp: table index is out of bounds'), ); const first = await parseFile('int main() { return 0; }', 'a.cpp', config); const second = await parseFile('int main() { return 0; }', 'b.cpp', config); expect(first).toBeUndefined(); expect(second).toBeUndefined(); expect(mockParser.init).toHaveBeenCalledTimes(1); expect(logger.warn).toHaveBeenCalledTimes(2); }); });