Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.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 { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';6import type * as vscode from 'vscode';7// eslint-disable-next-line no-duplicate-imports8import * as vscodeShim from 'vscode';9import { IRunCommandExecutionService } from '../../../../platform/commands/common/runCommandExecutionService';10import { DefaultsOnlyConfigurationService } from '../../../../platform/configuration/common/defaultsOnlyConfigurationService';11import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService';12import { IGitService, RepoContext } from '../../../../platform/git/common/gitService';13import { PullRequestSearchItem } from '../../../../platform/github/common/githubAPI';14import { IOctoKitService } from '../../../../platform/github/common/githubService';15import { ILogService } from '../../../../platform/log/common/logService';16import { NullWorkspaceService } from '../../../../platform/workspace/common/workspaceService';17import { mock } from '../../../../util/common/test/simpleMock';18import { CancellationToken } from '../../../../util/vs/base/common/cancellation';19import { Event } from '../../../../util/vs/base/common/event';20import { URI } from '../../../../util/vs/base/common/uri';21import { IChatSessionMetadataStore } from '../../common/chatSessionMetadataStore';22import { IChatSessionWorkspaceFolderService } from '../../common/chatSessionWorkspaceFolderService';23import { ChatSessionWorktreeProperties, IChatSessionWorktreeService } from '../../common/chatSessionWorktreeService';24import { IFolderRepositoryManager, IsolationMode } from '../../common/folderRepositoryManager';25import { emptyWorkspaceInfo } from '../../common/workspaceInfo';26import { ICustomSessionTitleService } from '../../copilotcli/common/customSessionTitleService';27import { ICopilotCLISession } from '../../copilotcli/node/copilotcliSession';28import { ICopilotCLISessionItem, ICopilotCLISessionService } from '../../copilotcli/node/copilotcliSessionService';29import { ICopilotCLISessionTracker } from '../../copilotcli/vscode-node/copilotCLISessionTracker';30import { CopilotCLIChatSessionContentProvider, resolveBranchLockState, resolveBranchSelection, resolveIsolationSelection, resolveSessionDirsForTerminal } from '../copilotCLIChatSessions';31import { PullRequestDetectionService } from '../pullRequestDetectionService';32import { ISessionOptionGroupBuilder } from '../sessionOptionGroupBuilder';33vi.mock('../copilotCLIShim.ps1', () => ({ default: '# mock powershell script' }));3435beforeAll(() => {36(vscodeShim as Record<string, unknown>).chat = {37createChatSessionItemController: () => ({38id: 'copilotcli',39items: {40get: () => undefined,41add: () => { },42delete: () => { },43replace: () => { },44[Symbol.iterator]: function* () { },45forEach: () => { },46},47createChatSessionItem: (resource: vscode.Uri, label: string): vscode.ChatSessionItem => ({ resource, label }),48dispose: () => { },49}),50};51(vscodeShim as Record<string, unknown>).workspace = {52...((vscodeShim as Record<string, unknown>).workspace as object),53workspaceFolders: [],54isAgentSessionsWorkspace: false,55isResourceTrusted: async () => true,56};57});5859class TestSessionService extends mock<ICopilotCLISessionService>() {60declare readonly _serviceBrand: undefined;61override onDidChangeSessions = Event.None;62override onDidDeleteSession = Event.None;63override onDidChangeSession = Event.None;64override onDidCreateSession = Event.None;65override getSessionWorkingDirectory = vi.fn(() => undefined);66override getSessionItem = vi.fn(async () => undefined);67override getAllSessions = vi.fn(async () => [] as ICopilotCLISessionItem[]);68override createNewSessionId = vi.fn(() => 'new-session');69override isNewSessionId = vi.fn(() => false);70override deleteSession = vi.fn(async () => { });71override renameSession = vi.fn(async () => { });72override getSessionTitle = vi.fn(async () => '');73override getSession = vi.fn(async () => ({74object: {75sessionId: 'session-1',76workspace: emptyWorkspaceInfo,77getChatHistory: async () => [],78},79dispose: () => { },80} as unknown as { object: ICopilotCLISession; dispose(): void }));81override createSession = vi.fn(async () => {82throw new Error('Not implemented');83});84override forkSession = vi.fn(async () => 'forked-session');85override tryGetPartialSessionHistory = vi.fn(async () => undefined);86override getChatHistory = vi.fn(async () => []);87}8889class TestWorktreeService extends mock<IChatSessionWorktreeService>() {90declare readonly _serviceBrand: undefined;91override getWorktreeProperties = vi.fn(async (_sessionId: string | vscode.Uri): Promise<ChatSessionWorktreeProperties | undefined> => undefined);92override setWorktreeProperties = vi.fn(async () => { });93override getWorktreeChanges = vi.fn(async () => []);94override hasCachedChanges = vi.fn(async () => false);95override onDidChangeWorktreeChanges = Event.None;96}9798class TestWorkspaceFolderService extends mock<IChatSessionWorkspaceFolderService>() {99declare readonly _serviceBrand: undefined;100override getWorkspaceChanges = vi.fn(async () => []);101override hasCachedChanges = vi.fn(async () => false);102override onDidChangeWorkspaceFolderChanges = Event.None;103}104105class TestFolderRepositoryManager extends mock<IFolderRepositoryManager>() {106declare readonly _serviceBrand: undefined;107override setNewSessionFolder = vi.fn();108override deleteNewSessionFolder = vi.fn();109override getFolderRepository = vi.fn(async () => ({110folder: undefined,111repository: undefined,112worktree: undefined,113worktreeProperties: undefined,114trusted: undefined,115}));116override initializeFolderRepository = vi.fn(async () => ({117folder: undefined,118repository: undefined,119worktree: undefined,120worktreeProperties: undefined,121trusted: undefined,122}));123override getRepositoryInfo = vi.fn(async () => ({ repository: undefined, headBranchName: undefined }));124override getFolderMRU = vi.fn(async () => []);125}126127class TestGitService extends mock<IGitService>() {128declare readonly _serviceBrand: undefined;129override onDidOpenRepository = Event.None;130override onDidCloseRepository = Event.None;131override onDidFinishInitialization = Event.None;132override activeRepository = { get: () => undefined } as IGitService['activeRepository'];133override repositories: RepoContext[] = [];134135setRepo(repo: RepoContext): void {136this.repositories = [repo];137}138139override getRepository = vi.fn(async () => this.repositories[0]);140}141142class TestOctoKitService extends mock<IOctoKitService>() {143declare readonly _serviceBrand: undefined;144override findPullRequestByHeadBranch = vi.fn(async (): Promise<PullRequestSearchItem | undefined> => undefined);145}146147class TestRunCommandExecutionService extends mock<IRunCommandExecutionService>() {148declare readonly _serviceBrand: undefined;149override executeCommand = vi.fn(async () => undefined);150}151152class TestCustomSessionTitleService extends mock<ICustomSessionTitleService>() {153declare readonly _serviceBrand: undefined;154override getCustomSessionTitle = vi.fn(async () => 'Session Title');155override setCustomSessionTitle = vi.fn(async () => { });156override generateSessionTitle = vi.fn(async () => undefined);157}158159function createProvider() {160const sessionService = new TestSessionService();161const worktreeService = new TestWorktreeService();162const metadataStore = new class extends mock<IChatSessionMetadataStore>() {163override getRequestDetails = vi.fn(async () => []);164override getRepositoryProperties = vi.fn(async () => undefined);165override getSessionParentId = vi.fn(async () => undefined);166};167const gitService = new TestGitService();168const folderRepositoryManager = new TestFolderRepositoryManager();169const configurationService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService());170const customSessionTitleService = new TestCustomSessionTitleService();171const commandExecutionService = new TestRunCommandExecutionService();172const workspaceFolderService = new TestWorkspaceFolderService();173const octoKitService = new TestOctoKitService();174const logService = new class extends mock<ILogService>() {175declare readonly _serviceBrand: undefined;176override trace = vi.fn();177override debug = vi.fn();178override info = vi.fn();179override error = vi.fn();180}();181182const prDetectionService = new PullRequestDetectionService(183worktreeService,184gitService,185octoKitService,186logService,187);188const optionGroupBuilder = new class extends mock<ISessionOptionGroupBuilder>() {189declare readonly _serviceBrand: undefined;190override provideChatSessionProviderOptionGroups = vi.fn(async () => []);191override buildBranchOptionGroup = vi.fn(() => undefined);192override handleInputStateChange = vi.fn(async () => { });193override rebuildInputState = vi.fn(async () => { });194override buildExistingSessionInputStateGroups = vi.fn(async () => []);195override getBranchOptionItemsForRepository = vi.fn(async () => []);196override getRepositoryOptionItems = vi.fn(() => []);197}();198const provider = new CopilotCLIChatSessionContentProvider(199sessionService,200worktreeService,201folderRepositoryManager,202configurationService,203customSessionTitleService,204commandExecutionService,205logService,206prDetectionService,207optionGroupBuilder,208gitService,209workspaceFolderService,210metadataStore,211new NullWorkspaceService(),212worktreeService,213);214215return {216provider,217prDetectionService,218sessionService,219worktreeService,220gitService,221octoKitService,222};223}224225describe('CopilotCLIChatSessionContentProvider', () => {226beforeEach(() => {227vi.restoreAllMocks();228});229230it('triggers pull request detection when opening an existing session', async () => {231const { provider, prDetectionService } = createProvider();232const detectSpy = vi.spyOn(prDetectionService, 'detectPullRequest');233234await provider.provideChatSessionContent(235URI.from({ scheme: 'copilotcli', path: '/session-1' }),236CancellationToken.None,237);238239expect(detectSpy).toHaveBeenCalledWith('session-1');240});241242it('persists detected pull request url and state on session open', async () => {243const { prDetectionService, worktreeService, gitService, octoKitService } = createProvider();244const worktreeProperties: ChatSessionWorktreeProperties = {245version: 2,246baseCommit: 'abc123',247baseBranchName: 'main',248branchName: 'copilot/test-branch',249repositoryPath: '/repo',250worktreePath: '/worktree',251};252253worktreeService.getWorktreeProperties.mockResolvedValue(worktreeProperties);254gitService.setRepo({255rootUri: URI.file('/repo'),256kind: 'repository',257remotes: ['origin'],258remoteFetchUrls: ['https://github.com/testowner/testrepo.git'],259} as unknown as RepoContext);260octoKitService.findPullRequestByHeadBranch.mockResolvedValue({261id: 'pr-42',262number: 42,263title: 'Test PR',264url: 'https://github.com/testowner/testrepo/pull/42',265state: 'OPEN',266isDraft: false,267createdAt: '2026-01-01T00:00:00Z',268updatedAt: '2026-01-01T00:00:00Z',269author: { login: 'testowner' },270repository: { owner: { login: 'testowner' }, name: 'testrepo' },271additions: 1,272deletions: 0,273files: { totalCount: 1 },274fullDatabaseId: 42,275headRefOid: 'deadbeef',276headRefName: 'copilot/test-branch',277baseRefName: 'main',278body: '',279});280281prDetectionService.detectPullRequest('session-1');282283await vi.waitFor(() => expect(worktreeService.setWorktreeProperties).toHaveBeenCalledWith(284'session-1',285expect.objectContaining({286pullRequestUrl: 'https://github.com/testowner/testrepo/pull/42',287pullRequestState: 'open',288}),289));290});291292it('skips session-open detection for merged pull requests', async () => {293const { prDetectionService, worktreeService, octoKitService } = createProvider();294const mergedProperties: ChatSessionWorktreeProperties = {295version: 2,296baseCommit: 'abc123',297baseBranchName: 'main',298branchName: 'copilot/test-branch',299repositoryPath: '/repo',300worktreePath: '/worktree',301pullRequestState: 'merged',302};303304worktreeService.getWorktreeProperties.mockResolvedValue(mergedProperties);305306prDetectionService.detectPullRequest('session-1');307308await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());309expect(octoKitService.findPullRequestByHeadBranch).not.toHaveBeenCalled();310expect(worktreeService.setWorktreeProperties).not.toHaveBeenCalled();311});312});313314// ─── Re-exported helper function smoke tests ────────────────────315// Full test coverage lives in sessionOptionGroupBuilder.spec.ts;316// these just verify the re-exports are wired up correctly.317318describe('re-exported dropdown helpers', () => {319it('resolveBranchSelection is callable', () => {320const branches = [{ id: 'main', name: 'main' }];321expect(resolveBranchSelection(branches, 'main', undefined)?.id).toBe('main');322});323324it('resolveBranchLockState is callable', () => {325const result = resolveBranchLockState(false, undefined);326expect(result.locked).toBe(true);327});328329it('resolveIsolationSelection is callable', () => {330expect(resolveIsolationSelection(IsolationMode.Workspace, undefined)).toBe(IsolationMode.Workspace);331});332});333334// ─── resolveSessionDirsForTerminal ──────────────────────────────335336describe('resolveSessionDirsForTerminal', () => {337it('returns matching terminal sessions before non-matching ones', async () => {338const terminal = {} as vscode.Terminal;339const otherTerminal = {} as vscode.Terminal;340const tracker: ICopilotCLISessionTracker = {341_serviceBrand: undefined,342getSessionIds: () => ['session-a', 'session-b'],343getTerminal: vi.fn(async (id: string) => id === 'session-a' ? terminal : otherTerminal),344} as unknown as ICopilotCLISessionTracker;345346const dirs = await resolveSessionDirsForTerminal(tracker, terminal);347expect(dirs).toHaveLength(2);348// First dir should be for the matching session349expect(dirs[0].fsPath).toContain('session-a');350});351352it('returns empty array when no sessions exist', async () => {353const terminal = {} as vscode.Terminal;354const tracker: ICopilotCLISessionTracker = {355_serviceBrand: undefined,356getSessionIds: () => [],357getTerminal: vi.fn(async () => undefined),358} as unknown as ICopilotCLISessionTracker;359360const dirs = await resolveSessionDirsForTerminal(tracker, terminal);361expect(dirs).toHaveLength(0);362});363});364365// ─── Additional CopilotCLIChatSessionContentProvider tests ──────366367describe('CopilotCLIChatSessionContentProvider (additional)', () => {368beforeEach(() => {369vi.restoreAllMocks();370});371372it('toChatSessionItem maps session to chat session item', async () => {373const { provider } = createProvider();374const sessionItem: ICopilotCLISessionItem = {375id: 'session-1',376label: 'Test Session',377status: undefined,378workingDirectory: undefined,379} as unknown as ICopilotCLISessionItem;380381const item = await provider.toChatSessionItem(sessionItem);382expect(item.label).toBe('Test Session');383});384385it('does not call refreshSession when PR detection finds no update', async () => {386const { provider, prDetectionService, worktreeService } = createProvider();387const refreshSpy = vi.spyOn(provider, 'refreshSession').mockResolvedValue();388389// No worktree properties means no PR detection390worktreeService.getWorktreeProperties.mockResolvedValue(undefined);391392prDetectionService.detectPullRequest('session-1');393await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());394expect(refreshSpy).not.toHaveBeenCalled();395});396});397398399