Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeSettingsChangeTracker.spec.ts
13406 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { beforeEach, describe, expect, it } from 'vitest';6import { IFileSystemService } from '../../../../../platform/filesystem/common/fileSystemService';7import { FileType } from '../../../../../platform/filesystem/common/fileTypes';8import { MockFileSystemService } from '../../../../../platform/filesystem/node/test/mockFileSystemService';9import { TestingServiceCollection } from '../../../../../platform/test/node/services';10import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../util/common/test/testUtils';11import { URI } from '../../../../../util/vs/base/common/uri';12import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';13import { createExtensionUnitTestingServices } from '../../../../test/node/services';14import { ClaudeSettingsChangeTracker } from '../claudeSettingsChangeTracker';1516describe('ClaudeSettingsChangeTracker', () => {17let mockFs: MockFileSystemService;18let testingServiceCollection: TestingServiceCollection;19let tracker: ClaudeSettingsChangeTracker;2021const store = ensureNoDisposablesAreLeakedInTestSuite();2223const testFile1 = URI.file('/project/.claude/CLAUDE.md');24const testFile2 = URI.file('/project/.claude/settings.json');2526beforeEach(() => {27mockFs = new MockFileSystemService();28testingServiceCollection = store.add(createExtensionUnitTestingServices(store));29testingServiceCollection.set(IFileSystemService, mockFs);3031const accessor = testingServiceCollection.createTestingAccessor();32const instaService = accessor.get(IInstantiationService);33tracker = instaService.createInstance(ClaudeSettingsChangeTracker);34});3536describe('takeSnapshot', () => {37it('should capture mtime of existing files', async () => {38mockFs.mockFile(testFile1, '# Instructions', 1000);3940tracker.registerPathResolver(() => [testFile1]);41await tracker.takeSnapshot();4243// No changes immediately after snapshot44const hasChanges = await tracker.hasChanges();45expect(hasChanges).toBe(false);46});4748it('should record non-existent files as 0 mtime', async () => {49// testFile1 is not mocked, so stat will throw50tracker.registerPathResolver(() => [testFile1]);51await tracker.takeSnapshot();5253// No changes immediately after snapshot54const hasChanges = await tracker.hasChanges();55expect(hasChanges).toBe(false);56});57});5859describe('hasChanges', () => {60it('should return false when files have not changed', async () => {61mockFs.mockFile(testFile1, '# Instructions', 1000);6263tracker.registerPathResolver(() => [testFile1]);64await tracker.takeSnapshot();6566const hasChanges = await tracker.hasChanges();67expect(hasChanges).toBe(false);68});6970it('should return true when file mtime increases', async () => {71mockFs.mockFile(testFile1, '# Instructions', 1000);7273tracker.registerPathResolver(() => [testFile1]);74await tracker.takeSnapshot();7576// Simulate file modification by updating mtime77mockFs.mockFile(testFile1, '# Updated Instructions', 2000);7879const hasChanges = await tracker.hasChanges();80expect(hasChanges).toBe(true);81});8283it('should return true when a new file is created', async () => {84// File doesn't exist at snapshot time85tracker.registerPathResolver(() => [testFile1]);86await tracker.takeSnapshot();8788// File is created89mockFs.mockFile(testFile1, '# New Instructions', 1000);9091const hasChanges = await tracker.hasChanges();92expect(hasChanges).toBe(true);93});9495it('should return true when a file is deleted', async () => {96mockFs.mockFile(testFile1, '# Instructions', 1000);9798tracker.registerPathResolver(() => [testFile1]);99await tracker.takeSnapshot();100101// Simulate file deletion by mocking an error102mockFs.mockError(testFile1, new Error('ENOENT'));103104const hasChanges = await tracker.hasChanges();105expect(hasChanges).toBe(true);106});107108it('should track multiple files from single resolver', async () => {109mockFs.mockFile(testFile1, '# Instructions', 1000);110mockFs.mockFile(testFile2, '{}', 1000);111112tracker.registerPathResolver(() => [testFile1, testFile2]);113await tracker.takeSnapshot();114115// Modify only second file116mockFs.mockFile(testFile2, '{"hooks": []}', 2000);117118const hasChanges = await tracker.hasChanges();119expect(hasChanges).toBe(true);120});121});122123describe('multiple path resolvers', () => {124it('should track files from all registered resolvers', async () => {125mockFs.mockFile(testFile1, '# Instructions', 1000);126mockFs.mockFile(testFile2, '{}', 1000);127128tracker.registerPathResolver(() => [testFile1]);129tracker.registerPathResolver(() => [testFile2]);130await tracker.takeSnapshot();131132// Modify second file (from second resolver)133mockFs.mockFile(testFile2, '{"updated": true}', 2000);134135const hasChanges = await tracker.hasChanges();136expect(hasChanges).toBe(true);137});138139it('should detect new files added by resolver after snapshot', async () => {140const testFile3 = URI.file('/project/.claude/new-file.md');141const dynamicPaths: URI[] = [testFile1];142143tracker.registerPathResolver(() => dynamicPaths);144await tracker.takeSnapshot();145146// Add a new file to the resolver's list and create it147dynamicPaths.push(testFile3);148mockFs.mockFile(testFile3, '# New file', 1000);149150const hasChanges = await tracker.hasChanges();151// testFile3 wasn't in the original snapshot, so it's a "new" file152expect(hasChanges).toBe(true);153});154});155156describe('registerDirectoryResolver', () => {157const agentsDir = URI.file('/project/.claude/agents');158const agent1 = URI.file('/project/.claude/agents/test-runner.md');159const agent2 = URI.file('/project/.claude/agents/code-reviewer.md');160161it('should track files in registered directories', async () => {162mockFs.mockDirectory(agentsDir, [163['test-runner.md', FileType.File],164['code-reviewer.md', FileType.File],165]);166mockFs.mockFile(agent1, '# Test Runner', 1000);167mockFs.mockFile(agent2, '# Code Reviewer', 1000);168169tracker.registerDirectoryResolver(() => [agentsDir]);170await tracker.takeSnapshot();171172const hasChanges = await tracker.hasChanges();173expect(hasChanges).toBe(false);174});175176it('should detect modified files in directory', async () => {177mockFs.mockDirectory(agentsDir, [178['test-runner.md', FileType.File],179]);180mockFs.mockFile(agent1, '# Test Runner', 1000);181182tracker.registerDirectoryResolver(() => [agentsDir]);183await tracker.takeSnapshot();184185// Modify the file186mockFs.mockFile(agent1, '# Updated Test Runner', 2000);187188const hasChanges = await tracker.hasChanges();189expect(hasChanges).toBe(true);190});191192it('should detect new files added to directory', async () => {193mockFs.mockDirectory(agentsDir, [194['test-runner.md', FileType.File],195]);196mockFs.mockFile(agent1, '# Test Runner', 1000);197198tracker.registerDirectoryResolver(() => [agentsDir]);199await tracker.takeSnapshot();200201// Add a new file to the directory202mockFs.mockDirectory(agentsDir, [203['test-runner.md', FileType.File],204['code-reviewer.md', FileType.File],205]);206mockFs.mockFile(agent2, '# Code Reviewer', 1000);207208const hasChanges = await tracker.hasChanges();209expect(hasChanges).toBe(true);210});211212it('should detect deleted files from directory', async () => {213mockFs.mockDirectory(agentsDir, [214['test-runner.md', FileType.File],215['code-reviewer.md', FileType.File],216]);217mockFs.mockFile(agent1, '# Test Runner', 1000);218mockFs.mockFile(agent2, '# Code Reviewer', 1000);219220tracker.registerDirectoryResolver(() => [agentsDir]);221await tracker.takeSnapshot();222223// Remove agent2 from directory listing224mockFs.mockDirectory(agentsDir, [225['test-runner.md', FileType.File],226]);227228const hasChanges = await tracker.hasChanges();229expect(hasChanges).toBe(true);230});231232it('should handle non-existent directories gracefully', async () => {233// Don't mock the directory - it doesn't exist234tracker.registerDirectoryResolver(() => [agentsDir]);235await tracker.takeSnapshot();236237const hasChanges = await tracker.hasChanges();238expect(hasChanges).toBe(false);239});240});241242describe('extension filtering', () => {243const agentsDir = URI.file('/project/.claude/agents');244245it('should only track files with matching extension', async () => {246mockFs.mockDirectory(agentsDir, [247['test-runner.md', FileType.File],248['readme.txt', FileType.File],249['config.json', FileType.File],250]);251mockFs.mockFile(URI.file('/project/.claude/agents/test-runner.md'), '# Test', 1000);252mockFs.mockFile(URI.file('/project/.claude/agents/readme.txt'), 'readme', 1000);253mockFs.mockFile(URI.file('/project/.claude/agents/config.json'), '{}', 1000);254255tracker.registerDirectoryResolver(() => [agentsDir], '.md');256await tracker.takeSnapshot();257258// Modify the txt file - should NOT trigger change since we only track .md259mockFs.mockFile(URI.file('/project/.claude/agents/readme.txt'), 'updated readme', 2000);260261const hasChanges = await tracker.hasChanges();262expect(hasChanges).toBe(false);263});264265it('should detect changes to files with matching extension', async () => {266mockFs.mockDirectory(agentsDir, [267['test-runner.md', FileType.File],268['readme.txt', FileType.File],269]);270mockFs.mockFile(URI.file('/project/.claude/agents/test-runner.md'), '# Test', 1000);271mockFs.mockFile(URI.file('/project/.claude/agents/readme.txt'), 'readme', 1000);272273tracker.registerDirectoryResolver(() => [agentsDir], '.md');274await tracker.takeSnapshot();275276// Modify the .md file - should trigger change277mockFs.mockFile(URI.file('/project/.claude/agents/test-runner.md'), '# Updated', 2000);278279const hasChanges = await tracker.hasChanges();280expect(hasChanges).toBe(true);281});282});283284describe('lazy evaluation', () => {285it('should stop checking after first change is found', async () => {286mockFs.mockFile(testFile1, '# Instructions', 1000);287mockFs.mockFile(testFile2, '{}', 1000);288289// Register two resolvers290tracker.registerPathResolver(() => [testFile1]);291tracker.registerPathResolver(() => [testFile2]);292await tracker.takeSnapshot();293294// Modify first file295mockFs.mockFile(testFile1, '# Updated', 2000);296297mockFs.resetStatCallCount();298const hasChanges = await tracker.hasChanges();299300expect(hasChanges).toBe(true);301// Should only have called stat once (for testFile1) before returning302expect(mockFs.getStatCallCount()).toBe(1);303});304305it('should not invoke later resolvers if early change found', async () => {306mockFs.mockFile(testFile1, '# Instructions', 1000);307mockFs.mockFile(testFile2, '{}', 1000);308309let resolver2Called = false;310tracker.registerPathResolver(() => [testFile1]);311tracker.registerPathResolver(() => {312resolver2Called = true;313return [testFile2];314});315await tracker.takeSnapshot();316317// Modify first file318mockFs.mockFile(testFile1, '# Updated', 2000);319320resolver2Called = false;321await tracker.hasChanges();322323// Second resolver should not have been called324expect(resolver2Called).toBe(false);325});326327it('should check all resolvers when no changes found', async () => {328mockFs.mockFile(testFile1, '# Instructions', 1000);329mockFs.mockFile(testFile2, '{}', 1000);330331let resolver2Called = false;332tracker.registerPathResolver(() => [testFile1]);333tracker.registerPathResolver(() => {334resolver2Called = true;335return [testFile2];336});337await tracker.takeSnapshot();338339// No modifications340resolver2Called = false;341await tracker.hasChanges();342343// Both resolvers should have been called344expect(resolver2Called).toBe(true);345});346});347});348349350