Path: blob/main/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts
13405 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 { Codicon } from '../../../../../base/common/codicons.js';7import { URI } from '../../../../../base/common/uri.js';8import { Range } from '../../../../../editor/common/core/range.js';9import { IObservable, observableValue } from '../../../../../base/common/observable.js';10import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';11import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';12import { DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';13import { ICommandService } from '../../../../../platform/commands/common/commands.js';14import { Emitter, Event } from '../../../../../base/common/event.js';15import { mock } from '../../../../../base/test/common/mock.js';16import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js';17import { InMemoryStorageService, IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';18import { IChatSessionFileChange, IChatSessionFileChange2 } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js';19import { IGitHubService } from '../../../github/browser/githubService.js';20import { GitHubPRFetcher } from '../../../github/browser/fetchers/githubPRFetcher.js';21import { GitHubPullRequestModel } from '../../../github/browser/models/githubPullRequestModel.js';22import { GitHubPullRequestReviewThreadsModel } from '../../../github/browser/models/githubPullRequestReviewThreadsModel.js';23import { IGitHubPRComment, IGitHubPullRequestReviewThread } from '../../../github/common/types.js';24import { IGitHubInfo, ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js';25import { ICodeReviewService, CodeReviewService, CodeReviewStateKind, PRReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion } from '../../browser/codeReviewService.js';26import { IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js';2728suite('CodeReviewService', () => {2930const store = new DisposableStore();31let instantiationService: TestInstantiationService;32let service: ICodeReviewService;33let commandService: MockCommandService;34let gitHubService: MockGitHubService;35let storageService: InMemoryStorageService;36let sessionsManagement: MockSessionsManagementService;3738let session: URI;39let fileA: URI;40let fileB: URI;4142class MockCommandService implements ICommandService {43declare readonly _serviceBrand: undefined;44readonly onWillExecuteCommand = Event.None;45readonly onDidExecuteCommand = Event.None;4647result: unknown = undefined;48lastCommandId: string | undefined;49lastArgs: unknown[] | undefined;50executeDeferred: { resolve: (v: unknown) => void; reject: (e: unknown) => void } | undefined;5152async executeCommand<T>(commandId: string, ...args: unknown[]): Promise<T> {53this.lastCommandId = commandId;54this.lastArgs = args;5556if (this.executeDeferred) {57return await new Promise<T>((resolve, reject) => {58this.executeDeferred = { resolve: resolve as (v: unknown) => void, reject };59});60}61return this.result as T;62}6364/**65* Configure the mock to defer execution until manually resolved/rejected.66*/67deferNextExecution(): void {68this.executeDeferred = undefined;69const self = this;70const originalResult = this.result;7172// Override executeCommand for next call to capture the deferred promise73const origExecute = this.executeCommand.bind(this);74this.executeCommand = async function <T>(commandId: string, ...args: unknown[]): Promise<T> {75self.lastCommandId = commandId;76self.lastArgs = args;7778return new Promise<T>((resolve, reject) => {79self.executeDeferred = { resolve: resolve as (v: unknown) => void, reject };80});81} as typeof origExecute;8283// Restore after use84this._restoreExecute = () => {85this.executeCommand = origExecute;86this.result = originalResult;87};88}8990private _restoreExecute: (() => void) | undefined;9192resolveExecution(value: unknown): void {93this.executeDeferred?.resolve(value);94this.executeDeferred = undefined;95this._restoreExecute?.();96}9798rejectExecution(error: unknown): void {99this.executeDeferred?.reject(error);100this.executeDeferred = undefined;101this._restoreExecute?.();102}103}104105class MockSessionsManagementService extends mock<ISessionsManagementService>() {106private readonly _onDidChangeSessions: Emitter<ISessionsChangeEvent>;107private readonly _activeSession: ReturnType<typeof observableValue<IActiveSession | undefined>>;108override readonly onDidChangeSessions: Event<ISessionsChangeEvent>;109override readonly activeSession: IObservable<IActiveSession | undefined>;110111private readonly _sessions = new Map<string, ISession>();112113constructor(disposables: DisposableStore) {114super();115this._onDidChangeSessions = disposables.add(new Emitter<ISessionsChangeEvent>());116this.onDidChangeSessions = this._onDidChangeSessions.event;117this._activeSession = observableValue<IActiveSession | undefined>('test.activeSession', undefined);118this.activeSession = this._activeSession;119}120121override getSession(resource: URI): ISession | undefined {122return this._sessions.get(resource.toString());123}124125addSession(resource: URI, changes?: readonly IChatSessionFileChange2[], archived = false): ISession {126const changesObs = observableValue<readonly IChatSessionFileChange[]>('test.changes',127(changes ?? []).map(c => ({ modifiedUri: c.modifiedUri ?? c.uri, originalUri: c.originalUri, insertions: c.insertions, deletions: c.deletions }))128);129const isArchivedObs = observableValue<boolean>('test.isArchived', archived);130const gitHubInfoObs = observableValue<IGitHubInfo | undefined>('test.gitHubInfo', undefined);131const workspaceObs = observableValue<ISessionWorkspace | undefined>('test.workspace', {132label: 'workspace',133icon: Codicon.folder,134repositories: [{ uri: URI.file('/workspace'), workingDirectory: undefined, detail: undefined, baseBranchName: undefined }],135requiresWorkspaceTrust: false,136});137const sessionData: ISession = {138sessionId: `test:${resource.toString()}`,139resource,140workspace: workspaceObs,141changes: changesObs,142isArchived: isArchivedObs,143gitHubInfo: gitHubInfoObs,144} as unknown as ISession;145this._sessions.set(resource.toString(), sessionData);146return sessionData;147}148149setGitHubInfo(resource: URI, gitHubInfo: IGitHubInfo | undefined): void {150const session = this._sessions.get(resource.toString());151if (session) {152(session.gitHubInfo as ReturnType<typeof observableValue<IGitHubInfo | undefined>>).set(gitHubInfo, undefined);153}154}155156setActiveSession(resource: URI | undefined): void {157this._activeSession.set(resource ? this._sessions.get(resource.toString()) as IActiveSession | undefined : undefined, undefined);158}159160updateSessionChanges(resource: URI, changes: readonly IChatSessionFileChange2[] | undefined): void {161const session = this._sessions.get(resource.toString());162if (session) {163const obs = session.changes as ReturnType<typeof observableValue<readonly IChatSessionFileChange[]>>;164obs.set(165(changes ?? []).map(c => ({ modifiedUri: c.modifiedUri ?? c.uri, originalUri: c.originalUri, insertions: c.insertions, deletions: c.deletions })),166undefined167);168}169}170171removeSession(resource: URI): void {172this._sessions.delete(resource.toString());173}174175override getSessions(): ISession[] {176return [...this._sessions.values()];177}178179fireSessionsChanged(event?: Partial<ISessionsChangeEvent>): void {180this._onDidChangeSessions.fire({181added: event?.added ?? [],182removed: event?.removed ?? [],183changed: event?.changed ?? [],184});185}186}187188class MockReviewThreadsFetcher {189nextThreads: IGitHubPullRequestReviewThread[] = [];190getReviewThreadsCalls = 0;191resolveThreadCalls: { threadId: string }[] = [];192193async getReviewThreads(_owner: string, _repo: string, _prNumber: number): Promise<IGitHubPullRequestReviewThread[]> {194this.getReviewThreadsCalls++;195return this.nextThreads;196}197198async postReviewComment(_owner: string, _repo: string, _prNumber: number, body: string, inReplyTo: number): Promise<IGitHubPRComment> {199return makePRComment(inReplyTo, body);200}201202async resolveThread(_owner: string, _repo: string, threadId: string): Promise<void> {203this.resolveThreadCalls.push({ threadId });204}205}206207class MockGitHubPullRequestReviewThreadsModel extends GitHubPullRequestReviewThreadsModel {208startPollingCalls = 0;209stopPollingCalls = 0;210211override startPolling(intervalMs?: number): IDisposable {212this.startPollingCalls++;213const polling = super.startPolling(intervalMs);214return toDisposable(() => {215this.stopPollingCalls++;216polling.dispose();217});218}219}220221class MockGitHubService extends mock<IGitHubService>() {222readonly legacyFetcher = new MockReviewThreadsFetcher();223readonly reviewThreadsFetcher = new MockReviewThreadsFetcher();224225private readonly _pullRequestModel: GitHubPullRequestModel;226private readonly _reviewThreadsModels = new Map<string, MockGitHubPullRequestReviewThreadsModel>();227private readonly _reviewThreadsFetchers = new Map<string, MockReviewThreadsFetcher>();228229getPullRequestCalls = 0;230getPullRequestReviewThreadsCalls = 0;231232constructor(disposables: DisposableStore, logService: ILogService) {233super();234this._pullRequestModel = disposables.add(new GitHubPullRequestModel('owner', 'repo', 1, this.legacyFetcher as unknown as GitHubPRFetcher, logService));235this._reviewThreadsFetchers.set(this._key('owner', 'repo', 1), this.reviewThreadsFetcher);236}237238override getPullRequest(): GitHubPullRequestModel {239this.getPullRequestCalls++;240return this._pullRequestModel;241}242243override getPullRequestReviewThreads(owner: string, repo: string, prNumber: number): GitHubPullRequestReviewThreadsModel {244this.getPullRequestReviewThreadsCalls++;245return this.getReviewThreadsModel(owner, repo, prNumber);246}247248getReviewThreadsFetcher(owner: string, repo: string, prNumber: number): MockReviewThreadsFetcher {249const key = this._key(owner, repo, prNumber);250let fetcher = this._reviewThreadsFetchers.get(key);251if (!fetcher) {252fetcher = new MockReviewThreadsFetcher();253this._reviewThreadsFetchers.set(key, fetcher);254}255return fetcher;256}257258getReviewThreadsModel(owner: string, repo: string, prNumber: number): MockGitHubPullRequestReviewThreadsModel {259const key = this._key(owner, repo, prNumber);260let model = this._reviewThreadsModels.get(key);261if (!model) {262model = store.add(new MockGitHubPullRequestReviewThreadsModel(owner, repo, prNumber, this.getReviewThreadsFetcher(owner, repo, prNumber) as unknown as GitHubPRFetcher, new NullLogService()));263this._reviewThreadsModels.set(key, model);264}265return model;266}267268private _key(owner: string, repo: string, prNumber: number): string {269return `${owner}/${repo}#${prNumber}`;270}271}272273setup(() => {274instantiationService = store.add(new TestInstantiationService());275276commandService = new MockCommandService();277instantiationService.stub(ICommandService, commandService);278const logService = new NullLogService();279instantiationService.stub(ILogService, logService);280gitHubService = new MockGitHubService(store, logService);281instantiationService.stub(IGitHubService, gitHubService);282283sessionsManagement = new MockSessionsManagementService(store);284instantiationService.stub(ISessionsManagementService, sessionsManagement);285286storageService = store.add(new InMemoryStorageService());287instantiationService.stub(IStorageService, storageService);288289service = store.add(instantiationService.createInstance(CodeReviewService));290session = URI.parse('test://session/1');291fileA = URI.parse('file:///a.ts');292fileB = URI.parse('file:///b.ts');293});294295teardown(() => {296store.clear();297});298299ensureNoDisposablesAreLeakedInTestSuite();300301// --- getReviewState ---302303test('initial state is idle', () => {304const state = service.getReviewState(session).get();305assert.strictEqual(state.kind, CodeReviewStateKind.Idle);306});307308test('getReviewState returns the same observable for the same session', () => {309const obs1 = service.getReviewState(session);310const obs2 = service.getReviewState(session);311assert.strictEqual(obs1, obs2);312});313314test('getReviewState returns different observables for different sessions', () => {315const session2 = URI.parse('test://session/2');316const obs1 = service.getReviewState(session);317const obs2 = service.getReviewState(session2);318assert.notStrictEqual(obs1, obs2);319});320321test('PR review state uses dedicated review threads model', async () => {322sessionsManagement.addSession(session);323sessionsManagement.setGitHubInfo(session, makeGitHubInfo());324gitHubService.reviewThreadsFetcher.nextThreads = [makePRThread('thread-100', 'src/a.ts')];325326sessionsManagement.setActiveSession(session);327await tick();328await tick();329330const state = service.getPRReviewState(session).get();331assert.strictEqual(state.kind, PRReviewStateKind.Loaded);332if (state.kind === PRReviewStateKind.Loaded) {333assert.deepStrictEqual({334comments: state.comments.map(comment => ({ id: comment.id, uri: comment.uri.toString(), body: comment.body, author: comment.author })),335getPullRequestCalls: gitHubService.getPullRequestCalls,336getPullRequestReviewThreadsCalls: gitHubService.getPullRequestReviewThreadsCalls,337legacyThreadRefreshes: gitHubService.legacyFetcher.getReviewThreadsCalls,338reviewThreadRefreshes: gitHubService.reviewThreadsFetcher.getReviewThreadsCalls,339}, {340comments: [{ id: 'thread-100', uri: 'file:///workspace/src/a.ts', body: 'Comment on src/a.ts', author: 'reviewer' }],341getPullRequestCalls: 0,342getPullRequestReviewThreadsCalls: 1,343legacyThreadRefreshes: 0,344reviewThreadRefreshes: 1,345});346}347});348349test('only active session PR review model is polled', async () => {350const session2 = URI.parse('test://session/2');351sessionsManagement.addSession(session);352sessionsManagement.setGitHubInfo(session, makeGitHubInfo(1));353sessionsManagement.addSession(session2);354sessionsManagement.setGitHubInfo(session2, makeGitHubInfo(2));355gitHubService.getReviewThreadsFetcher('owner', 'repo', 1).nextThreads = [makePRThread('thread-100', 'src/a.ts')];356gitHubService.getReviewThreadsFetcher('owner', 'repo', 2).nextThreads = [makePRThread('thread-200', 'src/b.ts')];357358sessionsManagement.setActiveSession(session);359await tick();360await tick();361362const session1Model = gitHubService.getReviewThreadsModel('owner', 'repo', 1);363const session2Model = gitHubService.getReviewThreadsModel('owner', 'repo', 2);364assert.deepStrictEqual({365session1StartPollingCalls: session1Model.startPollingCalls,366session1StopPollingCalls: session1Model.stopPollingCalls,367session2StartPollingCalls: session2Model.startPollingCalls,368session2StopPollingCalls: session2Model.stopPollingCalls,369}, {370session1StartPollingCalls: 1,371session1StopPollingCalls: 0,372session2StartPollingCalls: 0,373session2StopPollingCalls: 0,374});375376sessionsManagement.setActiveSession(session2);377await tick();378await tick();379380assert.deepStrictEqual({381session1StartPollingCalls: session1Model.startPollingCalls,382session1StopPollingCalls: session1Model.stopPollingCalls,383session2StartPollingCalls: session2Model.startPollingCalls,384session2StopPollingCalls: session2Model.stopPollingCalls,385}, {386session1StartPollingCalls: 1,387session1StopPollingCalls: 1,388session2StartPollingCalls: 1,389session2StopPollingCalls: 0,390});391392sessionsManagement.setActiveSession(undefined);393await tick();394395assert.deepStrictEqual({396session1StartPollingCalls: session1Model.startPollingCalls,397session1StopPollingCalls: session1Model.stopPollingCalls,398session2StartPollingCalls: session2Model.startPollingCalls,399session2StopPollingCalls: session2Model.stopPollingCalls,400}, {401session1StartPollingCalls: 1,402session1StopPollingCalls: 1,403session2StartPollingCalls: 1,404session2StopPollingCalls: 1,405});406});407408test('resolvePRReviewThread uses dedicated review threads model', async () => {409sessionsManagement.addSession(session);410sessionsManagement.setGitHubInfo(session, makeGitHubInfo());411412await service.resolvePRReviewThread(session, 'thread-100');413414assert.deepStrictEqual({415getPullRequestCalls: gitHubService.getPullRequestCalls,416getPullRequestReviewThreadsCalls: gitHubService.getPullRequestReviewThreadsCalls,417legacyResolveThreadCalls: gitHubService.legacyFetcher.resolveThreadCalls,418reviewResolveThreadCalls: gitHubService.reviewThreadsFetcher.resolveThreadCalls,419}, {420getPullRequestCalls: 0,421getPullRequestReviewThreadsCalls: 1,422legacyResolveThreadCalls: [],423reviewResolveThreadCalls: [{ threadId: 'thread-100' }],424});425});426427// --- hasReview ---428429test('hasReview returns false when no review exists', () => {430assert.strictEqual(service.hasReview(session, 'v1'), false);431});432433test('hasReview returns false when review is for a different version', async () => {434commandService.result = { type: 'success', comments: [] };435service.requestReview(session, 'v1', [{ currentUri: fileA }]);436437// Wait for async command to complete438await tick();439440assert.strictEqual(service.hasReview(session, 'v1'), true);441assert.strictEqual(service.hasReview(session, 'v2'), false);442});443444test('hasReview returns true after successful review', async () => {445commandService.result = { type: 'success', comments: [] };446service.requestReview(session, 'v1', [{ currentUri: fileA }]);447448await tick();449450assert.strictEqual(service.hasReview(session, 'v1'), true);451});452453// --- requestReview ---454455test('requestReview transitions to loading state', () => {456commandService.deferNextExecution();457service.requestReview(session, 'v1', [{ currentUri: fileA }]);458459const state = service.getReviewState(session).get();460assert.strictEqual(state.kind, CodeReviewStateKind.Loading);461if (state.kind === CodeReviewStateKind.Loading) {462assert.strictEqual(state.version, 'v1');463assert.strictEqual(state.reviewCount, 1);464}465466// Resolve to avoid leaking467commandService.resolveExecution({ type: 'success', comments: [] });468});469470test('requestReview calls command with correct arguments', async () => {471commandService.result = { type: 'success', comments: [] };472service.requestReview(session, 'v1', [473{ currentUri: fileA, baseUri: fileB },474{ currentUri: fileB },475]);476477await tick();478479assert.strictEqual(commandService.lastCommandId, 'chat.internal.codeReview.run');480const args = commandService.lastArgs?.[0] as { files: { currentUri: URI; baseUri?: URI }[] };481assert.strictEqual(args.files.length, 2);482assert.strictEqual(args.files[0].currentUri.toString(), fileA.toString());483assert.strictEqual(args.files[0].baseUri?.toString(), fileB.toString());484assert.strictEqual(args.files[1].currentUri.toString(), fileB.toString());485assert.strictEqual(args.files[1].baseUri, undefined);486});487488test('requestReview with success populates comments', async () => {489commandService.result = {490type: 'success',491comments: [492{493uri: fileA,494range: new Range(1, 1, 5, 1),495body: 'Bug found',496kind: 'bug',497severity: 'high',498},499{500uri: fileB,501range: new Range(10, 1, 15, 1),502body: 'Style issue',503kind: 'style',504severity: 'low',505},506],507};508509service.requestReview(session, 'v1', [{ currentUri: fileA }, { currentUri: fileB }]);510await tick();511512const state = service.getReviewState(session).get();513assert.strictEqual(state.kind, CodeReviewStateKind.Result);514if (state.kind === CodeReviewStateKind.Result) {515assert.strictEqual(state.version, 'v1');516assert.strictEqual(state.reviewCount, 1);517assert.strictEqual(state.comments.length, 2);518assert.strictEqual(state.comments[0].body, 'Bug found');519assert.strictEqual(state.comments[0].kind, 'bug');520assert.strictEqual(state.comments[0].severity, 'high');521assert.strictEqual(state.comments[0].uri.toString(), fileA.toString());522assert.strictEqual(state.comments[1].body, 'Style issue');523}524});525526test('requestReview with error transitions to error state', async () => {527commandService.result = { type: 'error', reason: 'Auth failed' };528service.requestReview(session, 'v1', [{ currentUri: fileA }]);529530await tick();531532const state = service.getReviewState(session).get();533assert.strictEqual(state.kind, CodeReviewStateKind.Error);534if (state.kind === CodeReviewStateKind.Error) {535assert.strictEqual(state.version, 'v1');536assert.strictEqual(state.reviewCount, 1);537assert.strictEqual(state.reason, 'Auth failed');538}539});540541test('requestReview with cancelled result transitions to idle', async () => {542commandService.result = { type: 'cancelled' };543service.requestReview(session, 'v1', [{ currentUri: fileA }]);544545await tick();546547const state = service.getReviewState(session).get();548assert.strictEqual(state.kind, CodeReviewStateKind.Idle);549});550551test('requestReview with undefined result transitions to idle', async () => {552commandService.result = undefined;553service.requestReview(session, 'v1', [{ currentUri: fileA }]);554555await tick();556557const state = service.getReviewState(session).get();558assert.strictEqual(state.kind, CodeReviewStateKind.Idle);559});560561test('requestReview with thrown error transitions to error state', async () => {562commandService.deferNextExecution();563service.requestReview(session, 'v1', [{ currentUri: fileA }]);564commandService.rejectExecution(new Error('Network error'));565566await tick();567568const state = service.getReviewState(session).get();569assert.strictEqual(state.kind, CodeReviewStateKind.Error);570if (state.kind === CodeReviewStateKind.Error) {571assert.strictEqual(state.reviewCount, 1);572assert.ok(state.reason.includes('Network error'));573}574});575576test('requestReview is a no-op when loading for the same version', () => {577commandService.deferNextExecution();578service.requestReview(session, 'v1', [{ currentUri: fileA }]);579580// Attempt to request again for the same version581service.requestReview(session, 'v1', [{ currentUri: fileA }]);582583// Should still be loading (not re-triggered)584const state = service.getReviewState(session).get();585assert.strictEqual(state.kind, CodeReviewStateKind.Loading);586587commandService.resolveExecution({ type: 'success', comments: [] });588});589590test('requestReview is a no-op when unresolved comments exist for the same version', async () => {591commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment' }] };592service.requestReview(session, 'v1', [{ currentUri: fileA }]);593await tick();594595// Attempt to request again596service.requestReview(session, 'v1', [{ currentUri: fileA }]);597598// Should still have the result599const state = service.getReviewState(session).get();600assert.strictEqual(state.kind, CodeReviewStateKind.Result);601if (state.kind === CodeReviewStateKind.Result) {602assert.strictEqual(state.comments.length, 1);603}604});605606test('requestReview reruns when previous result for the same version had no comments', async () => {607commandService.result = { type: 'success', comments: [] };608service.requestReview(session, 'v1', [{ currentUri: fileA }]);609await tick();610611commandService.deferNextExecution();612service.requestReview(session, 'v1', [{ currentUri: fileA }]);613614const state = service.getReviewState(session).get();615assert.strictEqual(state.kind, CodeReviewStateKind.Loading);616617commandService.resolveExecution({ type: 'success', comments: [] });618await tick();619});620621test('requestReview reruns when all comments for the same version were removed', async () => {622commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment' }] };623service.requestReview(session, 'v1', [{ currentUri: fileA }]);624await tick();625626const initialState = service.getReviewState(session).get();627assert.strictEqual(initialState.kind, CodeReviewStateKind.Result);628if (initialState.kind !== CodeReviewStateKind.Result) {629return;630}631632service.removeComment(session, initialState.comments[0].id);633634commandService.deferNextExecution();635service.requestReview(session, 'v1', [{ currentUri: fileA }]);636637const state = service.getReviewState(session).get();638assert.strictEqual(state.kind, CodeReviewStateKind.Loading);639640commandService.resolveExecution({ type: 'success', comments: [] });641await tick();642});643644test('requestReview is a no-op after five reviews for the same version', async () => {645commandService.result = { type: 'success', comments: [] };646647for (let i = 0; i < 5; i++) {648service.requestReview(session, 'v1', [{ currentUri: fileA }]);649await tick();650}651652const stateBefore = service.getReviewState(session).get();653assert.strictEqual(stateBefore.kind, CodeReviewStateKind.Result);654if (stateBefore.kind === CodeReviewStateKind.Result) {655assert.strictEqual(stateBefore.reviewCount, 5);656}657658commandService.deferNextExecution();659service.requestReview(session, 'v1', [{ currentUri: fileA }]);660661const stateAfter = service.getReviewState(session).get();662assert.strictEqual(stateAfter.kind, CodeReviewStateKind.Result);663if (stateAfter.kind === CodeReviewStateKind.Result) {664assert.strictEqual(stateAfter.reviewCount, 5);665}666});667668test('requestReview for a new version replaces loading state', async () => {669// Start v1 review — it will complete immediately with empty result670commandService.result = { type: 'success', comments: [] };671service.requestReview(session, 'v1', [{ currentUri: fileA }]);672await tick();673674assert.strictEqual(service.hasReview(session, 'v1'), true);675676// Request v2 — since v1 is a different version, it should proceed677commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'v2 comment' }] };678service.requestReview(session, 'v2', [{ currentUri: fileA }]);679await tick();680681const state = service.getReviewState(session).get();682assert.strictEqual(state.kind, CodeReviewStateKind.Result);683if (state.kind === CodeReviewStateKind.Result) {684assert.strictEqual(state.version, 'v2');685assert.strictEqual(state.comments.length, 1);686assert.strictEqual(state.comments[0].body, 'v2 comment');687}688689// v1 is no longer valid690assert.strictEqual(service.hasReview(session, 'v1'), false);691});692693// --- removeComment ---694695test('removeComment removes a specific comment', async () => {696commandService.result = {697type: 'success',698comments: [699{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment1' },700{ uri: fileA, range: new Range(5, 1, 5, 1), body: 'comment2' },701{ uri: fileB, range: new Range(10, 1, 10, 1), body: 'comment3' },702],703};704705service.requestReview(session, 'v1', [{ currentUri: fileA }, { currentUri: fileB }]);706await tick();707708const state = service.getReviewState(session).get();709assert.strictEqual(state.kind, CodeReviewStateKind.Result);710if (state.kind !== CodeReviewStateKind.Result) { return; }711712const commentToRemove = state.comments[1];713service.removeComment(session, commentToRemove.id);714715const newState = service.getReviewState(session).get();716assert.strictEqual(newState.kind, CodeReviewStateKind.Result);717if (newState.kind === CodeReviewStateKind.Result) {718assert.strictEqual(newState.comments.length, 2);719assert.strictEqual(newState.comments[0].body, 'comment1');720assert.strictEqual(newState.comments[1].body, 'comment3');721}722});723724test('removeComment is a no-op for unknown comment id', async () => {725commandService.result = {726type: 'success',727comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment1' }],728};729730service.requestReview(session, 'v1', [{ currentUri: fileA }]);731await tick();732733service.removeComment(session, 'nonexistent-id');734735const state = service.getReviewState(session).get();736if (state.kind === CodeReviewStateKind.Result) {737assert.strictEqual(state.comments.length, 1);738}739});740741test('removeComment is a no-op when no review exists', () => {742// Should not throw743service.removeComment(session, 'some-id');744const state = service.getReviewState(session).get();745assert.strictEqual(state.kind, CodeReviewStateKind.Idle);746});747748test('removeComment is a no-op when state is not result', () => {749commandService.deferNextExecution();750service.requestReview(session, 'v1', [{ currentUri: fileA }]);751752// State is loading — removeComment should be ignored753service.removeComment(session, 'some-id');754755const state = service.getReviewState(session).get();756assert.strictEqual(state.kind, CodeReviewStateKind.Loading);757758commandService.resolveExecution({ type: 'success', comments: [] });759});760761test('removeComment preserves version in result', async () => {762commandService.result = {763type: 'success',764comments: [765{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment1' },766{ uri: fileA, range: new Range(5, 1, 5, 1), body: 'comment2' },767],768};769770service.requestReview(session, 'v1', [{ currentUri: fileA }]);771await tick();772773const state = service.getReviewState(session).get();774if (state.kind !== CodeReviewStateKind.Result) { return; }775776service.removeComment(session, state.comments[0].id);777778const newState = service.getReviewState(session).get();779if (newState.kind === CodeReviewStateKind.Result) {780assert.strictEqual(newState.version, 'v1');781}782});783784// --- dismissReview ---785786test('dismissReview resets to idle', async () => {787commandService.result = { type: 'success', comments: [] };788service.requestReview(session, 'v1', [{ currentUri: fileA }]);789await tick();790791assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Result);792793service.dismissReview(session);794795assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle);796});797798test('dismissReview while loading resets to idle', () => {799commandService.deferNextExecution();800service.requestReview(session, 'v1', [{ currentUri: fileA }]);801802assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Loading);803804service.dismissReview(session);805806assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle);807808// Resolve the pending command — should be ignored since dismissed809commandService.resolveExecution({ type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'late' }] });810});811812test('dismissReview is a no-op when no data exists', () => {813// Should not throw814service.dismissReview(session);815});816817test('hasReview returns false after dismissReview', async () => {818commandService.result = { type: 'success', comments: [] };819service.requestReview(session, 'v1', [{ currentUri: fileA }]);820await tick();821822assert.strictEqual(service.hasReview(session, 'v1'), true);823824service.dismissReview(session);825826assert.strictEqual(service.hasReview(session, 'v1'), false);827});828829// --- Isolation between sessions ---830831test('different sessions are independent', async () => {832const session2 = URI.parse('test://session/2');833834commandService.result = {835type: 'success',836comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'session1 comment' }],837};838service.requestReview(session, 'v1', [{ currentUri: fileA }]);839await tick();840841commandService.result = {842type: 'success',843comments: [{ uri: fileB, range: new Range(2, 1, 2, 1), body: 'session2 comment' }],844};845service.requestReview(session2, 'v2', [{ currentUri: fileB }]);846await tick();847848const state1 = service.getReviewState(session).get();849const state2 = service.getReviewState(session2).get();850851assert.strictEqual(state1.kind, CodeReviewStateKind.Result);852assert.strictEqual(state2.kind, CodeReviewStateKind.Result);853854if (state1.kind === CodeReviewStateKind.Result && state2.kind === CodeReviewStateKind.Result) {855assert.strictEqual(state1.comments[0].body, 'session1 comment');856assert.strictEqual(state2.comments[0].body, 'session2 comment');857}858859// Dismissing session1 doesn't affect session2860service.dismissReview(session);861assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle);862assert.strictEqual(service.getReviewState(session2).get().kind, CodeReviewStateKind.Result);863});864865// --- Comment parsing ---866867test('comments with string URIs are parsed correctly', async () => {868commandService.result = {869type: 'success',870comments: [871{872uri: 'file:///parsed.ts',873range: new Range(1, 1, 1, 1),874body: 'parsed comment',875},876],877};878879service.requestReview(session, 'v1', [{ currentUri: fileA }]);880await tick();881882const state = service.getReviewState(session).get();883if (state.kind === CodeReviewStateKind.Result) {884assert.strictEqual(state.comments[0].uri.toString(), 'file:///parsed.ts');885}886});887888test('comments with missing optional fields get defaults', async () => {889commandService.result = {890type: 'success',891comments: [892{893uri: fileA,894range: new Range(1, 1, 1, 1),895// body, kind, severity omitted896},897],898};899900service.requestReview(session, 'v1', [{ currentUri: fileA }]);901await tick();902903const state = service.getReviewState(session).get();904if (state.kind === CodeReviewStateKind.Result) {905assert.strictEqual(state.comments[0].body, '');906assert.strictEqual(state.comments[0].kind, '');907assert.strictEqual(state.comments[0].severity, '');908assert.strictEqual(state.comments[0].suggestion, undefined);909}910});911912test('comments normalize VS Code API style ranges', async () => {913commandService.result = {914type: 'success',915comments: [916{917uri: fileA,918range: {919start: { line: 4, character: 2 },920end: { line: 6, character: 5 },921},922body: 'normalized comment',923suggestion: {924edits: [925{926range: {927start: { line: 8, character: 1 },928end: { line: 8, character: 9 },929},930oldText: 'let value',931newText: 'const value',932},933],934},935},936],937};938939service.requestReview(session, 'v1', [{ currentUri: fileA }]);940await tick();941942const state = service.getReviewState(session).get();943assert.strictEqual(state.kind, CodeReviewStateKind.Result);944if (state.kind === CodeReviewStateKind.Result) {945assert.deepStrictEqual(state.comments[0].range, new Range(5, 3, 7, 6));946assert.deepStrictEqual(state.comments[0].suggestion?.edits[0].range, new Range(9, 2, 9, 10));947}948});949950test('comments normalize serialized URIs and tuple ranges from API payloads', async () => {951const serializedUri = JSON.parse(JSON.stringify(URI.parse('git:/c%3A/Code/vscode.worktrees/copilot-worktree-2026-03-04T14-44-38/src/vs/sessions/contrib/changesView/test/browser/codeReviewService.test.ts?%7B%22path%22%3A%22c%3A%5C%5CCode%5C%5Cvscode.worktrees%5C%5Ccopilot-worktree-2026-03-04T14-44-38%5C%5Csrc%5C%5Cvs%5C%5Csessions%5C%5Ccontrib%5C%5CchangesView%5C%5Ctest%5C%5Cbrowser%5C%5CcodeReviewService.test.ts%22%2C%22ref%22%3A%22copilot-worktree-2026-03-04T14-44-38%22%7D')));952953commandService.result = {954type: 'success',955comments: [956{957uri: serializedUri,958range: [959{ line: 72, character: 2 },960{ line: 72, character: 3 },961],962body: 'tuple range comment',963kind: 'bug',964severity: 'medium',965},966],967};968969service.requestReview(session, 'v1', [{ currentUri: fileA }]);970await tick();971972const state = service.getReviewState(session).get();973assert.strictEqual(state.kind, CodeReviewStateKind.Result);974if (state.kind === CodeReviewStateKind.Result) {975assert.strictEqual(state.comments[0].uri.toString(), URI.revive(serializedUri).toString());976assert.deepStrictEqual(state.comments[0].range, new Range(73, 3, 73, 4));977}978});979980test('each comment gets a unique id', async () => {981commandService.result = {982type: 'success',983comments: [984{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'a' },985{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'b' },986],987};988989service.requestReview(session, 'v1', [{ currentUri: fileA }]);990await tick();991992const state = service.getReviewState(session).get();993if (state.kind === CodeReviewStateKind.Result) {994assert.notStrictEqual(state.comments[0].id, state.comments[1].id);995}996});997998// --- Observable reactivity ---9991000test('observable fires on state transitions', async () => {1001const states: string[] = [];1002const obs = service.getReviewState(session);10031004// Collect initial state1005states.push(obs.get().kind);10061007commandService.deferNextExecution();1008service.requestReview(session, 'v1', [{ currentUri: fileA }]);1009states.push(obs.get().kind);10101011commandService.resolveExecution({ type: 'success', comments: [] });1012await tick();1013states.push(obs.get().kind);10141015service.dismissReview(session);1016states.push(obs.get().kind);10171018assert.deepStrictEqual(states, [1019CodeReviewStateKind.Idle,1020CodeReviewStateKind.Loading,1021CodeReviewStateKind.Result,1022CodeReviewStateKind.Idle,1023]);1024});10251026// --- Storage persistence ---10271028test('review results are persisted to storage', async () => {1029commandService.result = {1030type: 'success',1031comments: [{ uri: fileA, range: new Range(1, 1, 5, 1), body: 'Persisted comment', kind: 'bug', severity: 'high' }],1032};1033service.requestReview(session, 'v1', [{ currentUri: fileA }]);1034await tick();10351036const raw = storageService.get('codeReview.reviews', StorageScope.WORKSPACE);1037assert.ok(raw, 'Storage should contain review data');1038const stored = JSON.parse(raw!);1039const reviewData = stored[session.toString()];1040assert.ok(reviewData);1041assert.strictEqual(reviewData.version, 'v1');1042assert.strictEqual(reviewData.reviewCount, 1);1043assert.strictEqual(reviewData.comments.length, 1);1044assert.strictEqual(reviewData.comments[0].body, 'Persisted comment');1045});10461047test('reviews are restored from storage on service creation', async () => {1048commandService.result = {1049type: 'success',1050comments: [{ uri: fileA, range: new Range(1, 1, 5, 1), body: 'Restored comment', kind: 'bug', severity: 'high' }],1051};1052service.requestReview(session, 'v1', [{ currentUri: fileA }]);1053await tick();10541055// Create a second service with the same storage1056const service2 = store.add(instantiationService.createInstance(CodeReviewService));1057const state = service2.getReviewState(session).get();1058assert.strictEqual(state.kind, CodeReviewStateKind.Result);1059if (state.kind === CodeReviewStateKind.Result) {1060assert.strictEqual(state.version, 'v1');1061assert.strictEqual(state.reviewCount, 1);1062assert.strictEqual(state.comments.length, 1);1063assert.strictEqual(state.comments[0].body, 'Restored comment');1064assert.strictEqual(state.comments[0].uri.toString(), fileA.toString());1065assert.deepStrictEqual(state.comments[0].range, { startLineNumber: 1, startColumn: 1, endLineNumber: 5, endColumn: 1 });1066}1067});10681069test('suggestions are persisted and restored correctly', async () => {1070commandService.result = {1071type: 'success',1072comments: [{1073uri: fileA,1074range: new Range(1, 1, 5, 1),1075body: 'suggestion comment',1076suggestion: {1077edits: [{1078range: new Range(2, 1, 3, 10),1079oldText: 'let x = 1;',1080newText: 'const x = 1;',1081}],1082},1083}],1084};1085service.requestReview(session, 'v1', [{ currentUri: fileA }]);1086await tick();10871088const service2 = store.add(instantiationService.createInstance(CodeReviewService));1089const state = service2.getReviewState(session).get();1090assert.strictEqual(state.kind, CodeReviewStateKind.Result);1091if (state.kind === CodeReviewStateKind.Result) {1092assert.strictEqual(state.comments[0].suggestion?.edits.length, 1);1093assert.strictEqual(state.comments[0].suggestion?.edits[0].oldText, 'let x = 1;');1094assert.strictEqual(state.comments[0].suggestion?.edits[0].newText, 'const x = 1;');1095}1096});10971098test('removeComment updates storage', async () => {1099commandService.result = {1100type: 'success',1101comments: [1102{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment1' },1103{ uri: fileA, range: new Range(5, 1, 5, 1), body: 'comment2' },1104],1105};1106service.requestReview(session, 'v1', [{ currentUri: fileA }]);1107await tick();11081109const state = service.getReviewState(session).get();1110if (state.kind !== CodeReviewStateKind.Result) { return; }11111112service.removeComment(session, state.comments[0].id);11131114const raw = storageService.get('codeReview.reviews', StorageScope.WORKSPACE);1115const stored = JSON.parse(raw!);1116assert.strictEqual(stored[session.toString()].comments.length, 1);1117assert.strictEqual(stored[session.toString()].comments[0].body, 'comment2');1118});11191120test('dismissReview removes session from storage', async () => {1121commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'c' }] };1122service.requestReview(session, 'v1', [{ currentUri: fileA }]);1123await tick();11241125assert.ok(storageService.get('codeReview.reviews', StorageScope.WORKSPACE));11261127service.dismissReview(session);11281129assert.strictEqual(storageService.get('codeReview.reviews', StorageScope.WORKSPACE), undefined);1130});11311132test('corrupted storage is handled gracefully', () => {1133storageService.store('codeReview.reviews', 'not-valid-json{{{', StorageScope.WORKSPACE, StorageTarget.MACHINE);11341135const service2 = store.add(instantiationService.createInstance(CodeReviewService));1136const state = service2.getReviewState(session).get();1137assert.strictEqual(state.kind, CodeReviewStateKind.Idle);1138});11391140// --- Session lifecycle cleanup ---11411142test('archived session reviews are cleaned up', async () => {1143commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment' }] };1144service.requestReview(session, 'v1', [{ currentUri: fileA }]);1145await tick();11461147assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Result);11481149const mockSession = sessionsManagement.addSession(session, undefined, true);1150sessionsManagement.fireSessionsChanged({ changed: [mockSession] });11511152assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle);1153assert.strictEqual(storageService.get('codeReview.reviews', StorageScope.WORKSPACE), undefined);1154});11551156test('non-archived session change does not clean up review', async () => {1157const changes: IChatSessionFileChange2[] = [1158{ uri: fileA, modifiedUri: fileA, insertions: 1, deletions: 0 },1159];1160const files = getCodeReviewFilesFromSessionChanges(changes);1161const version = getCodeReviewVersion(files);11621163commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment' }] };1164service.requestReview(session, version, files);1165await tick();11661167const mockSession = sessionsManagement.addSession(session, changes, false);1168sessionsManagement.fireSessionsChanged({ changed: [mockSession] });11691170assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Result);1171});11721173test('session with changed version has review cleaned up', async () => {1174const changes: IChatSessionFileChange2[] = [1175{ uri: fileA, modifiedUri: fileA, insertions: 1, deletions: 0 },1176];1177sessionsManagement.addSession(session, changes);11781179const files = getCodeReviewFilesFromSessionChanges(changes);1180const version = getCodeReviewVersion(files);11811182commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'stale comment' }] };1183service.requestReview(session, version, files);1184await tick();11851186assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Result);11871188const newChanges: IChatSessionFileChange2[] = [1189{ uri: fileA, modifiedUri: fileA, insertions: 1, deletions: 0 },1190{ uri: fileB, modifiedUri: fileB, insertions: 2, deletions: 0 },1191];1192sessionsManagement.updateSessionChanges(session, newChanges);1193sessionsManagement.fireSessionsChanged();11941195assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle);1196assert.strictEqual(storageService.get('codeReview.reviews', StorageScope.WORKSPACE), undefined);1197});11981199test('session that no longer exists has review cleaned up', async () => {1200commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'orphaned comment' }] };1201service.requestReview(session, 'v1', [{ currentUri: fileA }]);1202await tick();12031204assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Result);12051206sessionsManagement.fireSessionsChanged();12071208assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle);1209});12101211test('session with no changes has review cleaned up', async () => {1212sessionsManagement.addSession(session, [1213{ uri: fileA, modifiedUri: fileA, insertions: 1, deletions: 0 },1214]);12151216commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment' }] };1217service.requestReview(session, 'v1', [{ currentUri: fileA }]);1218await tick();12191220sessionsManagement.updateSessionChanges(session, undefined);1221sessionsManagement.fireSessionsChanged();12221223assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle);1224});12251226test('session with matching version keeps review intact', async () => {1227const changes: IChatSessionFileChange2[] = [1228{ uri: fileA, modifiedUri: fileA, insertions: 1, deletions: 0 },1229];1230sessionsManagement.addSession(session, changes);12311232const files = getCodeReviewFilesFromSessionChanges(changes);1233const version = getCodeReviewVersion(files);12341235commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'valid comment' }] };1236service.requestReview(session, version, files);1237await tick();12381239sessionsManagement.fireSessionsChanged();12401241const state = service.getReviewState(session).get();1242assert.strictEqual(state.kind, CodeReviewStateKind.Result);1243if (state.kind === CodeReviewStateKind.Result) {1244assert.strictEqual(state.comments[0].body, 'valid comment');1245}1246});1247});12481249function makeGitHubInfo(prNumber = 1): IGitHubInfo {1250return {1251owner: 'owner',1252repo: 'repo',1253pullRequest: {1254number: prNumber,1255uri: URI.parse(`https://github.com/owner/repo/pull/${prNumber}`),1256},1257};1258}12591260function makePRThread(id: string, path: string): IGitHubPullRequestReviewThread {1261return {1262id,1263isResolved: false,1264path,1265line: 10,1266comments: [makePRComment(100, `Comment on ${path}`, id)],1267};1268}12691270function makePRComment(id: number, body: string, threadId: string = String(id)): IGitHubPRComment {1271return {1272id,1273body,1274author: { login: 'reviewer', avatarUrl: '' },1275createdAt: '2024-01-01T00:00:00Z',1276updatedAt: '2024-01-01T00:00:00Z',1277path: undefined,1278line: undefined,1279threadId,1280inReplyToId: undefined,1281};1282}12831284function tick(): Promise<void> {1285return new Promise(resolve => setTimeout(resolve, 0));1286}128712881289