Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts
13405 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 assert from 'assert';
7
import { Codicon } from '../../../../../base/common/codicons.js';
8
import { URI } from '../../../../../base/common/uri.js';
9
import { Range } from '../../../../../editor/common/core/range.js';
10
import { IObservable, observableValue } from '../../../../../base/common/observable.js';
11
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
12
import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
13
import { DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';
14
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
15
import { Emitter, Event } from '../../../../../base/common/event.js';
16
import { mock } from '../../../../../base/test/common/mock.js';
17
import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js';
18
import { InMemoryStorageService, IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';
19
import { IChatSessionFileChange, IChatSessionFileChange2 } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js';
20
import { IGitHubService } from '../../../github/browser/githubService.js';
21
import { GitHubPRFetcher } from '../../../github/browser/fetchers/githubPRFetcher.js';
22
import { GitHubPullRequestModel } from '../../../github/browser/models/githubPullRequestModel.js';
23
import { GitHubPullRequestReviewThreadsModel } from '../../../github/browser/models/githubPullRequestReviewThreadsModel.js';
24
import { IGitHubPRComment, IGitHubPullRequestReviewThread } from '../../../github/common/types.js';
25
import { IGitHubInfo, ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js';
26
import { ICodeReviewService, CodeReviewService, CodeReviewStateKind, PRReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion } from '../../browser/codeReviewService.js';
27
import { IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js';
28
29
suite('CodeReviewService', () => {
30
31
const store = new DisposableStore();
32
let instantiationService: TestInstantiationService;
33
let service: ICodeReviewService;
34
let commandService: MockCommandService;
35
let gitHubService: MockGitHubService;
36
let storageService: InMemoryStorageService;
37
let sessionsManagement: MockSessionsManagementService;
38
39
let session: URI;
40
let fileA: URI;
41
let fileB: URI;
42
43
class MockCommandService implements ICommandService {
44
declare readonly _serviceBrand: undefined;
45
readonly onWillExecuteCommand = Event.None;
46
readonly onDidExecuteCommand = Event.None;
47
48
result: unknown = undefined;
49
lastCommandId: string | undefined;
50
lastArgs: unknown[] | undefined;
51
executeDeferred: { resolve: (v: unknown) => void; reject: (e: unknown) => void } | undefined;
52
53
async executeCommand<T>(commandId: string, ...args: unknown[]): Promise<T> {
54
this.lastCommandId = commandId;
55
this.lastArgs = args;
56
57
if (this.executeDeferred) {
58
return await new Promise<T>((resolve, reject) => {
59
this.executeDeferred = { resolve: resolve as (v: unknown) => void, reject };
60
});
61
}
62
return this.result as T;
63
}
64
65
/**
66
* Configure the mock to defer execution until manually resolved/rejected.
67
*/
68
deferNextExecution(): void {
69
this.executeDeferred = undefined;
70
const self = this;
71
const originalResult = this.result;
72
73
// Override executeCommand for next call to capture the deferred promise
74
const origExecute = this.executeCommand.bind(this);
75
this.executeCommand = async function <T>(commandId: string, ...args: unknown[]): Promise<T> {
76
self.lastCommandId = commandId;
77
self.lastArgs = args;
78
79
return new Promise<T>((resolve, reject) => {
80
self.executeDeferred = { resolve: resolve as (v: unknown) => void, reject };
81
});
82
} as typeof origExecute;
83
84
// Restore after use
85
this._restoreExecute = () => {
86
this.executeCommand = origExecute;
87
this.result = originalResult;
88
};
89
}
90
91
private _restoreExecute: (() => void) | undefined;
92
93
resolveExecution(value: unknown): void {
94
this.executeDeferred?.resolve(value);
95
this.executeDeferred = undefined;
96
this._restoreExecute?.();
97
}
98
99
rejectExecution(error: unknown): void {
100
this.executeDeferred?.reject(error);
101
this.executeDeferred = undefined;
102
this._restoreExecute?.();
103
}
104
}
105
106
class MockSessionsManagementService extends mock<ISessionsManagementService>() {
107
private readonly _onDidChangeSessions: Emitter<ISessionsChangeEvent>;
108
private readonly _activeSession: ReturnType<typeof observableValue<IActiveSession | undefined>>;
109
override readonly onDidChangeSessions: Event<ISessionsChangeEvent>;
110
override readonly activeSession: IObservable<IActiveSession | undefined>;
111
112
private readonly _sessions = new Map<string, ISession>();
113
114
constructor(disposables: DisposableStore) {
115
super();
116
this._onDidChangeSessions = disposables.add(new Emitter<ISessionsChangeEvent>());
117
this.onDidChangeSessions = this._onDidChangeSessions.event;
118
this._activeSession = observableValue<IActiveSession | undefined>('test.activeSession', undefined);
119
this.activeSession = this._activeSession;
120
}
121
122
override getSession(resource: URI): ISession | undefined {
123
return this._sessions.get(resource.toString());
124
}
125
126
addSession(resource: URI, changes?: readonly IChatSessionFileChange2[], archived = false): ISession {
127
const changesObs = observableValue<readonly IChatSessionFileChange[]>('test.changes',
128
(changes ?? []).map(c => ({ modifiedUri: c.modifiedUri ?? c.uri, originalUri: c.originalUri, insertions: c.insertions, deletions: c.deletions }))
129
);
130
const isArchivedObs = observableValue<boolean>('test.isArchived', archived);
131
const gitHubInfoObs = observableValue<IGitHubInfo | undefined>('test.gitHubInfo', undefined);
132
const workspaceObs = observableValue<ISessionWorkspace | undefined>('test.workspace', {
133
label: 'workspace',
134
icon: Codicon.folder,
135
repositories: [{ uri: URI.file('/workspace'), workingDirectory: undefined, detail: undefined, baseBranchName: undefined }],
136
requiresWorkspaceTrust: false,
137
});
138
const sessionData: ISession = {
139
sessionId: `test:${resource.toString()}`,
140
resource,
141
workspace: workspaceObs,
142
changes: changesObs,
143
isArchived: isArchivedObs,
144
gitHubInfo: gitHubInfoObs,
145
} as unknown as ISession;
146
this._sessions.set(resource.toString(), sessionData);
147
return sessionData;
148
}
149
150
setGitHubInfo(resource: URI, gitHubInfo: IGitHubInfo | undefined): void {
151
const session = this._sessions.get(resource.toString());
152
if (session) {
153
(session.gitHubInfo as ReturnType<typeof observableValue<IGitHubInfo | undefined>>).set(gitHubInfo, undefined);
154
}
155
}
156
157
setActiveSession(resource: URI | undefined): void {
158
this._activeSession.set(resource ? this._sessions.get(resource.toString()) as IActiveSession | undefined : undefined, undefined);
159
}
160
161
updateSessionChanges(resource: URI, changes: readonly IChatSessionFileChange2[] | undefined): void {
162
const session = this._sessions.get(resource.toString());
163
if (session) {
164
const obs = session.changes as ReturnType<typeof observableValue<readonly IChatSessionFileChange[]>>;
165
obs.set(
166
(changes ?? []).map(c => ({ modifiedUri: c.modifiedUri ?? c.uri, originalUri: c.originalUri, insertions: c.insertions, deletions: c.deletions })),
167
undefined
168
);
169
}
170
}
171
172
removeSession(resource: URI): void {
173
this._sessions.delete(resource.toString());
174
}
175
176
override getSessions(): ISession[] {
177
return [...this._sessions.values()];
178
}
179
180
fireSessionsChanged(event?: Partial<ISessionsChangeEvent>): void {
181
this._onDidChangeSessions.fire({
182
added: event?.added ?? [],
183
removed: event?.removed ?? [],
184
changed: event?.changed ?? [],
185
});
186
}
187
}
188
189
class MockReviewThreadsFetcher {
190
nextThreads: IGitHubPullRequestReviewThread[] = [];
191
getReviewThreadsCalls = 0;
192
resolveThreadCalls: { threadId: string }[] = [];
193
194
async getReviewThreads(_owner: string, _repo: string, _prNumber: number): Promise<IGitHubPullRequestReviewThread[]> {
195
this.getReviewThreadsCalls++;
196
return this.nextThreads;
197
}
198
199
async postReviewComment(_owner: string, _repo: string, _prNumber: number, body: string, inReplyTo: number): Promise<IGitHubPRComment> {
200
return makePRComment(inReplyTo, body);
201
}
202
203
async resolveThread(_owner: string, _repo: string, threadId: string): Promise<void> {
204
this.resolveThreadCalls.push({ threadId });
205
}
206
}
207
208
class MockGitHubPullRequestReviewThreadsModel extends GitHubPullRequestReviewThreadsModel {
209
startPollingCalls = 0;
210
stopPollingCalls = 0;
211
212
override startPolling(intervalMs?: number): IDisposable {
213
this.startPollingCalls++;
214
const polling = super.startPolling(intervalMs);
215
return toDisposable(() => {
216
this.stopPollingCalls++;
217
polling.dispose();
218
});
219
}
220
}
221
222
class MockGitHubService extends mock<IGitHubService>() {
223
readonly legacyFetcher = new MockReviewThreadsFetcher();
224
readonly reviewThreadsFetcher = new MockReviewThreadsFetcher();
225
226
private readonly _pullRequestModel: GitHubPullRequestModel;
227
private readonly _reviewThreadsModels = new Map<string, MockGitHubPullRequestReviewThreadsModel>();
228
private readonly _reviewThreadsFetchers = new Map<string, MockReviewThreadsFetcher>();
229
230
getPullRequestCalls = 0;
231
getPullRequestReviewThreadsCalls = 0;
232
233
constructor(disposables: DisposableStore, logService: ILogService) {
234
super();
235
this._pullRequestModel = disposables.add(new GitHubPullRequestModel('owner', 'repo', 1, this.legacyFetcher as unknown as GitHubPRFetcher, logService));
236
this._reviewThreadsFetchers.set(this._key('owner', 'repo', 1), this.reviewThreadsFetcher);
237
}
238
239
override getPullRequest(): GitHubPullRequestModel {
240
this.getPullRequestCalls++;
241
return this._pullRequestModel;
242
}
243
244
override getPullRequestReviewThreads(owner: string, repo: string, prNumber: number): GitHubPullRequestReviewThreadsModel {
245
this.getPullRequestReviewThreadsCalls++;
246
return this.getReviewThreadsModel(owner, repo, prNumber);
247
}
248
249
getReviewThreadsFetcher(owner: string, repo: string, prNumber: number): MockReviewThreadsFetcher {
250
const key = this._key(owner, repo, prNumber);
251
let fetcher = this._reviewThreadsFetchers.get(key);
252
if (!fetcher) {
253
fetcher = new MockReviewThreadsFetcher();
254
this._reviewThreadsFetchers.set(key, fetcher);
255
}
256
return fetcher;
257
}
258
259
getReviewThreadsModel(owner: string, repo: string, prNumber: number): MockGitHubPullRequestReviewThreadsModel {
260
const key = this._key(owner, repo, prNumber);
261
let model = this._reviewThreadsModels.get(key);
262
if (!model) {
263
model = store.add(new MockGitHubPullRequestReviewThreadsModel(owner, repo, prNumber, this.getReviewThreadsFetcher(owner, repo, prNumber) as unknown as GitHubPRFetcher, new NullLogService()));
264
this._reviewThreadsModels.set(key, model);
265
}
266
return model;
267
}
268
269
private _key(owner: string, repo: string, prNumber: number): string {
270
return `${owner}/${repo}#${prNumber}`;
271
}
272
}
273
274
setup(() => {
275
instantiationService = store.add(new TestInstantiationService());
276
277
commandService = new MockCommandService();
278
instantiationService.stub(ICommandService, commandService);
279
const logService = new NullLogService();
280
instantiationService.stub(ILogService, logService);
281
gitHubService = new MockGitHubService(store, logService);
282
instantiationService.stub(IGitHubService, gitHubService);
283
284
sessionsManagement = new MockSessionsManagementService(store);
285
instantiationService.stub(ISessionsManagementService, sessionsManagement);
286
287
storageService = store.add(new InMemoryStorageService());
288
instantiationService.stub(IStorageService, storageService);
289
290
service = store.add(instantiationService.createInstance(CodeReviewService));
291
session = URI.parse('test://session/1');
292
fileA = URI.parse('file:///a.ts');
293
fileB = URI.parse('file:///b.ts');
294
});
295
296
teardown(() => {
297
store.clear();
298
});
299
300
ensureNoDisposablesAreLeakedInTestSuite();
301
302
// --- getReviewState ---
303
304
test('initial state is idle', () => {
305
const state = service.getReviewState(session).get();
306
assert.strictEqual(state.kind, CodeReviewStateKind.Idle);
307
});
308
309
test('getReviewState returns the same observable for the same session', () => {
310
const obs1 = service.getReviewState(session);
311
const obs2 = service.getReviewState(session);
312
assert.strictEqual(obs1, obs2);
313
});
314
315
test('getReviewState returns different observables for different sessions', () => {
316
const session2 = URI.parse('test://session/2');
317
const obs1 = service.getReviewState(session);
318
const obs2 = service.getReviewState(session2);
319
assert.notStrictEqual(obs1, obs2);
320
});
321
322
test('PR review state uses dedicated review threads model', async () => {
323
sessionsManagement.addSession(session);
324
sessionsManagement.setGitHubInfo(session, makeGitHubInfo());
325
gitHubService.reviewThreadsFetcher.nextThreads = [makePRThread('thread-100', 'src/a.ts')];
326
327
sessionsManagement.setActiveSession(session);
328
await tick();
329
await tick();
330
331
const state = service.getPRReviewState(session).get();
332
assert.strictEqual(state.kind, PRReviewStateKind.Loaded);
333
if (state.kind === PRReviewStateKind.Loaded) {
334
assert.deepStrictEqual({
335
comments: state.comments.map(comment => ({ id: comment.id, uri: comment.uri.toString(), body: comment.body, author: comment.author })),
336
getPullRequestCalls: gitHubService.getPullRequestCalls,
337
getPullRequestReviewThreadsCalls: gitHubService.getPullRequestReviewThreadsCalls,
338
legacyThreadRefreshes: gitHubService.legacyFetcher.getReviewThreadsCalls,
339
reviewThreadRefreshes: gitHubService.reviewThreadsFetcher.getReviewThreadsCalls,
340
}, {
341
comments: [{ id: 'thread-100', uri: 'file:///workspace/src/a.ts', body: 'Comment on src/a.ts', author: 'reviewer' }],
342
getPullRequestCalls: 0,
343
getPullRequestReviewThreadsCalls: 1,
344
legacyThreadRefreshes: 0,
345
reviewThreadRefreshes: 1,
346
});
347
}
348
});
349
350
test('only active session PR review model is polled', async () => {
351
const session2 = URI.parse('test://session/2');
352
sessionsManagement.addSession(session);
353
sessionsManagement.setGitHubInfo(session, makeGitHubInfo(1));
354
sessionsManagement.addSession(session2);
355
sessionsManagement.setGitHubInfo(session2, makeGitHubInfo(2));
356
gitHubService.getReviewThreadsFetcher('owner', 'repo', 1).nextThreads = [makePRThread('thread-100', 'src/a.ts')];
357
gitHubService.getReviewThreadsFetcher('owner', 'repo', 2).nextThreads = [makePRThread('thread-200', 'src/b.ts')];
358
359
sessionsManagement.setActiveSession(session);
360
await tick();
361
await tick();
362
363
const session1Model = gitHubService.getReviewThreadsModel('owner', 'repo', 1);
364
const session2Model = gitHubService.getReviewThreadsModel('owner', 'repo', 2);
365
assert.deepStrictEqual({
366
session1StartPollingCalls: session1Model.startPollingCalls,
367
session1StopPollingCalls: session1Model.stopPollingCalls,
368
session2StartPollingCalls: session2Model.startPollingCalls,
369
session2StopPollingCalls: session2Model.stopPollingCalls,
370
}, {
371
session1StartPollingCalls: 1,
372
session1StopPollingCalls: 0,
373
session2StartPollingCalls: 0,
374
session2StopPollingCalls: 0,
375
});
376
377
sessionsManagement.setActiveSession(session2);
378
await tick();
379
await tick();
380
381
assert.deepStrictEqual({
382
session1StartPollingCalls: session1Model.startPollingCalls,
383
session1StopPollingCalls: session1Model.stopPollingCalls,
384
session2StartPollingCalls: session2Model.startPollingCalls,
385
session2StopPollingCalls: session2Model.stopPollingCalls,
386
}, {
387
session1StartPollingCalls: 1,
388
session1StopPollingCalls: 1,
389
session2StartPollingCalls: 1,
390
session2StopPollingCalls: 0,
391
});
392
393
sessionsManagement.setActiveSession(undefined);
394
await tick();
395
396
assert.deepStrictEqual({
397
session1StartPollingCalls: session1Model.startPollingCalls,
398
session1StopPollingCalls: session1Model.stopPollingCalls,
399
session2StartPollingCalls: session2Model.startPollingCalls,
400
session2StopPollingCalls: session2Model.stopPollingCalls,
401
}, {
402
session1StartPollingCalls: 1,
403
session1StopPollingCalls: 1,
404
session2StartPollingCalls: 1,
405
session2StopPollingCalls: 1,
406
});
407
});
408
409
test('resolvePRReviewThread uses dedicated review threads model', async () => {
410
sessionsManagement.addSession(session);
411
sessionsManagement.setGitHubInfo(session, makeGitHubInfo());
412
413
await service.resolvePRReviewThread(session, 'thread-100');
414
415
assert.deepStrictEqual({
416
getPullRequestCalls: gitHubService.getPullRequestCalls,
417
getPullRequestReviewThreadsCalls: gitHubService.getPullRequestReviewThreadsCalls,
418
legacyResolveThreadCalls: gitHubService.legacyFetcher.resolveThreadCalls,
419
reviewResolveThreadCalls: gitHubService.reviewThreadsFetcher.resolveThreadCalls,
420
}, {
421
getPullRequestCalls: 0,
422
getPullRequestReviewThreadsCalls: 1,
423
legacyResolveThreadCalls: [],
424
reviewResolveThreadCalls: [{ threadId: 'thread-100' }],
425
});
426
});
427
428
// --- hasReview ---
429
430
test('hasReview returns false when no review exists', () => {
431
assert.strictEqual(service.hasReview(session, 'v1'), false);
432
});
433
434
test('hasReview returns false when review is for a different version', async () => {
435
commandService.result = { type: 'success', comments: [] };
436
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
437
438
// Wait for async command to complete
439
await tick();
440
441
assert.strictEqual(service.hasReview(session, 'v1'), true);
442
assert.strictEqual(service.hasReview(session, 'v2'), false);
443
});
444
445
test('hasReview returns true after successful review', async () => {
446
commandService.result = { type: 'success', comments: [] };
447
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
448
449
await tick();
450
451
assert.strictEqual(service.hasReview(session, 'v1'), true);
452
});
453
454
// --- requestReview ---
455
456
test('requestReview transitions to loading state', () => {
457
commandService.deferNextExecution();
458
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
459
460
const state = service.getReviewState(session).get();
461
assert.strictEqual(state.kind, CodeReviewStateKind.Loading);
462
if (state.kind === CodeReviewStateKind.Loading) {
463
assert.strictEqual(state.version, 'v1');
464
assert.strictEqual(state.reviewCount, 1);
465
}
466
467
// Resolve to avoid leaking
468
commandService.resolveExecution({ type: 'success', comments: [] });
469
});
470
471
test('requestReview calls command with correct arguments', async () => {
472
commandService.result = { type: 'success', comments: [] };
473
service.requestReview(session, 'v1', [
474
{ currentUri: fileA, baseUri: fileB },
475
{ currentUri: fileB },
476
]);
477
478
await tick();
479
480
assert.strictEqual(commandService.lastCommandId, 'chat.internal.codeReview.run');
481
const args = commandService.lastArgs?.[0] as { files: { currentUri: URI; baseUri?: URI }[] };
482
assert.strictEqual(args.files.length, 2);
483
assert.strictEqual(args.files[0].currentUri.toString(), fileA.toString());
484
assert.strictEqual(args.files[0].baseUri?.toString(), fileB.toString());
485
assert.strictEqual(args.files[1].currentUri.toString(), fileB.toString());
486
assert.strictEqual(args.files[1].baseUri, undefined);
487
});
488
489
test('requestReview with success populates comments', async () => {
490
commandService.result = {
491
type: 'success',
492
comments: [
493
{
494
uri: fileA,
495
range: new Range(1, 1, 5, 1),
496
body: 'Bug found',
497
kind: 'bug',
498
severity: 'high',
499
},
500
{
501
uri: fileB,
502
range: new Range(10, 1, 15, 1),
503
body: 'Style issue',
504
kind: 'style',
505
severity: 'low',
506
},
507
],
508
};
509
510
service.requestReview(session, 'v1', [{ currentUri: fileA }, { currentUri: fileB }]);
511
await tick();
512
513
const state = service.getReviewState(session).get();
514
assert.strictEqual(state.kind, CodeReviewStateKind.Result);
515
if (state.kind === CodeReviewStateKind.Result) {
516
assert.strictEqual(state.version, 'v1');
517
assert.strictEqual(state.reviewCount, 1);
518
assert.strictEqual(state.comments.length, 2);
519
assert.strictEqual(state.comments[0].body, 'Bug found');
520
assert.strictEqual(state.comments[0].kind, 'bug');
521
assert.strictEqual(state.comments[0].severity, 'high');
522
assert.strictEqual(state.comments[0].uri.toString(), fileA.toString());
523
assert.strictEqual(state.comments[1].body, 'Style issue');
524
}
525
});
526
527
test('requestReview with error transitions to error state', async () => {
528
commandService.result = { type: 'error', reason: 'Auth failed' };
529
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
530
531
await tick();
532
533
const state = service.getReviewState(session).get();
534
assert.strictEqual(state.kind, CodeReviewStateKind.Error);
535
if (state.kind === CodeReviewStateKind.Error) {
536
assert.strictEqual(state.version, 'v1');
537
assert.strictEqual(state.reviewCount, 1);
538
assert.strictEqual(state.reason, 'Auth failed');
539
}
540
});
541
542
test('requestReview with cancelled result transitions to idle', async () => {
543
commandService.result = { type: 'cancelled' };
544
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
545
546
await tick();
547
548
const state = service.getReviewState(session).get();
549
assert.strictEqual(state.kind, CodeReviewStateKind.Idle);
550
});
551
552
test('requestReview with undefined result transitions to idle', async () => {
553
commandService.result = undefined;
554
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
555
556
await tick();
557
558
const state = service.getReviewState(session).get();
559
assert.strictEqual(state.kind, CodeReviewStateKind.Idle);
560
});
561
562
test('requestReview with thrown error transitions to error state', async () => {
563
commandService.deferNextExecution();
564
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
565
commandService.rejectExecution(new Error('Network error'));
566
567
await tick();
568
569
const state = service.getReviewState(session).get();
570
assert.strictEqual(state.kind, CodeReviewStateKind.Error);
571
if (state.kind === CodeReviewStateKind.Error) {
572
assert.strictEqual(state.reviewCount, 1);
573
assert.ok(state.reason.includes('Network error'));
574
}
575
});
576
577
test('requestReview is a no-op when loading for the same version', () => {
578
commandService.deferNextExecution();
579
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
580
581
// Attempt to request again for the same version
582
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
583
584
// Should still be loading (not re-triggered)
585
const state = service.getReviewState(session).get();
586
assert.strictEqual(state.kind, CodeReviewStateKind.Loading);
587
588
commandService.resolveExecution({ type: 'success', comments: [] });
589
});
590
591
test('requestReview is a no-op when unresolved comments exist for the same version', async () => {
592
commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment' }] };
593
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
594
await tick();
595
596
// Attempt to request again
597
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
598
599
// Should still have the result
600
const state = service.getReviewState(session).get();
601
assert.strictEqual(state.kind, CodeReviewStateKind.Result);
602
if (state.kind === CodeReviewStateKind.Result) {
603
assert.strictEqual(state.comments.length, 1);
604
}
605
});
606
607
test('requestReview reruns when previous result for the same version had no comments', async () => {
608
commandService.result = { type: 'success', comments: [] };
609
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
610
await tick();
611
612
commandService.deferNextExecution();
613
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
614
615
const state = service.getReviewState(session).get();
616
assert.strictEqual(state.kind, CodeReviewStateKind.Loading);
617
618
commandService.resolveExecution({ type: 'success', comments: [] });
619
await tick();
620
});
621
622
test('requestReview reruns when all comments for the same version were removed', async () => {
623
commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment' }] };
624
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
625
await tick();
626
627
const initialState = service.getReviewState(session).get();
628
assert.strictEqual(initialState.kind, CodeReviewStateKind.Result);
629
if (initialState.kind !== CodeReviewStateKind.Result) {
630
return;
631
}
632
633
service.removeComment(session, initialState.comments[0].id);
634
635
commandService.deferNextExecution();
636
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
637
638
const state = service.getReviewState(session).get();
639
assert.strictEqual(state.kind, CodeReviewStateKind.Loading);
640
641
commandService.resolveExecution({ type: 'success', comments: [] });
642
await tick();
643
});
644
645
test('requestReview is a no-op after five reviews for the same version', async () => {
646
commandService.result = { type: 'success', comments: [] };
647
648
for (let i = 0; i < 5; i++) {
649
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
650
await tick();
651
}
652
653
const stateBefore = service.getReviewState(session).get();
654
assert.strictEqual(stateBefore.kind, CodeReviewStateKind.Result);
655
if (stateBefore.kind === CodeReviewStateKind.Result) {
656
assert.strictEqual(stateBefore.reviewCount, 5);
657
}
658
659
commandService.deferNextExecution();
660
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
661
662
const stateAfter = service.getReviewState(session).get();
663
assert.strictEqual(stateAfter.kind, CodeReviewStateKind.Result);
664
if (stateAfter.kind === CodeReviewStateKind.Result) {
665
assert.strictEqual(stateAfter.reviewCount, 5);
666
}
667
});
668
669
test('requestReview for a new version replaces loading state', async () => {
670
// Start v1 review — it will complete immediately with empty result
671
commandService.result = { type: 'success', comments: [] };
672
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
673
await tick();
674
675
assert.strictEqual(service.hasReview(session, 'v1'), true);
676
677
// Request v2 — since v1 is a different version, it should proceed
678
commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'v2 comment' }] };
679
service.requestReview(session, 'v2', [{ currentUri: fileA }]);
680
await tick();
681
682
const state = service.getReviewState(session).get();
683
assert.strictEqual(state.kind, CodeReviewStateKind.Result);
684
if (state.kind === CodeReviewStateKind.Result) {
685
assert.strictEqual(state.version, 'v2');
686
assert.strictEqual(state.comments.length, 1);
687
assert.strictEqual(state.comments[0].body, 'v2 comment');
688
}
689
690
// v1 is no longer valid
691
assert.strictEqual(service.hasReview(session, 'v1'), false);
692
});
693
694
// --- removeComment ---
695
696
test('removeComment removes a specific comment', async () => {
697
commandService.result = {
698
type: 'success',
699
comments: [
700
{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment1' },
701
{ uri: fileA, range: new Range(5, 1, 5, 1), body: 'comment2' },
702
{ uri: fileB, range: new Range(10, 1, 10, 1), body: 'comment3' },
703
],
704
};
705
706
service.requestReview(session, 'v1', [{ currentUri: fileA }, { currentUri: fileB }]);
707
await tick();
708
709
const state = service.getReviewState(session).get();
710
assert.strictEqual(state.kind, CodeReviewStateKind.Result);
711
if (state.kind !== CodeReviewStateKind.Result) { return; }
712
713
const commentToRemove = state.comments[1];
714
service.removeComment(session, commentToRemove.id);
715
716
const newState = service.getReviewState(session).get();
717
assert.strictEqual(newState.kind, CodeReviewStateKind.Result);
718
if (newState.kind === CodeReviewStateKind.Result) {
719
assert.strictEqual(newState.comments.length, 2);
720
assert.strictEqual(newState.comments[0].body, 'comment1');
721
assert.strictEqual(newState.comments[1].body, 'comment3');
722
}
723
});
724
725
test('removeComment is a no-op for unknown comment id', async () => {
726
commandService.result = {
727
type: 'success',
728
comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment1' }],
729
};
730
731
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
732
await tick();
733
734
service.removeComment(session, 'nonexistent-id');
735
736
const state = service.getReviewState(session).get();
737
if (state.kind === CodeReviewStateKind.Result) {
738
assert.strictEqual(state.comments.length, 1);
739
}
740
});
741
742
test('removeComment is a no-op when no review exists', () => {
743
// Should not throw
744
service.removeComment(session, 'some-id');
745
const state = service.getReviewState(session).get();
746
assert.strictEqual(state.kind, CodeReviewStateKind.Idle);
747
});
748
749
test('removeComment is a no-op when state is not result', () => {
750
commandService.deferNextExecution();
751
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
752
753
// State is loading — removeComment should be ignored
754
service.removeComment(session, 'some-id');
755
756
const state = service.getReviewState(session).get();
757
assert.strictEqual(state.kind, CodeReviewStateKind.Loading);
758
759
commandService.resolveExecution({ type: 'success', comments: [] });
760
});
761
762
test('removeComment preserves version in result', async () => {
763
commandService.result = {
764
type: 'success',
765
comments: [
766
{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment1' },
767
{ uri: fileA, range: new Range(5, 1, 5, 1), body: 'comment2' },
768
],
769
};
770
771
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
772
await tick();
773
774
const state = service.getReviewState(session).get();
775
if (state.kind !== CodeReviewStateKind.Result) { return; }
776
777
service.removeComment(session, state.comments[0].id);
778
779
const newState = service.getReviewState(session).get();
780
if (newState.kind === CodeReviewStateKind.Result) {
781
assert.strictEqual(newState.version, 'v1');
782
}
783
});
784
785
// --- dismissReview ---
786
787
test('dismissReview resets to idle', async () => {
788
commandService.result = { type: 'success', comments: [] };
789
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
790
await tick();
791
792
assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Result);
793
794
service.dismissReview(session);
795
796
assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle);
797
});
798
799
test('dismissReview while loading resets to idle', () => {
800
commandService.deferNextExecution();
801
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
802
803
assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Loading);
804
805
service.dismissReview(session);
806
807
assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle);
808
809
// Resolve the pending command — should be ignored since dismissed
810
commandService.resolveExecution({ type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'late' }] });
811
});
812
813
test('dismissReview is a no-op when no data exists', () => {
814
// Should not throw
815
service.dismissReview(session);
816
});
817
818
test('hasReview returns false after dismissReview', async () => {
819
commandService.result = { type: 'success', comments: [] };
820
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
821
await tick();
822
823
assert.strictEqual(service.hasReview(session, 'v1'), true);
824
825
service.dismissReview(session);
826
827
assert.strictEqual(service.hasReview(session, 'v1'), false);
828
});
829
830
// --- Isolation between sessions ---
831
832
test('different sessions are independent', async () => {
833
const session2 = URI.parse('test://session/2');
834
835
commandService.result = {
836
type: 'success',
837
comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'session1 comment' }],
838
};
839
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
840
await tick();
841
842
commandService.result = {
843
type: 'success',
844
comments: [{ uri: fileB, range: new Range(2, 1, 2, 1), body: 'session2 comment' }],
845
};
846
service.requestReview(session2, 'v2', [{ currentUri: fileB }]);
847
await tick();
848
849
const state1 = service.getReviewState(session).get();
850
const state2 = service.getReviewState(session2).get();
851
852
assert.strictEqual(state1.kind, CodeReviewStateKind.Result);
853
assert.strictEqual(state2.kind, CodeReviewStateKind.Result);
854
855
if (state1.kind === CodeReviewStateKind.Result && state2.kind === CodeReviewStateKind.Result) {
856
assert.strictEqual(state1.comments[0].body, 'session1 comment');
857
assert.strictEqual(state2.comments[0].body, 'session2 comment');
858
}
859
860
// Dismissing session1 doesn't affect session2
861
service.dismissReview(session);
862
assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle);
863
assert.strictEqual(service.getReviewState(session2).get().kind, CodeReviewStateKind.Result);
864
});
865
866
// --- Comment parsing ---
867
868
test('comments with string URIs are parsed correctly', async () => {
869
commandService.result = {
870
type: 'success',
871
comments: [
872
{
873
uri: 'file:///parsed.ts',
874
range: new Range(1, 1, 1, 1),
875
body: 'parsed comment',
876
},
877
],
878
};
879
880
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
881
await tick();
882
883
const state = service.getReviewState(session).get();
884
if (state.kind === CodeReviewStateKind.Result) {
885
assert.strictEqual(state.comments[0].uri.toString(), 'file:///parsed.ts');
886
}
887
});
888
889
test('comments with missing optional fields get defaults', async () => {
890
commandService.result = {
891
type: 'success',
892
comments: [
893
{
894
uri: fileA,
895
range: new Range(1, 1, 1, 1),
896
// body, kind, severity omitted
897
},
898
],
899
};
900
901
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
902
await tick();
903
904
const state = service.getReviewState(session).get();
905
if (state.kind === CodeReviewStateKind.Result) {
906
assert.strictEqual(state.comments[0].body, '');
907
assert.strictEqual(state.comments[0].kind, '');
908
assert.strictEqual(state.comments[0].severity, '');
909
assert.strictEqual(state.comments[0].suggestion, undefined);
910
}
911
});
912
913
test('comments normalize VS Code API style ranges', async () => {
914
commandService.result = {
915
type: 'success',
916
comments: [
917
{
918
uri: fileA,
919
range: {
920
start: { line: 4, character: 2 },
921
end: { line: 6, character: 5 },
922
},
923
body: 'normalized comment',
924
suggestion: {
925
edits: [
926
{
927
range: {
928
start: { line: 8, character: 1 },
929
end: { line: 8, character: 9 },
930
},
931
oldText: 'let value',
932
newText: 'const value',
933
},
934
],
935
},
936
},
937
],
938
};
939
940
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
941
await tick();
942
943
const state = service.getReviewState(session).get();
944
assert.strictEqual(state.kind, CodeReviewStateKind.Result);
945
if (state.kind === CodeReviewStateKind.Result) {
946
assert.deepStrictEqual(state.comments[0].range, new Range(5, 3, 7, 6));
947
assert.deepStrictEqual(state.comments[0].suggestion?.edits[0].range, new Range(9, 2, 9, 10));
948
}
949
});
950
951
test('comments normalize serialized URIs and tuple ranges from API payloads', async () => {
952
const 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')));
953
954
commandService.result = {
955
type: 'success',
956
comments: [
957
{
958
uri: serializedUri,
959
range: [
960
{ line: 72, character: 2 },
961
{ line: 72, character: 3 },
962
],
963
body: 'tuple range comment',
964
kind: 'bug',
965
severity: 'medium',
966
},
967
],
968
};
969
970
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
971
await tick();
972
973
const state = service.getReviewState(session).get();
974
assert.strictEqual(state.kind, CodeReviewStateKind.Result);
975
if (state.kind === CodeReviewStateKind.Result) {
976
assert.strictEqual(state.comments[0].uri.toString(), URI.revive(serializedUri).toString());
977
assert.deepStrictEqual(state.comments[0].range, new Range(73, 3, 73, 4));
978
}
979
});
980
981
test('each comment gets a unique id', async () => {
982
commandService.result = {
983
type: 'success',
984
comments: [
985
{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'a' },
986
{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'b' },
987
],
988
};
989
990
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
991
await tick();
992
993
const state = service.getReviewState(session).get();
994
if (state.kind === CodeReviewStateKind.Result) {
995
assert.notStrictEqual(state.comments[0].id, state.comments[1].id);
996
}
997
});
998
999
// --- Observable reactivity ---
1000
1001
test('observable fires on state transitions', async () => {
1002
const states: string[] = [];
1003
const obs = service.getReviewState(session);
1004
1005
// Collect initial state
1006
states.push(obs.get().kind);
1007
1008
commandService.deferNextExecution();
1009
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
1010
states.push(obs.get().kind);
1011
1012
commandService.resolveExecution({ type: 'success', comments: [] });
1013
await tick();
1014
states.push(obs.get().kind);
1015
1016
service.dismissReview(session);
1017
states.push(obs.get().kind);
1018
1019
assert.deepStrictEqual(states, [
1020
CodeReviewStateKind.Idle,
1021
CodeReviewStateKind.Loading,
1022
CodeReviewStateKind.Result,
1023
CodeReviewStateKind.Idle,
1024
]);
1025
});
1026
1027
// --- Storage persistence ---
1028
1029
test('review results are persisted to storage', async () => {
1030
commandService.result = {
1031
type: 'success',
1032
comments: [{ uri: fileA, range: new Range(1, 1, 5, 1), body: 'Persisted comment', kind: 'bug', severity: 'high' }],
1033
};
1034
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
1035
await tick();
1036
1037
const raw = storageService.get('codeReview.reviews', StorageScope.WORKSPACE);
1038
assert.ok(raw, 'Storage should contain review data');
1039
const stored = JSON.parse(raw!);
1040
const reviewData = stored[session.toString()];
1041
assert.ok(reviewData);
1042
assert.strictEqual(reviewData.version, 'v1');
1043
assert.strictEqual(reviewData.reviewCount, 1);
1044
assert.strictEqual(reviewData.comments.length, 1);
1045
assert.strictEqual(reviewData.comments[0].body, 'Persisted comment');
1046
});
1047
1048
test('reviews are restored from storage on service creation', async () => {
1049
commandService.result = {
1050
type: 'success',
1051
comments: [{ uri: fileA, range: new Range(1, 1, 5, 1), body: 'Restored comment', kind: 'bug', severity: 'high' }],
1052
};
1053
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
1054
await tick();
1055
1056
// Create a second service with the same storage
1057
const service2 = store.add(instantiationService.createInstance(CodeReviewService));
1058
const state = service2.getReviewState(session).get();
1059
assert.strictEqual(state.kind, CodeReviewStateKind.Result);
1060
if (state.kind === CodeReviewStateKind.Result) {
1061
assert.strictEqual(state.version, 'v1');
1062
assert.strictEqual(state.reviewCount, 1);
1063
assert.strictEqual(state.comments.length, 1);
1064
assert.strictEqual(state.comments[0].body, 'Restored comment');
1065
assert.strictEqual(state.comments[0].uri.toString(), fileA.toString());
1066
assert.deepStrictEqual(state.comments[0].range, { startLineNumber: 1, startColumn: 1, endLineNumber: 5, endColumn: 1 });
1067
}
1068
});
1069
1070
test('suggestions are persisted and restored correctly', async () => {
1071
commandService.result = {
1072
type: 'success',
1073
comments: [{
1074
uri: fileA,
1075
range: new Range(1, 1, 5, 1),
1076
body: 'suggestion comment',
1077
suggestion: {
1078
edits: [{
1079
range: new Range(2, 1, 3, 10),
1080
oldText: 'let x = 1;',
1081
newText: 'const x = 1;',
1082
}],
1083
},
1084
}],
1085
};
1086
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
1087
await tick();
1088
1089
const service2 = store.add(instantiationService.createInstance(CodeReviewService));
1090
const state = service2.getReviewState(session).get();
1091
assert.strictEqual(state.kind, CodeReviewStateKind.Result);
1092
if (state.kind === CodeReviewStateKind.Result) {
1093
assert.strictEqual(state.comments[0].suggestion?.edits.length, 1);
1094
assert.strictEqual(state.comments[0].suggestion?.edits[0].oldText, 'let x = 1;');
1095
assert.strictEqual(state.comments[0].suggestion?.edits[0].newText, 'const x = 1;');
1096
}
1097
});
1098
1099
test('removeComment updates storage', async () => {
1100
commandService.result = {
1101
type: 'success',
1102
comments: [
1103
{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment1' },
1104
{ uri: fileA, range: new Range(5, 1, 5, 1), body: 'comment2' },
1105
],
1106
};
1107
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
1108
await tick();
1109
1110
const state = service.getReviewState(session).get();
1111
if (state.kind !== CodeReviewStateKind.Result) { return; }
1112
1113
service.removeComment(session, state.comments[0].id);
1114
1115
const raw = storageService.get('codeReview.reviews', StorageScope.WORKSPACE);
1116
const stored = JSON.parse(raw!);
1117
assert.strictEqual(stored[session.toString()].comments.length, 1);
1118
assert.strictEqual(stored[session.toString()].comments[0].body, 'comment2');
1119
});
1120
1121
test('dismissReview removes session from storage', async () => {
1122
commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'c' }] };
1123
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
1124
await tick();
1125
1126
assert.ok(storageService.get('codeReview.reviews', StorageScope.WORKSPACE));
1127
1128
service.dismissReview(session);
1129
1130
assert.strictEqual(storageService.get('codeReview.reviews', StorageScope.WORKSPACE), undefined);
1131
});
1132
1133
test('corrupted storage is handled gracefully', () => {
1134
storageService.store('codeReview.reviews', 'not-valid-json{{{', StorageScope.WORKSPACE, StorageTarget.MACHINE);
1135
1136
const service2 = store.add(instantiationService.createInstance(CodeReviewService));
1137
const state = service2.getReviewState(session).get();
1138
assert.strictEqual(state.kind, CodeReviewStateKind.Idle);
1139
});
1140
1141
// --- Session lifecycle cleanup ---
1142
1143
test('archived session reviews are cleaned up', async () => {
1144
commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment' }] };
1145
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
1146
await tick();
1147
1148
assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Result);
1149
1150
const mockSession = sessionsManagement.addSession(session, undefined, true);
1151
sessionsManagement.fireSessionsChanged({ changed: [mockSession] });
1152
1153
assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle);
1154
assert.strictEqual(storageService.get('codeReview.reviews', StorageScope.WORKSPACE), undefined);
1155
});
1156
1157
test('non-archived session change does not clean up review', async () => {
1158
const changes: IChatSessionFileChange2[] = [
1159
{ uri: fileA, modifiedUri: fileA, insertions: 1, deletions: 0 },
1160
];
1161
const files = getCodeReviewFilesFromSessionChanges(changes);
1162
const version = getCodeReviewVersion(files);
1163
1164
commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment' }] };
1165
service.requestReview(session, version, files);
1166
await tick();
1167
1168
const mockSession = sessionsManagement.addSession(session, changes, false);
1169
sessionsManagement.fireSessionsChanged({ changed: [mockSession] });
1170
1171
assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Result);
1172
});
1173
1174
test('session with changed version has review cleaned up', async () => {
1175
const changes: IChatSessionFileChange2[] = [
1176
{ uri: fileA, modifiedUri: fileA, insertions: 1, deletions: 0 },
1177
];
1178
sessionsManagement.addSession(session, changes);
1179
1180
const files = getCodeReviewFilesFromSessionChanges(changes);
1181
const version = getCodeReviewVersion(files);
1182
1183
commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'stale comment' }] };
1184
service.requestReview(session, version, files);
1185
await tick();
1186
1187
assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Result);
1188
1189
const newChanges: IChatSessionFileChange2[] = [
1190
{ uri: fileA, modifiedUri: fileA, insertions: 1, deletions: 0 },
1191
{ uri: fileB, modifiedUri: fileB, insertions: 2, deletions: 0 },
1192
];
1193
sessionsManagement.updateSessionChanges(session, newChanges);
1194
sessionsManagement.fireSessionsChanged();
1195
1196
assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle);
1197
assert.strictEqual(storageService.get('codeReview.reviews', StorageScope.WORKSPACE), undefined);
1198
});
1199
1200
test('session that no longer exists has review cleaned up', async () => {
1201
commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'orphaned comment' }] };
1202
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
1203
await tick();
1204
1205
assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Result);
1206
1207
sessionsManagement.fireSessionsChanged();
1208
1209
assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle);
1210
});
1211
1212
test('session with no changes has review cleaned up', async () => {
1213
sessionsManagement.addSession(session, [
1214
{ uri: fileA, modifiedUri: fileA, insertions: 1, deletions: 0 },
1215
]);
1216
1217
commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment' }] };
1218
service.requestReview(session, 'v1', [{ currentUri: fileA }]);
1219
await tick();
1220
1221
sessionsManagement.updateSessionChanges(session, undefined);
1222
sessionsManagement.fireSessionsChanged();
1223
1224
assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle);
1225
});
1226
1227
test('session with matching version keeps review intact', async () => {
1228
const changes: IChatSessionFileChange2[] = [
1229
{ uri: fileA, modifiedUri: fileA, insertions: 1, deletions: 0 },
1230
];
1231
sessionsManagement.addSession(session, changes);
1232
1233
const files = getCodeReviewFilesFromSessionChanges(changes);
1234
const version = getCodeReviewVersion(files);
1235
1236
commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'valid comment' }] };
1237
service.requestReview(session, version, files);
1238
await tick();
1239
1240
sessionsManagement.fireSessionsChanged();
1241
1242
const state = service.getReviewState(session).get();
1243
assert.strictEqual(state.kind, CodeReviewStateKind.Result);
1244
if (state.kind === CodeReviewStateKind.Result) {
1245
assert.strictEqual(state.comments[0].body, 'valid comment');
1246
}
1247
});
1248
});
1249
1250
function makeGitHubInfo(prNumber = 1): IGitHubInfo {
1251
return {
1252
owner: 'owner',
1253
repo: 'repo',
1254
pullRequest: {
1255
number: prNumber,
1256
uri: URI.parse(`https://github.com/owner/repo/pull/${prNumber}`),
1257
},
1258
};
1259
}
1260
1261
function makePRThread(id: string, path: string): IGitHubPullRequestReviewThread {
1262
return {
1263
id,
1264
isResolved: false,
1265
path,
1266
line: 10,
1267
comments: [makePRComment(100, `Comment on ${path}`, id)],
1268
};
1269
}
1270
1271
function makePRComment(id: number, body: string, threadId: string = String(id)): IGitHubPRComment {
1272
return {
1273
id,
1274
body,
1275
author: { login: 'reviewer', avatarUrl: '' },
1276
createdAt: '2024-01-01T00:00:00Z',
1277
updatedAt: '2024-01-01T00:00:00Z',
1278
path: undefined,
1279
line: undefined,
1280
threadId,
1281
inReplyToId: undefined,
1282
};
1283
}
1284
1285
function tick(): Promise<void> {
1286
return new Promise(resolve => setTimeout(resolve, 0));
1287
}
1288
1289