Files
vscode/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts
T

379 lines
16 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';
import type { DisposableStore } from '../../../../base/common/lifecycle.js';
import { URI } from '../../../../base/common/uri.js';
import { mock } from '../../../../base/test/common/mock.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { FileSystemProviderCapabilities, IFileService } from '../../../files/common/files.js';
import { NullLogService } from '../../../log/common/log.js';
import { buildSessionChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js';
import { ChangesetOperationTargetKind, type InvokeChangesetOperationParams } from '../../common/state/protocol/channels-changeset/commands.js';
import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js';
import { buildChatUri, buildDefaultChatUri, SessionStatus, type ISessionFileDiff } from '../../common/state/sessionState.js';
import { AgentHostDiscardChangesOperationHandler } from '../../node/agentHostDiscardChangesOperationHandler.js';
import type { IAgentHostGitService, IBranch, IDefaultBranch } from '../../common/agentHostGitService.js';
import { AgentHostStateManager } from '../../node/agentHostStateManager.js';
class TestGitService implements IAgentHostGitService {
declare readonly _serviceBrand: undefined;
readonly restoreCalls: { workingDirectory: string; paths: readonly string[]; options?: { readonly staged?: boolean; readonly ref?: string } }[] = [];
restoreError: Error | undefined;
repositoryRoot = URI.file('/repo');
untrackedPaths: readonly string[] = [];
async getCurrentBranch(): Promise<string | undefined> { return undefined; }
async getDefaultBranch(): Promise<IDefaultBranch | undefined> { return undefined; }
async getBranch(): Promise<IBranch | undefined> { return undefined; }
async getRefs(): Promise<IBranch[]> { return []; }
async getBranches(): Promise<IBranch[]> { return []; }
async getRepositoryRoot(): Promise<URI | undefined> { return this.repositoryRoot; }
async getWorktreeRoots(): Promise<URI[]> { return []; }
async addWorktree(): Promise<void> { }
async copyWorktreeIncludeFiles(): Promise<void> { }
async symlinkWorktreeFolders(): Promise<readonly string[]> { return []; }
async addExistingWorktree(): Promise<void> { }
async removeWorktree(): Promise<void> { }
async branchExists(): Promise<boolean> { return false; }
async createBranch(): Promise<void> { }
async checkout(): Promise<void> { }
async hasUncommittedChanges(): Promise<boolean> { return true; }
async createStash(): Promise<void> { }
async commitAll(): Promise<void> { }
async mergeBranch(): Promise<string> { return ''; }
async restore(workingDirectory: URI, paths: readonly string[], options?: { readonly staged?: boolean; readonly ref?: string }): Promise<void> {
this.restoreCalls.push({ workingDirectory: workingDirectory.toString(), paths, options });
if (this.restoreError) {
throw this.restoreError;
}
}
async hasUpstream(): Promise<boolean> { return false; }
async fetch(): Promise<void> { }
async pull(): Promise<void> { }
async push(): Promise<void> { }
async getSessionGitState(): Promise<undefined> { return undefined; }
async computeSessionFileDiffs(): Promise<readonly ISessionFileDiff[] | undefined> { return undefined; }
async showBlob(): Promise<undefined> { return undefined; }
async captureWorkingTreeAsTree(): Promise<undefined> { return undefined; }
async commitTree(): Promise<undefined> { return undefined; }
async updateRef(): Promise<void> { }
async deleteRefs(): Promise<void> { }
async revParse(): Promise<string | undefined> { return undefined; }
async resolveBranchBaselineCommit(): Promise<string | undefined> { return undefined; }
async overlayPathIntoTree(): Promise<string | undefined> { return undefined; }
async diffTreePaths(): Promise<string[] | undefined> { return undefined; }
async computeFileDiffsBetweenRefs(): Promise<readonly ISessionFileDiff[] | undefined> { return undefined; }
async getFetchRemoteUrls(): Promise<undefined> { return undefined; }
async getUntrackedPaths(): Promise<readonly string[]> { return this.untrackedPaths; }
async getBranchDiffSafetyInfo(): Promise<undefined> { return undefined; }
async getDiffPatchBetweenRefs(): Promise<undefined> { return undefined; }
}
class TestFileService extends mock<IFileService>() {
readonly deleteCalls: { resource: string; useTrash: boolean }[] = [];
trashSupported = true;
override hasCapability(_resource: URI, capability: FileSystemProviderCapabilities): boolean {
return capability === FileSystemProviderCapabilities.Trash && this.trashSupported;
}
override async del(resource: URI, options?: Parameters<IFileService['del']>[1]): Promise<void> {
this.deleteCalls.push({ resource: resource.toString(), useTrash: options?.useTrash ?? false });
}
}
function setup(disposables: Pick<DisposableStore, 'add'>, opts?: { readonly withWorkingDirectory?: boolean; readonly workingDirectory?: URI; readonly registerSession?: boolean }): { handler: AgentHostDiscardChangesOperationHandler; gitService: TestGitService; fileService: TestFileService; stateManager: AgentHostStateManager; refreshes: string[]; session: URI } {
const gitService = new TestGitService();
const fileService = new TestFileService();
const refreshes: string[] = [];
const stateManager = disposables.add(new AgentHostStateManager(new NullLogService()));
const session = URI.parse('agent:/session');
if (opts?.registerSession !== false) {
stateManager.createSession({
resource: session.toString(),
provider: 'copilot',
title: 'Session',
status: SessionStatus.Idle,
createdAt: new Date(1).toISOString(),
modifiedAt: new Date(1).toISOString(),
workingDirectories: opts?.withWorkingDirectory === false ? undefined : [(opts?.workingDirectory ?? URI.file('/repo')).toString()],
});
}
const handler = new AgentHostDiscardChangesOperationHandler(
sessionKey => stateManager.getSessionState(sessionKey),
async sessionKey => { refreshes.push(sessionKey); },
gitService,
fileService,
new NullLogService(),
stateManager,
);
return { handler, gitService, fileService, stateManager, refreshes, session };
}
function makeResourceTarget(resource: URI): InvokeChangesetOperationParams['target'] {
// `ChangesetOperationTarget.resource` is typed as the protocol `URI` (a
// branded string), not the workbench `URI` class.
return { kind: ChangesetOperationTargetKind.Resource, resource: resource.toString() as unknown as InvokeChangesetOperationParams['channel'] };
}
suite('AgentHostDiscardChangesOperationHandler', () => {
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
test('restores the targeted file on success', async () => {
const { handler, gitService, fileService, refreshes, session } = setup(disposables);
const target = URI.file('/repo/src/file.ts');
const result = await handler.invoke({
channel: buildUncommittedChangesetUri(buildDefaultChatUri(session.toString())),
operationId: AgentHostDiscardChangesOperationHandler.OPERATION_DISCARD_CHANGES,
target: makeResourceTarget(target),
}, CancellationToken.None);
assert.deepStrictEqual({
restoreCalls: gitService.restoreCalls,
deleteCalls: fileService.deleteCalls,
refreshes,
message: result.message,
}, {
restoreCalls: [{ workingDirectory: URI.file('/repo').toString(), paths: [target.fsPath], options: undefined }],
deleteCalls: [],
refreshes: [buildDefaultChatUri(session.toString())],
message: { markdown: 'Discarded changes to `file.ts`.' },
});
});
test('restores and refreshes a peer chat in its working directory', async () => {
const { handler, gitService, fileService, stateManager, refreshes, session } = setup(disposables);
const peer = buildChatUri(session.toString(), 'peer');
const peerRoot = URI.file('/peer-repo');
stateManager.addChat(session.toString(), peer, { workingDirectories: [peerRoot.toString()] });
const target = URI.file('/peer-repo/src/file.ts');
await handler.invoke({
channel: buildUncommittedChangesetUri(peer),
operationId: AgentHostDiscardChangesOperationHandler.OPERATION_DISCARD_CHANGES,
target: makeResourceTarget(target),
}, CancellationToken.None);
assert.deepStrictEqual({
restoreCalls: gitService.restoreCalls,
deleteCalls: fileService.deleteCalls,
refreshes,
}, {
restoreCalls: [{ workingDirectory: peerRoot.toString(), paths: [target.fsPath], options: undefined }],
deleteCalls: [],
refreshes: [peer],
});
});
test('deletes an untracked file using trash when supported', async () => {
const { handler, gitService, fileService, session } = setup(disposables);
gitService.untrackedPaths = ['src/new.ts'];
const target = URI.file('/repo/src/new.ts');
const result = await handler.invoke({
channel: buildUncommittedChangesetUri(session.toString()),
operationId: AgentHostDiscardChangesOperationHandler.OPERATION_DISCARD_CHANGES,
target: makeResourceTarget(target),
}, CancellationToken.None);
assert.deepStrictEqual({
restoreCalls: gitService.restoreCalls,
deleteCalls: fileService.deleteCalls,
message: result.message,
}, {
restoreCalls: [],
deleteCalls: [{ resource: target.toString(), useTrash: true }],
message: { markdown: 'Discarded changes to `new.ts`.' },
});
});
test('deletes an untracked file when Git canonicalizes the repository root', async () => {
const workingDirectory = URI.file('/var/folders/repo');
const { handler, gitService, fileService, session } = setup(disposables, { workingDirectory });
gitService.repositoryRoot = URI.file('/private/var/folders/repo');
gitService.untrackedPaths = ['src/new.ts'];
const target = URI.file('/private/var/folders/repo/src/new.ts');
await handler.invoke({
channel: buildUncommittedChangesetUri(session.toString()),
operationId: AgentHostDiscardChangesOperationHandler.OPERATION_DISCARD_CHANGES,
target: makeResourceTarget(target),
}, CancellationToken.None);
assert.deepStrictEqual({
restoreCalls: gitService.restoreCalls,
deleteCalls: fileService.deleteCalls,
}, {
restoreCalls: [],
deleteCalls: [{ resource: target.toString(), useTrash: true }],
});
});
test('deletes an untracked file without trash when unsupported', async () => {
const { handler, gitService, fileService, session } = setup(disposables);
gitService.untrackedPaths = ['src/new.ts'];
fileService.trashSupported = false;
const target = URI.file('/repo/src/new.ts');
await handler.invoke({
channel: buildUncommittedChangesetUri(session.toString()),
operationId: AgentHostDiscardChangesOperationHandler.OPERATION_DISCARD_CHANGES,
target: makeResourceTarget(target),
}, CancellationToken.None);
assert.deepStrictEqual({
restoreCalls: gitService.restoreCalls,
deleteCalls: fileService.deleteCalls,
}, {
restoreCalls: [],
deleteCalls: [{ resource: target.toString(), useTrash: false }],
});
});
test('rejects channels that are not uncommitted-changeset URIs', async () => {
const { handler, gitService, session } = setup(disposables);
const target = URI.file('/repo/src/file.ts');
let err: ProtocolError | undefined;
try {
await handler.invoke({
channel: buildSessionChangesetUri(session.toString()),
operationId: AgentHostDiscardChangesOperationHandler.OPERATION_DISCARD_CHANGES,
target: makeResourceTarget(target),
}, CancellationToken.None);
} catch (error) {
err = error as ProtocolError;
}
assert.deepStrictEqual({
code: err?.code,
restoreCalls: gitService.restoreCalls.length,
}, {
code: JsonRpcErrorCodes.InvalidParams,
restoreCalls: 0,
});
});
test('throws AHP_SESSION_NOT_FOUND when the session is unknown', async () => {
const { handler, gitService } = setup(disposables, { registerSession: false });
const session = URI.parse('agent:/missing');
const target = URI.file('/repo/src/file.ts');
let err: ProtocolError | undefined;
try {
await handler.invoke({
channel: buildUncommittedChangesetUri(session.toString()),
operationId: AgentHostDiscardChangesOperationHandler.OPERATION_DISCARD_CHANGES,
target: makeResourceTarget(target),
}, CancellationToken.None);
} catch (error) {
err = error as ProtocolError;
}
assert.deepStrictEqual({
code: err?.code,
restoreCalls: gitService.restoreCalls.length,
}, {
code: AHP_SESSION_NOT_FOUND,
restoreCalls: 0,
});
});
test('rejects invocations without a Resource target', async () => {
const { handler, gitService, session } = setup(disposables);
let err: ProtocolError | undefined;
try {
await handler.invoke({
channel: buildUncommittedChangesetUri(session.toString()),
operationId: AgentHostDiscardChangesOperationHandler.OPERATION_DISCARD_CHANGES,
}, CancellationToken.None);
} catch (error) {
err = error as ProtocolError;
}
assert.deepStrictEqual({
code: err?.code,
restoreCalls: gitService.restoreCalls.length,
}, {
code: JsonRpcErrorCodes.InvalidParams,
restoreCalls: 0,
});
});
test('throws InternalError when the session has no working directory', async () => {
const { handler, gitService, session } = setup(disposables, { withWorkingDirectory: false });
const target = URI.file('/repo/src/file.ts');
let err: ProtocolError | undefined;
try {
await handler.invoke({
channel: buildUncommittedChangesetUri(session.toString()),
operationId: AgentHostDiscardChangesOperationHandler.OPERATION_DISCARD_CHANGES,
target: makeResourceTarget(target),
}, CancellationToken.None);
} catch (error) {
err = error as ProtocolError;
}
assert.deepStrictEqual({
code: err?.code,
restoreCalls: gitService.restoreCalls.length,
}, {
code: JsonRpcErrorCodes.InternalError,
restoreCalls: 0,
});
});
test('wraps git restore failures in a ProtocolError without recomputing the changeset', async () => {
const { handler, gitService, session } = setup(disposables);
gitService.restoreError = new Error('git restore failed');
const target = URI.file('/repo/src/file.ts');
let err: ProtocolError | undefined;
try {
await handler.invoke({
channel: buildUncommittedChangesetUri(session.toString()),
operationId: AgentHostDiscardChangesOperationHandler.OPERATION_DISCARD_CHANGES,
target: makeResourceTarget(target),
}, CancellationToken.None);
} catch (error) {
err = error as ProtocolError;
}
assert.deepStrictEqual({
code: err?.code,
messageContainsCause: err?.message.includes('git restore failed'),
restoreCalls: gitService.restoreCalls.length,
}, {
code: JsonRpcErrorCodes.InternalError,
messageContainsCause: true,
restoreCalls: 1,
});
});
test('honors cancellation before mutating the repository', async () => {
const { handler, gitService, session } = setup(disposables);
const cts = disposables.add(new CancellationTokenSource());
cts.cancel();
const target = URI.file('/repo/src/file.ts');
await assert.rejects(
() => handler.invoke({
channel: buildUncommittedChangesetUri(session.toString()),
operationId: AgentHostDiscardChangesOperationHandler.OPERATION_DISCARD_CHANGES,
target: makeResourceTarget(target),
}, cts.token),
/cancelled/i,
);
assert.deepStrictEqual({ restoreCalls: gitService.restoreCalls.length }, { restoreCalls: 0 });
});
});