Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLITerminalIntegration.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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';6import type { Terminal, TerminalOptions } from 'vscode';7import { IAuthenticationService } from '../../../../platform/authentication/common/authentication';8import { IEnvService } from '../../../../platform/env/common/envService';9import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';10import { MockAuthenticationService } from '../../../../platform/ignore/node/test/mockAuthenticationService';11import { ILogService } from '../../../../platform/log/common/logService';12import { NoopOTelService, resolveOTelConfig } from '../../../../platform/otel/common/index';13import { NullTelemetryService } from '../../../../platform/telemetry/common/nullTelemetryService';14import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry';15import { ITerminalService, NullTerminalService } from '../../../../platform/terminal/common/terminalService';16import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';17import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';1819// The .ps1 asset cannot be parsed by Vite's transform pipeline,20// so we need to tell Vite to treat .ps1 files as raw text via a mock21vi.mock('../copilotCLIShim.ps1', () => ({ default: '# mock powershell script' }));2223// Mock fs operations to avoid real filesystem access during tests24const { mockMkdir, mockWriteFile, mockCopyFile, mockChmod, mockStat } = vi.hoisted(() => ({25mockMkdir: vi.fn(async () => { }),26mockWriteFile: vi.fn(async () => { }),27mockCopyFile: vi.fn(async () => { }),28mockChmod: vi.fn(async () => { }),29mockStat: vi.fn(async () => ({ isFile: () => true })),30}));3132vi.mock('fs', () => ({33promises: {34mkdir: mockMkdir,35writeFile: mockWriteFile,36copyFile: mockCopyFile,37chmod: mockChmod,38stat: mockStat,39}40}));4142// Mock Python terminal service to avoid extension dependency43vi.mock('../copilotCLIPythonTerminalService', () => ({44PythonTerminalService: class {45createTerminal = vi.fn(async () => undefined);46}47}));4849// Mock terminal link provider to avoid pulling in unrelated notebook/proposed API dependencies50vi.mock('../copilotCLITerminalLinkProvider', () => ({51CopilotCLITerminalLinkProvider: class {52registerTerminal = vi.fn();53setSessionDir = vi.fn();54setSessionDirResolver = vi.fn();55},56}));5758vi.mock('../../../../platform/workspace/common/workspaceService', () => ({59IWorkspaceService: (() => {60const identifier = () => { };61return identifier;62})(),63}));6465import type { IConfigurationService } from '../../../../platform/configuration/common/configurationService';66import { PythonTerminalService } from '../copilotCLIPythonTerminalService';67import { CopilotCLITerminalIntegration } from '../copilotCLITerminalIntegration';6869interface MockTerminal extends Pick<Terminal, 'show' | 'sendText' | 'dispose'> {70show: ReturnType<typeof vi.fn>;71sendText: ReturnType<typeof vi.fn>;72dispose: ReturnType<typeof vi.fn>;73shellIntegration: undefined;74}7576class TestTerminalService extends NullTerminalService {77public mockTerminal: MockTerminal;78public createTerminalSpy: ReturnType<typeof vi.fn>;79public contributePathSpy: ReturnType<typeof vi.fn>;8081constructor() {82super();83this.mockTerminal = {84show: vi.fn(),85sendText: vi.fn(),86dispose: vi.fn(),87shellIntegration: undefined,88};89this.createTerminalSpy = vi.fn().mockReturnValue(this.mockTerminal);90this.contributePathSpy = vi.fn();91}9293override createTerminal(): Terminal {94return this.createTerminalSpy(...arguments) as Terminal;95}9697override contributePath(contributor: unknown, pathLocation: unknown, description?: unknown, prepend?: unknown): void {98this.contributePathSpy(contributor, pathLocation, description, prepend);99}100}101102class TestEnvService {103declare readonly _serviceBrand: undefined;104shell = 'zsh';105userHome = { fsPath: '/Users/testuser' };106OS = 2; // OperatingSystem.Macintosh107appRoot = '';108language = 'en';109uiKind = 1;110clipboard = { readText: async () => '', writeText: async () => { } };111getAppSpecificStorageUri() { return undefined; }112getEditorInfo() { return { name: 'test-editor', version: '1.0' }; }113}114115class TestExtensionContext {116declare readonly _serviceBrand: undefined;117globalStorageUri = { fsPath: '/tmp/test-global-storage' };118extension = { id: 'GitHub.copilot-chat' };119extensionUri = { fsPath: '/tmp/extensions/copilot-chat' };120extensionMode = 3; // ExtensionMode.Test121}122123class TestTelemetryService extends NullTelemetryService {124public readonly events: Array<{ name: string; properties: Record<string, string> }> = [];125override sendMSFTTelemetryEvent(name: string, properties: Record<string, string>): void {126this.events.push({ name, properties });127}128}129130const { mockWorkspaceGetConfiguration, mockRegisterTerminalProfileProvider, mockRegisterTerminalLinkProvider } = vi.hoisted(() => ({131mockWorkspaceGetConfiguration: vi.fn(),132mockRegisterTerminalProfileProvider: vi.fn(() => ({ dispose: () => { } })),133mockRegisterTerminalLinkProvider: vi.fn(() => ({ dispose: () => { } })),134}));135136vi.mock('vscode', async (importOriginal) => {137const actual = await importOriginal() as Record<string, unknown>;138return {139...actual,140workspace: {141getConfiguration: mockWorkspaceGetConfiguration,142},143window: {144registerTerminalProfileProvider: mockRegisterTerminalProfileProvider,145registerTerminalLinkProvider: mockRegisterTerminalLinkProvider,146},147TerminalLocation: { Panel: 1, Editor: 2 },148ViewColumn: { Active: -1, Beside: -2 },149ThemeIcon: class ThemeIcon {150constructor(public readonly id: string) { }151},152TerminalProfile: class TerminalProfile {153constructor(public readonly options: TerminalOptions) { }154},155Range: class Range {156constructor(public startLine: number, public startCharacter: number, public endLine: number, public endCharacter: number) { }157},158Uri: {159joinPath: (base: { fsPath: string; scheme: string }, ...segments: string[]) => ({ fsPath: [base.fsPath, ...segments].join('/'), scheme: base.scheme }),160file: (path: string) => ({ fsPath: path, scheme: 'file' }),161},162};163});164165function setupTerminalConfig(defaultProfile: string | undefined, profiles: Record<string, { path: string | string[]; args?: string[] }> | undefined) {166mockWorkspaceGetConfiguration.mockImplementation((section: string) => ({167get: (key: string) => {168if (key.startsWith('integrated.defaultProfile.')) {169return defaultProfile;170}171if (key.startsWith('integrated.profiles.')) {172return profiles;173}174return undefined;175}176}));177}178179describe('CopilotCLITerminalIntegration', () => {180const disposables = new DisposableStore();181let terminalService: TestTerminalService;182let telemetryService: TestTelemetryService;183let envService: TestEnvService;184let integration: CopilotCLITerminalIntegration;185let authService: MockAuthenticationService;186187beforeEach(async () => {188vi.clearAllMocks();189190terminalService = disposables.add(new TestTerminalService());191telemetryService = new TestTelemetryService();192envService = new TestEnvService();193authService = new MockAuthenticationService();194195setupTerminalConfig('zsh', {196zsh: { path: 'zsh' },197});198199integration = new CopilotCLITerminalIntegration(200new TestExtensionContext() as unknown as IVSCodeExtensionContext,201authService as unknown as IAuthenticationService,202terminalService as unknown as ITerminalService,203envService as unknown as IEnvService,204{ trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), createSubLogger: () => ({}) } as unknown as ILogService,205telemetryService as unknown as ITelemetryService,206{ getConfig: () => true } as unknown as IConfigurationService,207208{ requestResourceTrust: vi.fn().mockResolvedValue(true) } as unknown as IWorkspaceService,209210new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })),211);212disposables.add(integration);213214// Wait for initialization to complete215await (integration as any).initialization;216});217218afterEach(() => {219disposables.clear();220});221222describe('openTerminal', () => {223it('should create a terminal via terminalService when no python terminal available', async () => {224await integration.openTerminal('Test Terminal');225226// Since pythonTerminalService.createTerminal returns undefined by default,227// and getShellInfo returns shell info for zsh, it falls through to the228// shell args terminal creation path229expect(terminalService.createTerminalSpy).toHaveBeenCalled();230});231232it('should set sessionType to "new" when no cliArgs provided', async () => {233await integration.openTerminal('Test Terminal');234235const event = telemetryService.events.find(e => e.name === 'copilotcli.terminal.open');236expect(event).toBeDefined();237expect(event!.properties.sessionType).toBe('new');238});239240it('should set sessionType to "resume" when cliArgs has --resume', async () => {241await integration.openTerminal('Test Terminal', ['--resume', 'session-123']);242243const event = telemetryService.events.find(e => e.name === 'copilotcli.terminal.open');244expect(event).toBeDefined();245expect(event!.properties.sessionType).toBe('resume');246});247248it('should send telemetry with shell type', async () => {249await integration.openTerminal('Test Terminal');250251const event = telemetryService.events.find(e => e.name === 'copilotcli.terminal.open');252expect(event).toBeDefined();253expect(event!.properties.shell).toBe('zsh');254});255256it('should pass cwd to terminal options', async () => {257await integration.openTerminal('Test Terminal', [], '/my/working/dir');258259const callArgs = terminalService.createTerminalSpy.mock.calls[0][0] as TerminalOptions;260expect(callArgs.cwd).toBe('/my/working/dir');261});262263it('should show the terminal after creation in shellArgs path', async () => {264await integration.openTerminal('Test Terminal');265266expect(terminalService.mockTerminal.show).toHaveBeenCalled();267});268269it('should fall back to terminalService when getShellInfo returns undefined', async () => {270// Setup config to return no matching profile271setupTerminalConfig(undefined, undefined);272273// Re-create the integration to pick up the new config274envService.shell = '/bin/unknownshell';275const freshIntegration = new CopilotCLITerminalIntegration(276new TestExtensionContext() as unknown as IVSCodeExtensionContext,277authService as unknown as IAuthenticationService,278terminalService as unknown as ITerminalService,279envService as unknown as IEnvService,280{ trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), createSubLogger: () => ({}) } as unknown as ILogService,281telemetryService as unknown as ITelemetryService,282{ getConfig: () => true } as unknown as IConfigurationService,283284{ requestResourceTrust: vi.fn().mockResolvedValue(true) } as unknown as IWorkspaceService,285286new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })),287);288disposables.add(freshIntegration);289await (freshIntegration as any).initialization;290291await freshIntegration.openTerminal('Fallback Terminal');292293const event = telemetryService.events.find(e => e.name === 'copilotcli.terminal.open');294expect(event).toBeDefined();295expect(event!.properties.shell).toBe('unknown');296expect(event!.properties.terminalCreationMethod).toBe('fallbackTerminal');297});298299it('should use pythonTerminal method when python terminal is available and shell is not powershell', async () => {300const mockPythonTerminal: MockTerminal = {301show: vi.fn(),302sendText: vi.fn(),303dispose: vi.fn(),304shellIntegration: undefined,305};306307// Access the internal pythonTerminalService and mock createTerminal to return a terminal308const pythonService = (integration as any).pythonTerminalService as PythonTerminalService;309(pythonService.createTerminal as ReturnType<typeof vi.fn>).mockResolvedValue(mockPythonTerminal);310311await integration.openTerminal('Python Terminal');312313const event = telemetryService.events.find(e => e.name === 'copilotcli.terminal.open');314expect(event).toBeDefined();315expect(event!.properties.terminalCreationMethod).toBe('pythonTerminal');316expect(event!.properties.shell).toBe('zsh');317});318319it('should use shellArgsTerminal method when python terminal is not available', async () => {320await integration.openTerminal('Shell Args Terminal');321322const event = telemetryService.events.find(e => e.name === 'copilotcli.terminal.open');323expect(event).toBeDefined();324expect(event!.properties.terminalCreationMethod).toBe('shellArgsTerminal');325});326327it('should prepend --clear to cliArgs', async () => {328await integration.openTerminal('Test Terminal', ['--resume', 'sess-1']);329330// For shellArgs terminal path, --clear gets removed before getShellInfo,331// but the final shell args should contain the original CLI args332const callArgs = terminalService.createTerminalSpy.mock.calls[0][0] as TerminalOptions;333const shellArgs = callArgs.shellArgs as string[];334// Shell args should contain the cli args (--resume, sess-1) but not --clear335const joinedArgs = shellArgs.join(' ');336expect(joinedArgs).toContain('--resume');337expect(joinedArgs).toContain('sess-1');338});339340it('should use editor location by default', async () => {341await integration.openTerminal('Test Terminal');342343const callArgs = terminalService.createTerminalSpy.mock.calls[0][0] as TerminalOptions;344// Default location is 'editor' which maps to ViewColumn.Active345expect(callArgs.location).toEqual({ viewColumn: -1 }); // ViewColumn.Active346});347348it('should set bash shell info when default profile is bash', async () => {349setupTerminalConfig('bash', {350bash: { path: 'bash' },351});352envService.shell = 'bash';353354const freshIntegration = new CopilotCLITerminalIntegration(355new TestExtensionContext() as unknown as IVSCodeExtensionContext,356authService as unknown as IAuthenticationService,357terminalService as unknown as ITerminalService,358envService as unknown as IEnvService,359{ trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), createSubLogger: () => ({}) } as unknown as ILogService,360telemetryService as unknown as ITelemetryService,361362{ getConfig: () => true } as unknown as IConfigurationService,363{ requestResourceTrust: vi.fn().mockResolvedValue(true) } as unknown as IWorkspaceService,364new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })),365);366disposables.add(freshIntegration);367await (freshIntegration as any).initialization;368369await freshIntegration.openTerminal('Bash Terminal');370371const event = telemetryService.events.find(e => e.name === 'copilotcli.terminal.open');372expect(event).toBeDefined();373expect(event!.properties.shell).toBe('bash');374});375});376377describe('initialize', () => {378it('should contribute path to terminal service', async () => {379expect(terminalService.contributePathSpy).toHaveBeenCalledWith(380'copilot-cli',381expect.stringContaining('copilotCli'),382expect.objectContaining({ command: 'copilot' }),383true,384);385});386387it('should register a terminal profile provider', async () => {388expect(mockRegisterTerminalProfileProvider).toHaveBeenCalledWith(389'copilot-cli',390expect.objectContaining({ provideTerminalProfile: expect.any(Function) }),391);392});393});394395describe('telemetry', () => {396it('should include location in telemetry', async () => {397await integration.openTerminal('Test', [], undefined, 'panel');398399const event = telemetryService.events.find(e => e.name === 'copilotcli.terminal.open');400expect(event).toBeDefined();401expect(event!.properties.location).toBe('panel');402});403404it('should report editorBeside location', async () => {405await integration.openTerminal('Test', [], undefined, 'editorBeside');406407const event = telemetryService.events.find(e => e.name === 'copilotcli.terminal.open');408expect(event!.properties.location).toBe('editorBeside');409});410});411412describe('getCommonTerminalOptions (via openTerminal)', () => {413it('should set terminal name from parameter', async () => {414await integration.openTerminal('My Custom Name');415416const callArgs = terminalService.createTerminalSpy.mock.calls[0][0] as TerminalOptions;417expect(callArgs.name).toBe('My Custom Name');418});419420it('should not include auth env vars when no session available', async () => {421await integration.openTerminal('No Auth Terminal');422423const callArgs = terminalService.createTerminalSpy.mock.calls[0][0] as TerminalOptions;424expect(callArgs.env).toBeUndefined();425});426427it('should include auth env vars when session is available', async () => {428const authServiceWithSession = new class extends MockAuthenticationService {429override async getGitHubSession() {430return { accessToken: 'test-token-123' } as any;431}432}();433434const freshIntegration = new CopilotCLITerminalIntegration(435new TestExtensionContext() as unknown as IVSCodeExtensionContext,436authServiceWithSession as unknown as IAuthenticationService,437terminalService as unknown as ITerminalService,438envService as unknown as IEnvService,439{ trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), createSubLogger: () => ({}) } as unknown as ILogService,440telemetryService as unknown as ITelemetryService,441442{ getConfig: () => true } as unknown as IConfigurationService,443{ requestResourceTrust: vi.fn().mockResolvedValue(true) } as unknown as IWorkspaceService,444new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })),445);446disposables.add(freshIntegration);447await (freshIntegration as any).initialization;448449await freshIntegration.openTerminal('Auth Terminal');450451const callArgs = terminalService.createTerminalSpy.mock.calls[0][0] as TerminalOptions;452expect(callArgs.env).toEqual({453GH_TOKEN: 'test-token-123',454COPILOT_GITHUB_TOKEN: 'test-token-123',455});456});457});458});459460461