Path: blob/main/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts
13399 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 assert from 'assert';6import { VSBuffer } from '../../../../base/common/buffer.js';7import { DisposableStore } from '../../../../base/common/lifecycle.js';8import { URI } from '../../../../base/common/uri.js';9import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';10import { FileService } from '../../../files/common/fileService.js';11import { IFileService } from '../../../files/common/files.js';12import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';13import { ILogService, NullLogService } from '../../../log/common/log.js';14import { IInstantiationService } from '../../../instantiation/common/instantiation.js';15import { InstantiationService } from '../../../instantiation/common/instantiationService.js';16import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js';17import { IDiffComputeService } from '../../common/diffComputeService.js';18import { ToolResultContentType } from '../../common/state/sessionState.js';19import { createZeroDiffComputeService } from '../common/sessionTestHelpers.js';20import { SessionDatabase } from '../../node/sessionDatabase.js';21import { FileEditTracker, buildSessionDbUri, parseSessionDbUri } from '../../node/copilot/fileEditTracker.js';2223suite('FileEditTracker', () => {2425const disposables = new DisposableStore();26let fileService: FileService;27let db: SessionDatabase;28let tracker: FileEditTracker;2930setup(async () => {31fileService = disposables.add(new FileService(new NullLogService()));32const sourceFs = disposables.add(new InMemoryFileSystemProvider());33disposables.add(fileService.registerProvider('file', sourceFs));3435db = disposables.add(await SessionDatabase.open(':memory:'));36await db.createTurn('turn-1');3738const services = new ServiceCollection();39services.set(ILogService, new NullLogService());40services.set(IFileService, fileService);41services.set(IDiffComputeService, createZeroDiffComputeService());42const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services));43tracker = instantiationService.createInstance(FileEditTracker, 'copilot:/test-session', db);44});4546teardown(async () => {47disposables.clear();48await db.close();49});50ensureNoDisposablesAreLeakedInTestSuite();5152test('tracks edit start and complete for existing file', async () => {53await fileService.writeFile(URI.file('/workspace/test.txt'), VSBuffer.fromString('original content\nline 2'));5455await tracker.trackEditStart('/workspace/test.txt');56await fileService.writeFile(URI.file('/workspace/test.txt'), VSBuffer.fromString('modified content\nline 2\nline 3'));57await tracker.completeEdit('/workspace/test.txt');5859const fileEdit = await tracker.takeCompletedEdit('turn-1', 'tc-1', '/workspace/test.txt');60assert.ok(fileEdit);61assert.strictEqual(fileEdit.type, ToolResultContentType.FileEdit);6263// URIs are parseable session-db: URIs64const beforeFields = parseSessionDbUri(fileEdit.before!.content.uri);65assert.ok(beforeFields);66assert.strictEqual(beforeFields.sessionUri, 'copilot:/test-session');67assert.strictEqual(beforeFields.toolCallId, 'tc-1');68assert.strictEqual(beforeFields.filePath, '/workspace/test.txt');69assert.strictEqual(beforeFields.part, 'before');7071const afterFields = parseSessionDbUri(fileEdit.after!.content.uri);72assert.ok(afterFields);73assert.strictEqual(afterFields.part, 'after');7475// Content is persisted in the database (wait for fire-and-forget write)76await new Promise(r => setTimeout(r, 50));7778const content = await db.readFileEditContent('tc-1', '/workspace/test.txt');79assert.ok(content);80assert.strictEqual(new TextDecoder().decode(content.beforeContent), 'original content\nline 2');81assert.strictEqual(new TextDecoder().decode(content.afterContent), 'modified content\nline 2\nline 3');82});8384test('tracks edit for newly created file (no before content)', async () => {85await tracker.trackEditStart('/workspace/new-file.txt');86await fileService.writeFile(URI.file('/workspace/new-file.txt'), VSBuffer.fromString('new file\ncontent'));87await tracker.completeEdit('/workspace/new-file.txt');8889const fileEdit = await tracker.takeCompletedEdit('turn-1', 'tc-2', '/workspace/new-file.txt');90assert.ok(fileEdit);9192// Wait for the fire-and-forget DB write to complete93await new Promise(r => setTimeout(r, 50));9495const content = await db.readFileEditContent('tc-2', '/workspace/new-file.txt');96assert.ok(content);97assert.strictEqual(new TextDecoder().decode(content.beforeContent), '');98assert.strictEqual(new TextDecoder().decode(content.afterContent), 'new file\ncontent');99});100101test('takeCompletedEdit returns undefined for unknown file path', async () => {102const result = await tracker.takeCompletedEdit('turn-1', 'tc-x', '/nonexistent');103assert.strictEqual(result, undefined);104});105106test('before and after content can be read from database', async () => {107await fileService.writeFile(URI.file('/workspace/file.ts'), VSBuffer.fromString('original'));108109await tracker.trackEditStart('/workspace/file.ts');110await fileService.writeFile(URI.file('/workspace/file.ts'), VSBuffer.fromString('modified'));111await tracker.completeEdit('/workspace/file.ts');112113await tracker.takeCompletedEdit('turn-1', 'tc-3', '/workspace/file.ts');114115// Wait for the fire-and-forget DB write to complete116await new Promise(r => setTimeout(r, 50));117118const content = await db.readFileEditContent('tc-3', '/workspace/file.ts');119assert.ok(content);120assert.strictEqual(new TextDecoder().decode(content.beforeContent), 'original');121assert.strictEqual(new TextDecoder().decode(content.afterContent), 'modified');122});123});124125suite('buildSessionDbUri / parseSessionDbUri', () => {126127ensureNoDisposablesAreLeakedInTestSuite();128129test('round-trips a simple URI', () => {130const uri = buildSessionDbUri('copilot:/abc-123', 'tc-1', '/workspace/file.ts', 'before');131const parsed = parseSessionDbUri(uri);132assert.ok(parsed);133assert.deepStrictEqual(parsed, {134sessionUri: 'copilot:/abc-123',135toolCallId: 'tc-1',136filePath: '/workspace/file.ts',137part: 'before',138});139});140141test('round-trips with special characters in filePath', () => {142const uri = buildSessionDbUri('copilot:/s1', 'tc-2', '/work space/file (1).ts', 'after');143const parsed = parseSessionDbUri(uri);144assert.ok(parsed);145assert.strictEqual(parsed.filePath, '/work space/file (1).ts');146assert.strictEqual(parsed.part, 'after');147});148149test('round-trips with special characters in toolCallId', () => {150const uri = buildSessionDbUri('copilot:/s1', 'call_abc=123&x', '/file.ts', 'before');151const parsed = parseSessionDbUri(uri);152assert.ok(parsed);153assert.strictEqual(parsed.toolCallId, 'call_abc=123&x');154});155156test('parseSessionDbUri returns undefined for non-session-db URIs', () => {157assert.strictEqual(parseSessionDbUri('file:///foo/bar'), undefined);158assert.strictEqual(parseSessionDbUri('https://example.com'), undefined);159});160161test('parseSessionDbUri returns undefined for malformed session-db URIs', () => {162assert.strictEqual(parseSessionDbUri('session-db:copilot:/s1'), undefined);163assert.strictEqual(parseSessionDbUri('session-db:copilot:/s1?toolCallId=tc-1'), undefined);164assert.strictEqual(parseSessionDbUri('session-db:copilot:/s1?toolCallId=tc-1&filePath=/f&part=middle'), undefined);165});166167test('URI path ends with the basename of the file', () => {168const uri = buildSessionDbUri('copilot:/s1', 'tc-1', '/workspace/src/index.ts', 'before');169const parsed = URI.parse(uri);170assert.ok(parsed.path.endsWith('/index.ts'));171});172173test('URI path ends with basename for files with spaces and special chars', () => {174const uri = buildSessionDbUri('copilot:/s1', 'tc-1', '/work space/file (1).ts', 'after');175const parsed = URI.parse(uri);176assert.ok(parsed.path.endsWith('/file (1).ts'));177});178});179180181