Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeSettingsChangeTracker.spec.ts
13406 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { beforeEach, describe, expect, it } from 'vitest';
7
import { IFileSystemService } from '../../../../../platform/filesystem/common/fileSystemService';
8
import { FileType } from '../../../../../platform/filesystem/common/fileTypes';
9
import { MockFileSystemService } from '../../../../../platform/filesystem/node/test/mockFileSystemService';
10
import { TestingServiceCollection } from '../../../../../platform/test/node/services';
11
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../util/common/test/testUtils';
12
import { URI } from '../../../../../util/vs/base/common/uri';
13
import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';
14
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
15
import { ClaudeSettingsChangeTracker } from '../claudeSettingsChangeTracker';
16
17
describe('ClaudeSettingsChangeTracker', () => {
18
let mockFs: MockFileSystemService;
19
let testingServiceCollection: TestingServiceCollection;
20
let tracker: ClaudeSettingsChangeTracker;
21
22
const store = ensureNoDisposablesAreLeakedInTestSuite();
23
24
const testFile1 = URI.file('/project/.claude/CLAUDE.md');
25
const testFile2 = URI.file('/project/.claude/settings.json');
26
27
beforeEach(() => {
28
mockFs = new MockFileSystemService();
29
testingServiceCollection = store.add(createExtensionUnitTestingServices(store));
30
testingServiceCollection.set(IFileSystemService, mockFs);
31
32
const accessor = testingServiceCollection.createTestingAccessor();
33
const instaService = accessor.get(IInstantiationService);
34
tracker = instaService.createInstance(ClaudeSettingsChangeTracker);
35
});
36
37
describe('takeSnapshot', () => {
38
it('should capture mtime of existing files', async () => {
39
mockFs.mockFile(testFile1, '# Instructions', 1000);
40
41
tracker.registerPathResolver(() => [testFile1]);
42
await tracker.takeSnapshot();
43
44
// No changes immediately after snapshot
45
const hasChanges = await tracker.hasChanges();
46
expect(hasChanges).toBe(false);
47
});
48
49
it('should record non-existent files as 0 mtime', async () => {
50
// testFile1 is not mocked, so stat will throw
51
tracker.registerPathResolver(() => [testFile1]);
52
await tracker.takeSnapshot();
53
54
// No changes immediately after snapshot
55
const hasChanges = await tracker.hasChanges();
56
expect(hasChanges).toBe(false);
57
});
58
});
59
60
describe('hasChanges', () => {
61
it('should return false when files have not changed', async () => {
62
mockFs.mockFile(testFile1, '# Instructions', 1000);
63
64
tracker.registerPathResolver(() => [testFile1]);
65
await tracker.takeSnapshot();
66
67
const hasChanges = await tracker.hasChanges();
68
expect(hasChanges).toBe(false);
69
});
70
71
it('should return true when file mtime increases', async () => {
72
mockFs.mockFile(testFile1, '# Instructions', 1000);
73
74
tracker.registerPathResolver(() => [testFile1]);
75
await tracker.takeSnapshot();
76
77
// Simulate file modification by updating mtime
78
mockFs.mockFile(testFile1, '# Updated Instructions', 2000);
79
80
const hasChanges = await tracker.hasChanges();
81
expect(hasChanges).toBe(true);
82
});
83
84
it('should return true when a new file is created', async () => {
85
// File doesn't exist at snapshot time
86
tracker.registerPathResolver(() => [testFile1]);
87
await tracker.takeSnapshot();
88
89
// File is created
90
mockFs.mockFile(testFile1, '# New Instructions', 1000);
91
92
const hasChanges = await tracker.hasChanges();
93
expect(hasChanges).toBe(true);
94
});
95
96
it('should return true when a file is deleted', async () => {
97
mockFs.mockFile(testFile1, '# Instructions', 1000);
98
99
tracker.registerPathResolver(() => [testFile1]);
100
await tracker.takeSnapshot();
101
102
// Simulate file deletion by mocking an error
103
mockFs.mockError(testFile1, new Error('ENOENT'));
104
105
const hasChanges = await tracker.hasChanges();
106
expect(hasChanges).toBe(true);
107
});
108
109
it('should track multiple files from single resolver', async () => {
110
mockFs.mockFile(testFile1, '# Instructions', 1000);
111
mockFs.mockFile(testFile2, '{}', 1000);
112
113
tracker.registerPathResolver(() => [testFile1, testFile2]);
114
await tracker.takeSnapshot();
115
116
// Modify only second file
117
mockFs.mockFile(testFile2, '{"hooks": []}', 2000);
118
119
const hasChanges = await tracker.hasChanges();
120
expect(hasChanges).toBe(true);
121
});
122
});
123
124
describe('multiple path resolvers', () => {
125
it('should track files from all registered resolvers', async () => {
126
mockFs.mockFile(testFile1, '# Instructions', 1000);
127
mockFs.mockFile(testFile2, '{}', 1000);
128
129
tracker.registerPathResolver(() => [testFile1]);
130
tracker.registerPathResolver(() => [testFile2]);
131
await tracker.takeSnapshot();
132
133
// Modify second file (from second resolver)
134
mockFs.mockFile(testFile2, '{"updated": true}', 2000);
135
136
const hasChanges = await tracker.hasChanges();
137
expect(hasChanges).toBe(true);
138
});
139
140
it('should detect new files added by resolver after snapshot', async () => {
141
const testFile3 = URI.file('/project/.claude/new-file.md');
142
const dynamicPaths: URI[] = [testFile1];
143
144
tracker.registerPathResolver(() => dynamicPaths);
145
await tracker.takeSnapshot();
146
147
// Add a new file to the resolver's list and create it
148
dynamicPaths.push(testFile3);
149
mockFs.mockFile(testFile3, '# New file', 1000);
150
151
const hasChanges = await tracker.hasChanges();
152
// testFile3 wasn't in the original snapshot, so it's a "new" file
153
expect(hasChanges).toBe(true);
154
});
155
});
156
157
describe('registerDirectoryResolver', () => {
158
const agentsDir = URI.file('/project/.claude/agents');
159
const agent1 = URI.file('/project/.claude/agents/test-runner.md');
160
const agent2 = URI.file('/project/.claude/agents/code-reviewer.md');
161
162
it('should track files in registered directories', async () => {
163
mockFs.mockDirectory(agentsDir, [
164
['test-runner.md', FileType.File],
165
['code-reviewer.md', FileType.File],
166
]);
167
mockFs.mockFile(agent1, '# Test Runner', 1000);
168
mockFs.mockFile(agent2, '# Code Reviewer', 1000);
169
170
tracker.registerDirectoryResolver(() => [agentsDir]);
171
await tracker.takeSnapshot();
172
173
const hasChanges = await tracker.hasChanges();
174
expect(hasChanges).toBe(false);
175
});
176
177
it('should detect modified files in directory', async () => {
178
mockFs.mockDirectory(agentsDir, [
179
['test-runner.md', FileType.File],
180
]);
181
mockFs.mockFile(agent1, '# Test Runner', 1000);
182
183
tracker.registerDirectoryResolver(() => [agentsDir]);
184
await tracker.takeSnapshot();
185
186
// Modify the file
187
mockFs.mockFile(agent1, '# Updated Test Runner', 2000);
188
189
const hasChanges = await tracker.hasChanges();
190
expect(hasChanges).toBe(true);
191
});
192
193
it('should detect new files added to directory', async () => {
194
mockFs.mockDirectory(agentsDir, [
195
['test-runner.md', FileType.File],
196
]);
197
mockFs.mockFile(agent1, '# Test Runner', 1000);
198
199
tracker.registerDirectoryResolver(() => [agentsDir]);
200
await tracker.takeSnapshot();
201
202
// Add a new file to the directory
203
mockFs.mockDirectory(agentsDir, [
204
['test-runner.md', FileType.File],
205
['code-reviewer.md', FileType.File],
206
]);
207
mockFs.mockFile(agent2, '# Code Reviewer', 1000);
208
209
const hasChanges = await tracker.hasChanges();
210
expect(hasChanges).toBe(true);
211
});
212
213
it('should detect deleted files from directory', async () => {
214
mockFs.mockDirectory(agentsDir, [
215
['test-runner.md', FileType.File],
216
['code-reviewer.md', FileType.File],
217
]);
218
mockFs.mockFile(agent1, '# Test Runner', 1000);
219
mockFs.mockFile(agent2, '# Code Reviewer', 1000);
220
221
tracker.registerDirectoryResolver(() => [agentsDir]);
222
await tracker.takeSnapshot();
223
224
// Remove agent2 from directory listing
225
mockFs.mockDirectory(agentsDir, [
226
['test-runner.md', FileType.File],
227
]);
228
229
const hasChanges = await tracker.hasChanges();
230
expect(hasChanges).toBe(true);
231
});
232
233
it('should handle non-existent directories gracefully', async () => {
234
// Don't mock the directory - it doesn't exist
235
tracker.registerDirectoryResolver(() => [agentsDir]);
236
await tracker.takeSnapshot();
237
238
const hasChanges = await tracker.hasChanges();
239
expect(hasChanges).toBe(false);
240
});
241
});
242
243
describe('extension filtering', () => {
244
const agentsDir = URI.file('/project/.claude/agents');
245
246
it('should only track files with matching extension', async () => {
247
mockFs.mockDirectory(agentsDir, [
248
['test-runner.md', FileType.File],
249
['readme.txt', FileType.File],
250
['config.json', FileType.File],
251
]);
252
mockFs.mockFile(URI.file('/project/.claude/agents/test-runner.md'), '# Test', 1000);
253
mockFs.mockFile(URI.file('/project/.claude/agents/readme.txt'), 'readme', 1000);
254
mockFs.mockFile(URI.file('/project/.claude/agents/config.json'), '{}', 1000);
255
256
tracker.registerDirectoryResolver(() => [agentsDir], '.md');
257
await tracker.takeSnapshot();
258
259
// Modify the txt file - should NOT trigger change since we only track .md
260
mockFs.mockFile(URI.file('/project/.claude/agents/readme.txt'), 'updated readme', 2000);
261
262
const hasChanges = await tracker.hasChanges();
263
expect(hasChanges).toBe(false);
264
});
265
266
it('should detect changes to files with matching extension', async () => {
267
mockFs.mockDirectory(agentsDir, [
268
['test-runner.md', FileType.File],
269
['readme.txt', FileType.File],
270
]);
271
mockFs.mockFile(URI.file('/project/.claude/agents/test-runner.md'), '# Test', 1000);
272
mockFs.mockFile(URI.file('/project/.claude/agents/readme.txt'), 'readme', 1000);
273
274
tracker.registerDirectoryResolver(() => [agentsDir], '.md');
275
await tracker.takeSnapshot();
276
277
// Modify the .md file - should trigger change
278
mockFs.mockFile(URI.file('/project/.claude/agents/test-runner.md'), '# Updated', 2000);
279
280
const hasChanges = await tracker.hasChanges();
281
expect(hasChanges).toBe(true);
282
});
283
});
284
285
describe('lazy evaluation', () => {
286
it('should stop checking after first change is found', async () => {
287
mockFs.mockFile(testFile1, '# Instructions', 1000);
288
mockFs.mockFile(testFile2, '{}', 1000);
289
290
// Register two resolvers
291
tracker.registerPathResolver(() => [testFile1]);
292
tracker.registerPathResolver(() => [testFile2]);
293
await tracker.takeSnapshot();
294
295
// Modify first file
296
mockFs.mockFile(testFile1, '# Updated', 2000);
297
298
mockFs.resetStatCallCount();
299
const hasChanges = await tracker.hasChanges();
300
301
expect(hasChanges).toBe(true);
302
// Should only have called stat once (for testFile1) before returning
303
expect(mockFs.getStatCallCount()).toBe(1);
304
});
305
306
it('should not invoke later resolvers if early change found', async () => {
307
mockFs.mockFile(testFile1, '# Instructions', 1000);
308
mockFs.mockFile(testFile2, '{}', 1000);
309
310
let resolver2Called = false;
311
tracker.registerPathResolver(() => [testFile1]);
312
tracker.registerPathResolver(() => {
313
resolver2Called = true;
314
return [testFile2];
315
});
316
await tracker.takeSnapshot();
317
318
// Modify first file
319
mockFs.mockFile(testFile1, '# Updated', 2000);
320
321
resolver2Called = false;
322
await tracker.hasChanges();
323
324
// Second resolver should not have been called
325
expect(resolver2Called).toBe(false);
326
});
327
328
it('should check all resolvers when no changes found', async () => {
329
mockFs.mockFile(testFile1, '# Instructions', 1000);
330
mockFs.mockFile(testFile2, '{}', 1000);
331
332
let resolver2Called = false;
333
tracker.registerPathResolver(() => [testFile1]);
334
tracker.registerPathResolver(() => {
335
resolver2Called = true;
336
return [testFile2];
337
});
338
await tracker.takeSnapshot();
339
340
// No modifications
341
resolver2Called = false;
342
await tracker.hasChanges();
343
344
// Both resolvers should have been called
345
expect(resolver2Called).toBe(true);
346
});
347
});
348
});
349
350