From 8932f4aec39db030efc09b1b475f690f7af40837 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Thu, 17 Sep 2026 11:14:05 -0700 Subject: [PATCH 1/5] Install lsp plugin --- README.md | 1 + docs/Copilot-Dotnet-Plugin.md | 65 ++ docs/readme.md | 1 + l10n/bundle.l10n.json | 7 + package.json | 5 + package.nls.json | 1 + src/main.ts | 3 + src/shared/copilot/copilotCli.ts | 476 +++++++++++ src/shared/copilot/dotnetPlugin.ts | 438 ++++++++++ src/shared/telemetryEventNames.ts | 3 + .../lsptoolshost/unitTests/copilotCli.test.ts | 802 ++++++++++++++++++ .../unitTests/dotnetPlugin.test.ts | 521 ++++++++++++ 12 files changed, 2323 insertions(+) create mode 100644 docs/Copilot-Dotnet-Plugin.md create mode 100644 src/shared/copilot/copilotCli.ts create mode 100644 src/shared/copilot/dotnetPlugin.ts create mode 100644 test/lsptoolshost/unitTests/copilotCli.test.ts create mode 100644 test/lsptoolshost/unitTests/dotnetPlugin.test.ts diff --git a/README.md b/README.md index d875fb8be0..06d1c05953 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Learn more about the rich features of the C# extension: * [Navigation](https://code.visualstudio.com/docs/csharp/navigate-edit): Explore and navigate your code with features like Go To Definition and Find All References * [IntelliSense](https://code.visualstudio.com/docs/csharp/navigate-edit): Write code with auto-completion * [Formatting and Linting](https://code.visualstudio.com/docs/csharp/formatting-linting): Format and lint your code + * [GitHub Copilot C# LSP .NET plugin](docs/Copilot-Dotnet-Plugin.md): Automatic installation of .NET skills and C# language intelligence for Copilot, and how to uninstall For more information you can: diff --git a/docs/Copilot-Dotnet-Plugin.md b/docs/Copilot-Dotnet-Plugin.md new file mode 100644 index 0000000000..ce34ea878f --- /dev/null +++ b/docs/Copilot-Dotnet-Plugin.md @@ -0,0 +1,65 @@ +# C# LSP .NET plugin for GitHub Copilot + +The C# extension automatically installs the [.NET team's `dotnet` plugin](https://github.com/dotnet/skills/tree/main/plugins/dotnet) when a compatible GitHub Copilot CLI or GitHub Copilot app runtime is available on the machine running the extension. + +## Why it is installed + +The plugin provides .NET development skills and a C# language-server declaration for GitHub Copilot. These help Copilot work with .NET projects and use C# language intelligence. Only the base `dotnet` plugin is installed, not the other plugins in the `dotnet/skills` repository. + +The plugin's C# language server requires the **.NET 10 SDK** and `dotnet` on PATH. Installing the plugin does not install that SDK or start the language server. Start a new Copilot session, or restart an existing one, to load the plugin. + +This is separate from the C# extension's own language server and from VS Code Chat plugins. + +## How installation works + +Installation runs in the background and does not delay C# extension startup or language-server initialization. The extension uses: + +```text +copilot plugin install dotnet/skills:plugins/dotnet +``` + +The extension prefers an available standalone CLI, otherwise it looks for the installed GitHub app's extracted CLI. It does not install Copilot or launch the app. If the app has never extracted its CLI, installation is skipped; a later VS Code launch can try again after the app has been used. + +App discovery supports standard Windows installation folders, macOS Applications folders, and Linux packaged or extracted app layouts. Nonstandard app locations and opaque Linux AppImages may not be discoverable; a standalone Copilot CLI on PATH can be used in those cases. + +Copilot controls where plugins are installed. The subprocess inherits the extension host's environment, including `COPILOT_HOME`; the normal default is the user's `.copilot` directory. The app and standalone CLI share the plugin when they use the same configuration directory. A configuration override used only by an already-running app is not inherited by a subprocess started by VS Code. + +In Remote SSH, WSL, and dev-container workspaces, only Copilot on the **extension host** is considered. The extension does not install into a separate local desktop host. + +Existing installations, including disabled plugins, are left unchanged. The extension does not update or re-enable them, and it leaves conflicting same-name plugins alone. Automatic installation is skipped in untrusted workspaces and when the existing VS Code `chat.disableAIFeatures` setting is enabled. + +Installed and conflicting-plugin results are cached privately for the current C# extension version to avoid repeated CLI launches. An extension version change invalidates the cache. External plugin removal, enablement changes, or resolution of a conflict might therefore not be noticed until the next extension update. Unavailable runtimes and failures are not cached. + +## Uninstall and prevent automatic reinstallation + +Open the Command Palette and run: + +**.NET: Uninstall Copilot C# LSP plugin** + +This command records a private opt-out in VS Code's extension state, then checks the current CLI inventory and uninstalls the plugin. It always bypasses the automatic-install cache. The opt-out persists across C# extension updates and is not synced to other machines. + +If removal fails or Copilot is unavailable, the opt-out remains in effect as long as it was saved successfully. The command reports any failure; see the **C#** output channel for details. + +You can also remove the plugin directly from a terminal: + +```text +copilot plugin uninstall dotnet +``` + +For a marketplace installation, use: + +```text +copilot plugin uninstall dotnet@dotnet-agent-skills +``` + +**Removing it only through Copilot CLI does not opt out of the C# extension's automatic installation.** Use the extension's uninstall command to prevent reinstallation, even if the plugin has already been removed. + +To use the plugin again, install it manually with the installation command above. This does not clear the extension's automatic-install opt-out. There is no additional public C# setting for this feature. + +## Troubleshooting + +Open **View > Output** and select **C#**. Discovery, inventory, installation, and removal failures are logged without affecting normal C# features. Each install or uninstall operation has an overall two-minute timeout. + +If installation is skipped, make sure Copilot is installed on the extension host. For the GitHub app, use the app once so its CLI can be extracted, then restart VS Code. An incompatible CLI listing format, unavailable Git/network access, permissions, or organization policy can prevent installation. The extension does not change credentials, install missing prerequisites, or bypass policy. + +Installation status, cached status, skips, failures, and manual uninstall outcomes are reported through the extension's existing telemetry mechanism, subject to VS Code telemetry controls. These events do not include CLI output, file paths, configuration contents, or credentials. diff --git a/docs/readme.md b/docs/readme.md index 0588b2c983..349408ed10 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -4,6 +4,7 @@ * [Contributor guide](../CONTRIBUTING.md) * [How to get support](../SUPPORT.md) * [Installing without internet connectivity](./Installing-without-Internet-connectivity.md) +* [Copilot C# LSP .NET plugin: installation and removal](./Copilot-Dotnet-Plugin.md) * [How to run and debug unit tests](./How-to-run-and-debug-unit-tests.md) * [Troubleshooting: 'The .NET Core SDK cannot be located.' errors](./Troubleshooting-'The-.NET-Core-SDK-cannot-be-located.'-errors.md) diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 27e7d2c6ae..4a82c755ee 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -67,6 +67,13 @@ "Replace existing build and debug assets?": "Replace existing build and debug assets?", "Could not locate .NET Core project in '{0}'. Assets were not generated.": "Could not locate .NET Core project in '{0}'. Assets were not generated.", "Unable to generate assets to build and debug. {0}.": "Unable to generate assets to build and debug. {0}.", + "Learn More": "Learn More", + "Installed the C# LSP .NET plugin for GitHub Copilot": "Installed the C# LSP .NET plugin for GitHub Copilot", + "Uninstalled the Copilot C# LSP plugin. Automatic installation is disabled.": "Uninstalled the Copilot C# LSP plugin. Automatic installation is disabled.", + "The Copilot C# LSP plugin is not installed. Automatic installation is disabled.": "The Copilot C# LSP plugin is not installed. Automatic installation is disabled.", + "Automatic installation is disabled, but Copilot is unavailable to uninstall the C# LSP plugin.": "Automatic installation is disabled, but Copilot is unavailable to uninstall the C# LSP plugin.", + "Could not disable automatic installation. See the C# output for details.": "Could not disable automatic installation. See the C# output for details.", + "Could not uninstall the Copilot C# LSP plugin. Automatic installation is disabled. See the C# output for details.": "Could not uninstall the Copilot C# LSP plugin. Automatic installation is disabled. See the C# output for details.", "Cannot load Razor OmniSharp language server because the directory was not found: '{0}'": "Cannot load Razor OmniSharp language server because the directory was not found: '{0}'", "Run and Debug: auto-detection found {0} for a launch browser": "Run and Debug: auto-detection found {0} for a launch browser", "Run and Debug: A valid browser is not installed. Please install Edge or Chrome.": "Run and Debug: A valid browser is not installed. Please install Edge or Chrome.", diff --git a/package.json b/package.json index d5eb5d2377..74c3760791 100644 --- a/package.json +++ b/package.json @@ -1859,6 +1859,11 @@ } ], "commands": [ + { + "command": "dotnet.copilot.uninstallDotnetPlugin", + "title": "%command.dotnet.copilot.uninstallDotnetPlugin%", + "category": ".NET" + }, { "command": "o.restart", "title": "%command.o.restart%", diff --git a/package.nls.json b/package.nls.json index 19a97f1575..ad2ae97baa 100644 --- a/package.nls.json +++ b/package.nls.json @@ -1,4 +1,5 @@ { + "command.dotnet.copilot.uninstallDotnetPlugin": "Uninstall Copilot C# LSP plugin", "command.o.restart": "Restart OmniSharp", "command.o.pickProjectAndStart": "Select Project", "command.dotnet.openSolution": "Open Solution", diff --git a/src/main.ts b/src/main.ts index 600619a2f5..568e622a83 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,6 +27,7 @@ import { checkDotNetRuntimeExtensionVersion } from './checkDotNetRuntimeExtensio import { checkIsSupportedPlatform } from './checkSupportedPlatform'; import { activateRoslyn } from './activateRoslyn'; import { LimitedActivationStatus } from './shared/limitedActivationStatus'; +import { registerDotnetPlugin } from './shared/copilot/dotnetPlugin'; export async function activate( context: vscode.ExtensionContext @@ -63,6 +64,8 @@ export async function activate( return null; } + registerDotnetPlugin(context, reporter, csharpChannel); + await checkDotNetRuntimeExtensionVersion(context); await MigrateOptions(vscode); diff --git a/src/shared/copilot/copilotCli.ts b/src/shared/copilot/copilotCli.ts new file mode 100644 index 0000000000..0487972569 --- /dev/null +++ b/src/shared/copilot/copilotCli.ts @@ -0,0 +1,476 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ChildProcess, spawn } from 'child_process'; +import { constants, promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { stripVTControlCharacters } from 'util'; + +export type CopilotCliSource = 'standalone' | 'app'; + +export interface CopilotCli { + command: string; + args: readonly string[]; + source: CopilotCliSource; +} + +export interface CopilotPlugin { + name: string; + enabled: boolean; + kind: 'installed' | 'builtin' | 'external'; +} + +function namedError(name: string, message: string, cause?: unknown): Error { + return Object.assign(new Error(message, { cause }), { name }); +} + +function isMissing(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return code === 'ENOENT' || code === 'ENOTDIR'; +} + +async function fileExists(file: string, signal: AbortSignal, executable = false): Promise { + signal.throwIfAborted(); + try { + const stat = await fs.stat(file); + signal.throwIfAborted(); + if (!stat.isFile()) { + return false; + } + if (executable && os.platform() !== 'win32') { + await fs.access(file, constants.X_OK); + signal.throwIfAborted(); + } + return true; + } catch (error) { + signal.throwIfAborted(); + if (isMissing(error)) { + return false; + } + throw error; + } +} + +async function readMetadata(file: string, signal: AbortSignal): Promise { + signal.throwIfAborted(); + try { + const text = await fs.readFile(file, 'utf8'); + signal.throwIfAborted(); + return text; + } catch (error) { + signal.throwIfAborted(); + if (isMissing(error)) { + return undefined; + } + throw error; + } +} + +function environment(name: string): string | undefined { + if (os.platform() !== 'win32') { + return process.env[name]; + } + const key = Object.keys(process.env).find((key) => key.toLowerCase() === name.toLowerCase()); + return key ? process.env[key] : undefined; +} + +function platformPath(): typeof path.win32 { + return os.platform() === 'win32' ? path.win32 : path.posix; +} + +function absolute(value: string | undefined): value is string { + if (!value || !platformPath().isAbsolute(value)) { + return false; + } + // A Windows rooted path without a drive still depends on the current drive. + return os.platform() !== 'win32' || /^[a-z]:[\\/]|^[\\/]{2}[^\\/]+[\\/][^\\/]+/i.test(value); +} + +function environmentPath(name: string): string | undefined { + const value = environment(name); + return absolute(value) ? value : undefined; +} + +function pathDirectories(): string[] { + return (environment('PATH') ?? '') + .split(platformPath().delimiter) + .map((directory) => directory.trim().replace(/^"(.*)"$/, '$1')) + .filter(absolute); +} + +async function npmCli( + directory: string, + directories: readonly string[], + signal: AbortSignal +): Promise { + const p = platformPath(); + const packageDirectory = p.join(directory, 'node_modules', '@github', 'copilot'); + const metadata = await readMetadata(p.join(packageDirectory, 'package.json'), signal); + if (metadata === undefined) { + return undefined; + } + const packageJson = JSON.parse(metadata); + const launcher = typeof packageJson.bin === 'string' ? packageJson.bin : packageJson.bin?.copilot; + if (packageJson.name !== '@github/copilot' || typeof launcher !== 'string') { + throw namedError('CopilotCliMetadataError', `Invalid Copilot npm package metadata in ${packageDirectory}`); + } + const launcherPath = p.resolve(packageDirectory, launcher); + const relativeLauncher = p.relative(packageDirectory, launcherPath); + if (relativeLauncher.startsWith('..') || p.isAbsolute(relativeLauncher) || !/\.[cm]?js$/i.test(launcherPath)) { + throw namedError('CopilotCliMetadataError', `Invalid Copilot npm launcher in ${packageDirectory}`); + } + if (!(await fileExists(launcherPath, signal))) { + return undefined; + } + + // npm may keep optional packages nested or hoist them beside @github/copilot. + const nativeName = `copilot-win32-${os.arch()}`; + const nativeVersion = packageJson.optionalDependencies?.[`@github/${nativeName}`]; + if (typeof nativeVersion === 'string') { + for (const root of [p.join(packageDirectory, 'node_modules', '@github'), p.dirname(packageDirectory)]) { + const nativeDirectory = p.join(root, nativeName); + const nativeMetadata = await readMetadata(p.join(nativeDirectory, 'package.json'), signal); + if (nativeMetadata === undefined) { + continue; + } + const nativePackage = JSON.parse(nativeMetadata); + if (nativePackage.name !== `@github/${nativeName}` || nativePackage.version !== nativeVersion) { + throw namedError( + 'CopilotCliMetadataError', + `Mismatched Copilot native npm package in ${nativeDirectory}` + ); + } + const command = p.join(nativeDirectory, 'copilot.exe'); + if (await fileExists(command, signal, true)) { + return { command, args: [], source: 'standalone' }; + } + } + } + for (const nodeDirectory of new Set([directory, ...directories])) { + const node = p.join(nodeDirectory, 'node.exe'); + if (await fileExists(node, signal, true)) { + return { command: node, args: [launcherPath], source: 'standalone' }; + } + } + return undefined; +} + +export async function findCopilotCli(signal: AbortSignal): Promise { + signal.throwIfAborted(); + const p = platformPath(); + const platform = os.platform(); + const home = os.homedir(); + const directories = pathDirectories(); + const standaloneDirectories = [...directories, p.join(home, '.local', 'bin')]; + if (platform === 'win32') { + const appData = environmentPath('APPDATA'); + if (appData) { + standaloneDirectories.push(p.join(appData, 'npm')); + } + for (const root of [environmentPath('LOCALAPPDATA'), environmentPath('ProgramFiles')]) { + if (root) { + const winget = + root === environmentPath('LOCALAPPDATA') + ? p.join(root, 'Microsoft', 'WinGet') + : p.join(root, 'WinGet'); + standaloneDirectories.push( + p.join(winget, 'Links'), + p.join(winget, 'Packages', 'GitHub.Copilot_Microsoft.Winget.Source_8wekyb3d8bbwe') + ); + } + } + } else { + standaloneDirectories.push('/usr/local/bin', '/usr/bin'); + if (platform === 'darwin') { + standaloneDirectories.push('/opt/homebrew/bin'); + } + } + for (const directory of new Set(standaloneDirectories.filter(absolute))) { + const command = p.join(directory, platform === 'win32' ? 'copilot.exe' : 'copilot'); + if (await fileExists(command, signal, true)) { + return { command, args: [], source: 'standalone' }; + } + if (platform === 'win32') { + for (const shim of ['copilot.cmd', 'copilot.ps1', 'copilot']) { + if (await fileExists(p.join(directory, shim), signal)) { + const cli = await npmCli(directory, directories, signal); + if (cli) { + return cli; + } + break; + } + } + } + } + + const apps: { executable: string; resources: string }[] = []; + let cache: string | undefined; + if (platform === 'win32') { + const local = environmentPath('LOCALAPPDATA'); + cache = local; + const appDirectories = [ + ...(local ? [p.join(local, 'Programs', 'GitHub Copilot')] : []), + ...['ProgramFiles', 'ProgramFiles(x86)'] + .map(environmentPath) + .filter((root): root is string => root !== undefined) + .map((root) => p.join(root, 'GitHub Copilot')), + ...directories, + ]; + for (const root of new Set(appDirectories)) { + apps.push({ executable: p.join(root, 'github.exe'), resources: root }); + } + } else if (platform === 'darwin') { + cache = p.join(home, 'Library', 'Caches'); + for (const root of ['/Applications', p.join(home, 'Applications')]) { + const contents = p.join(root, 'GitHub Copilot.app', 'Contents'); + apps.push({ executable: p.join(contents, 'MacOS', 'github'), resources: p.join(contents, 'Resources') }); + } + } else if (platform === 'linux') { + cache = environmentPath('XDG_CACHE_HOME') ?? p.join(home, '.cache'); + // Tauri deb/rpm resources use productName, including its spaces. An extracted + // AppImage's usr/bin + usr/lib layout also works when usr/bin is on PATH. + for (const bin of new Set(['/usr/bin', '/usr/local/bin', ...directories])) { + apps.push({ + executable: p.join(bin, 'github'), + resources: p.join(p.dirname(bin), 'lib', 'GitHub Copilot'), + }); + } + } + if (!cache || !absolute(cache)) { + return undefined; + } + for (const app of apps) { + if (!(await fileExists(app.executable, signal, true))) { + continue; + } + const metadataPath = p.join(app.resources, 'copilot-sdk', 'cliVersion.d.ts'); + const metadata = await readMetadata(metadataPath, signal); + if (metadata === undefined) { + continue; + } + const matches = [...metadata.matchAll(/export declare const COPILOT_CLI_VERSION\s*=\s*"([^"]+)";/g)]; + const version = matches[0]?.[1]; + if (matches.length !== 1 || !/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(version)) { + throw namedError('CopilotCliMetadataError', `Invalid pinned Copilot CLI version in ${metadataPath}`); + } + const command = p.join( + cache, + 'github-copilot-sdk', + 'cli', + version.replace(/[^a-zA-Z0-9._-]/g, '_'), + platform === 'win32' ? 'copilot.exe' : 'copilot' + ); + if (await fileExists(command, signal, true)) { + return { command, args: [], source: 'app' }; + } + } + signal.throwIfAborted(); + return undefined; +} + +const maxOutputBytes = 1024 * 1024; + +async function terminateProcessTree(child: ChildProcess): Promise { + if (child.pid === undefined) { + return; + } + if (os.platform() !== 'win32') { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { + throw error; + } + } + return; + } + const systemRoot = environmentPath('SystemRoot') ?? 'C:\\Windows'; + await new Promise((resolve, reject) => { + // Killing only the CLI leaves Git children running. /T is scoped to our PID; + // do not kill the root first, or taskkill can no longer discover its children. + const killer = spawn( + path.win32.join(systemRoot, 'System32', 'taskkill.exe'), + ['/PID', String(child.pid), '/T', '/F'], + { + windowsHide: true, + shell: false, + cwd: os.homedir(), + env: process.env, + stdio: ['ignore', 'ignore', 'pipe'], + } + ); + let error: Error | undefined; + let stderr = ''; + killer.stderr?.on('data', (data: Buffer) => { + stderr = (stderr + data.toString()).slice(0, 4096); + }); + killer.on('error', (failure: Error) => { + error = failure; + }); + killer.on('close', (code) => { + if (error) { + reject(error); + } else if (code !== 0) { + reject(namedError('CopilotCliTerminationError', `taskkill exited with code ${code}: ${stderr}`)); + } else { + resolve(); + } + }); + }); +} + +export async function runCopilotCli(cli: CopilotCli, args: readonly string[], signal: AbortSignal): Promise { + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const child = spawn(cli.command, [...cli.args, ...args], { + windowsHide: true, + shell: false, + cwd: os.homedir(), + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + // A private POSIX process group lets cancellation include spawned Git work. + // The child stays referenced and is always awaited; it is not a background job. + detached: os.platform() !== 'win32', + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let bytes = 0; + let failure: Error | undefined; + let termination: Promise | undefined; + const stop = (error: Error) => { + failure ??= error; + termination ??= terminateProcessTree(child).catch((terminationError) => { + failure = namedError( + 'CopilotCliTerminationError', + 'Could not terminate the Copilot CLI process tree', + new AggregateError([failure, terminationError]) + ); + // Still wait for the owned child to exit even if tree termination fails. + try { + child.kill('SIGKILL'); + } catch (killError) { + failure = namedError( + 'CopilotCliTerminationError', + 'Could not terminate the Copilot CLI process', + new AggregateError([failure, killError]) + ); + } + }); + }; + const onAbort = () => + stop( + signal.reason instanceof Error + ? signal.reason + : namedError('AbortError', 'Copilot CLI operation cancelled', signal.reason) + ); + const capture = (buffers: Buffer[], data: Buffer | string) => { + if (failure) { + return; + } + const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data); + bytes += buffer.length; + if (bytes > maxOutputBytes) { + stop(namedError('CopilotCliOutputLimitError', `Copilot CLI output exceeded ${maxOutputBytes} bytes`)); + } else { + buffers.push(buffer); + } + }; + child.stdout?.on('data', (data: Buffer) => capture(stdout, data)); + child.stderr?.on('data', (data: Buffer) => capture(stderr, data)); + child.on('error', (error: Error) => { + failure ??= error; + }); + child.on('close', (code, exitSignal) => { + signal.removeEventListener('abort', onAbort); + // close includes pipe closure; also wait for taskkill itself before releasing + // the caller's gate, otherwise a second install can race tree termination. + void (async () => { + await termination; + if (failure) { + reject(failure); + } else if (code !== 0) { + reject( + namedError( + 'CopilotCliProcessError', + `Copilot CLI exited with code ${code}, signal ${exitSignal}: ${Buffer.concat(stderr).toString('utf8')}` + ) + ); + } else { + resolve(Buffer.concat(stdout).toString('utf8')); + } + })(); + }); + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) { + onAbort(); + } + }); +} + +export function parsePluginList(output: string): CopilotPlugin[] { + const lines = stripVTControlCharacters(output).split(/\r?\n/); + const plugins: CopilotPlugin[] = []; + const sections = new Set(); + let section: CopilotPlugin['kind'] | undefined; + let sectionCount = 0; + let explicitlyEmpty = false; + let installHint = false; + const invalid = () => + namedError('CopilotPluginInventoryError', 'Unrecognized or incomplete Copilot plugin inventory'); + for (const raw of lines) { + const line = raw.trim(); + if (!line) { + continue; + } + if (line === 'No plugins installed.') { + if (explicitlyEmpty || sections.has('installed')) { + throw invalid(); + } + explicitlyEmpty = true; + continue; + } + if (line === "Use 'copilot plugin install ' to install a plugin.") { + if (!explicitlyEmpty || installHint) { + throw invalid(); + } + installHint = true; + continue; + } + const heading: CopilotPlugin['kind'] | undefined = /^Installed plugins:$/i.test(line) + ? 'installed' + : /^Built-in Plugins \(bundled with the CLI\):$/i.test(line) + ? 'builtin' + : /^External Plugins \(via --plugin-dir\):$/i.test(line) + ? 'external' + : undefined; + if (heading) { + if ( + (section && sectionCount === 0) || + sections.has(heading) || + (heading === 'installed' && explicitlyEmpty) + ) { + throw invalid(); + } + section = heading; + sectionCount = 0; + sections.add(heading); + continue; + } + const entry = + /^\s+• ([a-zA-Z0-9][a-zA-Z0-9._-]*(?:@[a-zA-Z0-9][a-zA-Z0-9._-]*)?)(?: \(v\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?\))?( \[disabled\])?$/.exec( + raw + ); + if (!section || !entry || plugins.some((plugin) => plugin.kind === section && plugin.name === entry[1])) { + throw invalid(); + } + plugins.push({ name: entry[1], enabled: !entry[2], kind: section }); + sectionCount++; + } + if ((!explicitlyEmpty && !sections.has('installed')) || (section && sectionCount === 0)) { + throw invalid(); + } + return plugins; +} diff --git a/src/shared/copilot/dotnetPlugin.ts b/src/shared/copilot/dotnetPlugin.ts new file mode 100644 index 0000000000..74611cae56 --- /dev/null +++ b/src/shared/copilot/dotnetPlugin.ts @@ -0,0 +1,438 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { commonOptions } from '../options'; +import { ITelemetryReporter } from '../telemetryReporter'; +import { TelemetryEventNames } from '../telemetryEventNames'; +import type { CopilotCli, CopilotCliSource, CopilotPlugin } from './copilotCli'; + +export const uninstallDotnetPluginCommand = 'dotnet.copilot.uninstallDotnetPlugin'; +export const dotnetPluginOptOutKey = 'csharp.copilotDotnetPlugin.autoInstallDisabled'; +export const dotnetPluginCacheKey = 'csharp.copilotDotnetPlugin.checkResult'; +const pluginSource = 'dotnet/skills:plugins/dotnet'; +const documentationUrl = 'https://github.com/dotnet/vscode-csharp/blob/main/docs/Copilot-Dotnet-Plugin.md'; +const operationTimeoutMs = 120_000; + +type CachedOutcome = 'alreadyInstalled' | 'alreadyInstalledDisabled' | 'conflictingPlugin'; +type Outcome = + | CachedOutcome + | 'installed' + | 'copilotNotAvailable' + | 'optedOut' + | 'aiDisabled' + | 'untrustedWorkspace' + | 'uninstallRequested' + | 'disposed' + | 'installFailed' + | 'uninstalled' + | 'alreadyAbsent' + | 'uninstallFailed'; +type Stage = 'eligibility' | 'cache' | 'discovery' | 'inventory' | 'install' | 'confirmation' | 'optOut' | 'uninstall'; +type Runtime = typeof import('./copilotCli'); +type Context = { + globalState: vscode.Memento; + extension: Pick, 'packageJSON'>; +}; +type Channel = Pick; +type Cache = { extensionVersion: string; outcome: CachedOutcome; source: CopilotCliSource }; +type Operation = { + outcome: Outcome; + source: CopilotCliSource | 'none'; + cached: boolean; + stage: Stage; + signal: AbortSignal; +}; + +export function registerDotnetPlugin( + context: Context & Pick, + reporter: ITelemetryReporter, + channel: Channel +): void { + const manager = new DotnetPluginManager(context, reporter, channel); + try { + context.subscriptions.push( + manager, + vscode.commands.registerCommand(uninstallDotnetPluginCommand, async () => manager.uninstall()) + ); + // Other integration suites must not install into the developer's real Copilot profile. + if (context.extensionMode !== vscode.ExtensionMode.Test) { + const scheduled = setImmediate(() => { + void manager.install(); + }); + context.subscriptions.push({ dispose: () => clearImmediate(scheduled) }); + } + } catch (error) { + manager.dispose(); + channel.error('Failed to register the Copilot .NET plugin integration', error); + } +} + +export class DotnetPluginManager implements vscode.Disposable { + private pending = Promise.resolve(); + private installation: Promise | undefined; + private controller: AbortController | undefined; + private disposed = false; + private uninstallRequested = false; + + constructor( + private readonly context: Context, + private readonly reporter: ITelemetryReporter, + private readonly channel: Channel + ) {} + + public dispose(): void { + this.disposed = true; + this.controller?.abort(new Error('Copilot plugin operation disposed')); + } + + public async install(): Promise { + this.installation ??= this.enqueue(async () => { + const result = await this.operate(false, async (operation) => this.installCore(operation)); + if (result.outcome === 'installed' && !this.disposed && !this.uninstallRequested) { + void this.showInstalled(); + } + }); + await this.installation; + } + + public async uninstall(): Promise { + this.uninstallRequested = true; + // Start persisting the opt-out immediately, even if removal must wait for an active install. + const optOut = this.persistOptOut(); + await this.enqueue(async () => { + const result = await this.operate(true, async (operation) => { + operation.stage = 'optOut'; + const persisted = await interruptible(optOut, operation.signal); + if (!persisted.success) { + throw persisted.error; + } + await this.clearCache(operation); + const runtime = await this.loadRuntime(operation); + const cli = await this.discover(runtime, operation); + if (!cli) { + operation.outcome = 'copilotNotAvailable'; + return; + } + const plugins = await this.inventory(runtime, cli, operation, 'inventory'); + const targets = plugins.filter(isDotnetPlugin); + if (targets.length === 0) { + if (plugins.some(isConflictingPlugin)) { + throw new Error('A different plugin uses the dotnet name; it has not been removed.'); + } + operation.outcome = 'alreadyAbsent'; + return; + } + for (const plugin of targets) { + operation.stage = 'uninstall'; + operation.signal.throwIfAborted(); + await runtime.runCopilotCli(cli, ['plugin', 'uninstall', plugin.name], operation.signal); + } + const remaining = await this.inventory(runtime, cli, operation, 'confirmation'); + if (remaining.some(isDotnetPlugin)) { + throw new Error('Copilot still lists the .NET plugin after uninstalling it.'); + } + operation.outcome = 'uninstalled'; + }); + if (!this.disposed) { + void this.showUninstallResult(result); + } + }); + } + + private async enqueue(work: () => Promise): Promise { + const next = this.pending.then(work); + // Keep the gate usable even if an unexpected failure escapes an operation's boundary. + this.pending = next.catch((error) => { + this.channel.error('Copilot .NET plugin operation failed', error); + }); + await this.pending; + } + + private async operate(uninstall: boolean, work: (operation: Operation) => Promise): Promise { + const controller = new AbortController(); + this.controller = controller; + const operation: Operation = { + outcome: uninstall ? 'uninstallFailed' : 'installFailed', + source: 'none', + cached: false, + stage: 'eligibility', + signal: controller.signal, + }; + const timeout = setTimeout(() => { + const error = new Error('Copilot plugin operation timed out'); + error.name = 'TimeoutError'; + controller.abort(error); + }, operationTimeoutMs); + try { + if (this.disposed) { + operation.outcome = 'disposed'; + } else { + await work(operation); + } + } catch (error) { + operation.outcome = this.disposed ? 'disposed' : uninstall ? 'uninstallFailed' : 'installFailed'; + this.reportError(operation, controller.signal.aborted ? controller.signal.reason : error); + } finally { + clearTimeout(timeout); + this.controller = undefined; + } + this.reportOutcome(operation, uninstall); + return operation; + } + + private async installCore(operation: Operation): Promise { + const skip = this.skipReason(); + if (skip) { + operation.outcome = skip; + return; + } + operation.stage = 'cache'; + const stored = this.context.globalState.get(dotnetPluginCacheKey); + if (isCache(stored) && stored.extensionVersion === this.context.extension.packageJSON.version) { + operation.outcome = stored.outcome; + operation.source = stored.source; + operation.cached = true; + return; + } + if (stored !== undefined) { + await this.clearCache(operation); + } + const runtime = await this.loadRuntime(operation); + const cli = await this.discover(runtime, operation); + if (!cli) { + operation.outcome = 'copilotNotAvailable'; + return; + } + const plugins = await this.inventory(runtime, cli, operation, 'inventory'); + const installed = plugins.filter(isDotnetPlugin); + if (installed.length > 0) { + operation.outcome = installed.some((plugin) => plugin.enabled) + ? 'alreadyInstalled' + : 'alreadyInstalledDisabled'; + await this.cache(operation, operation.outcome, cli.source); + return; + } + if (plugins.some(isConflictingPlugin)) { + operation.outcome = 'conflictingPlugin'; + this.channel.info('Skipping Copilot .NET plugin installation because a different plugin uses its name.'); + await this.cache(operation, operation.outcome, cli.source); + return; + } + const lateSkip = this.skipReason(); + if (lateSkip) { + operation.outcome = lateSkip; + return; + } + operation.stage = 'install'; + operation.signal.throwIfAborted(); + await runtime.runCopilotCli(cli, ['plugin', 'install', pluginSource], operation.signal); + const confirmed = (await this.inventory(runtime, cli, operation, 'confirmation')).filter(isDotnetPlugin); + if (confirmed.length === 0) { + throw new Error('Copilot did not list the .NET plugin after installation.'); + } + operation.outcome = 'installed'; + await this.cache( + operation, + confirmed.some((plugin) => plugin.enabled) ? 'alreadyInstalled' : 'alreadyInstalledDisabled', + cli.source + ); + } + + private skipReason(): Outcome | undefined { + if (this.disposed) { + return 'disposed'; + } + if (this.context.globalState.get(dotnetPluginOptOutKey, false)) { + return 'optedOut'; + } + if (this.uninstallRequested) { + return 'uninstallRequested'; + } + if (commonOptions.disableAIFeatures) { + return 'aiDisabled'; + } + if (!vscode.workspace.isTrusted) { + return 'untrustedWorkspace'; + } + return undefined; + } + + private async persistOptOut(): Promise<{ success: true } | { success: false; error: unknown }> { + try { + await this.context.globalState.update(dotnetPluginOptOutKey, true); + return { success: true }; + } catch (error) { + return { success: false, error }; + } + } + + private async loadRuntime(operation: Operation): Promise { + operation.stage = 'discovery'; + return await interruptible(import('./copilotCli'), operation.signal); + } + + private async discover(runtime: Runtime, operation: Operation): Promise { + operation.stage = 'discovery'; + const cli = await interruptible(runtime.findCopilotCli(operation.signal), operation.signal); + operation.source = cli?.source ?? 'none'; + return cli; + } + + private async inventory( + runtime: Runtime, + cli: CopilotCli, + operation: Operation, + stage: 'inventory' | 'confirmation' + ): Promise { + operation.stage = stage; + operation.signal.throwIfAborted(); + // The runner observes cancellation and waits for its process to exit before releasing the gate. + const output = await runtime.runCopilotCli(cli, ['plugin', 'list'], operation.signal); + operation.signal.throwIfAborted(); + return runtime.parsePluginList(output); + } + + private async clearCache(operation: Operation): Promise { + try { + await interruptible(this.context.globalState.update(dotnetPluginCacheKey, undefined), operation.signal); + } catch (error) { + this.reportError({ ...operation, stage: 'cache' }, error); + operation.signal.throwIfAborted(); + } + } + + private async cache(operation: Operation, outcome: CachedOutcome, source: CopilotCliSource): Promise { + if (this.skipReason()) { + return; + } + const value: Cache = { + extensionVersion: this.context.extension.packageJSON.version, + outcome, + source, + }; + try { + await interruptible(this.context.globalState.update(dotnetPluginCacheKey, value), operation.signal); + } catch (error) { + this.reportError({ ...operation, stage: 'cache' }, error); + } + } + + private reportOutcome(operation: Operation, uninstall: boolean): void { + try { + const properties: Record = { outcome: operation.outcome, source: operation.source }; + if (!uninstall) { + properties.cached = String(operation.cached); + } + this.reporter.sendTelemetryEvent( + uninstall ? TelemetryEventNames.CopilotDotnetPluginUninstall : TelemetryEventNames.CopilotDotnetPlugin, + properties + ); + } catch (error) { + this.channel.error('Failed to report Copilot .NET plugin telemetry', error); + } + } + + private reportError(operation: Operation, error: unknown): void { + this.channel.error(`Copilot .NET plugin ${operation.stage} failed`, error); + try { + this.reporter.sendTelemetryErrorEvent(TelemetryEventNames.CopilotDotnetPluginError, { + stage: operation.stage, + outcome: operation.outcome, + 'error.name': telemetryErrorName(error), + }); + } catch (telemetryError) { + this.channel.error('Failed to report Copilot .NET plugin error telemetry', telemetryError); + } + } + + private async showInstalled(): Promise { + try { + const learnMore = vscode.l10n.t('Learn More'); + const selected = await vscode.window.showInformationMessage( + vscode.l10n.t('Installed the C# LSP .NET plugin for GitHub Copilot'), + learnMore + ); + if (selected === learnMore && !this.disposed) { + if (!(await vscode.env.openExternal(vscode.Uri.parse(documentationUrl)))) { + this.channel.error('Could not open the Copilot .NET plugin documentation.'); + } + } + } catch (error) { + this.channel.error('Failed to show the Copilot .NET plugin notification or documentation', error); + } + } + + private async showUninstallResult(operation: Operation): Promise { + try { + if (operation.outcome === 'uninstalled' || operation.outcome === 'alreadyAbsent') { + await vscode.window.showInformationMessage( + operation.outcome === 'uninstalled' + ? vscode.l10n.t('Uninstalled the Copilot C# LSP plugin. Automatic installation is disabled.') + : vscode.l10n.t( + 'The Copilot C# LSP plugin is not installed. Automatic installation is disabled.' + ) + ); + } else { + await vscode.window.showWarningMessage( + operation.outcome === 'copilotNotAvailable' + ? vscode.l10n.t( + 'Automatic installation is disabled, but Copilot is unavailable to uninstall the C# LSP plugin.' + ) + : operation.stage === 'optOut' + ? vscode.l10n.t('Could not disable automatic installation. See the C# output for details.') + : vscode.l10n.t( + 'Could not uninstall the Copilot C# LSP plugin. Automatic installation is disabled. See the C# output for details.' + ) + ); + } + } catch (error) { + this.channel.error('Failed to show the Copilot .NET plugin uninstall result', error); + } + } +} + +function isDotnetPlugin(plugin: CopilotPlugin): boolean { + return plugin.kind === 'installed' && (plugin.name === 'dotnet' || plugin.name === 'dotnet@dotnet-agent-skills'); +} + +function isConflictingPlugin(plugin: CopilotPlugin): boolean { + return (plugin.name === 'dotnet' || plugin.name.startsWith('dotnet@')) && !isDotnetPlugin(plugin); +} + +function isCache(value: unknown): value is Cache { + if (typeof value !== 'object' || value === null) { + return false; + } + return ( + 'extensionVersion' in value && + typeof value.extensionVersion === 'string' && + 'outcome' in value && + (value.outcome === 'alreadyInstalled' || + value.outcome === 'alreadyInstalledDisabled' || + value.outcome === 'conflictingPlugin') && + 'source' in value && + (value.source === 'standalone' || value.source === 'app') + ); +} + +function telemetryErrorName(error: unknown): string { + const allowedNames = ['Error', 'AbortError', 'TimeoutError', 'TypeError', 'RangeError', 'SyntaxError']; + return error instanceof Error && allowedNames.includes(error.name) ? error.name : 'Error'; +} + +async function interruptible(work: PromiseLike, signal: AbortSignal): Promise { + return await new Promise((resolve, reject) => { + const aborted = () => reject(signal.reason); + if (signal.aborted) { + aborted(); + } else { + signal.addEventListener('abort', aborted, { once: true }); + } + void Promise.resolve(work) + .then(resolve, reject) + .finally(() => signal.removeEventListener('abort', aborted)); + }); +} diff --git a/src/shared/telemetryEventNames.ts b/src/shared/telemetryEventNames.ts index f76157844f..c937436079 100644 --- a/src/shared/telemetryEventNames.ts +++ b/src/shared/telemetryEventNames.ts @@ -10,6 +10,9 @@ export enum TelemetryEventNames { // Common extension events CSharpActivated = 'CSharpActivated', CSharpLimitedActivation = 'CSharpLimitedActivation', + CopilotDotnetPlugin = 'copilotDotnetPlugin', + CopilotDotnetPluginUninstall = 'copilotDotnetPlugin/uninstall', + CopilotDotnetPluginError = 'copilotDotnetPlugin/error', // Events related to the roslyn language server. diff --git a/test/lsptoolshost/unitTests/copilotCli.test.ts b/test/lsptoolshost/unitTests/copilotCli.test.ts new file mode 100644 index 0000000000..7da535cb51 --- /dev/null +++ b/test/lsptoolshost/unitTests/copilotCli.test.ts @@ -0,0 +1,802 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals'; +import { ChildProcess, spawn, SpawnOptions } from 'child_process'; +import { EventEmitter } from 'events'; +import { promises as fs, PathLike, Stats } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { PassThrough } from 'stream'; +import { CopilotCli, findCopilotCli, parsePluginList, runCopilotCli } from '../../../src/shared/copilot/copilotCli'; + +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + promises: { stat: jest.fn(), readFile: jest.fn(), access: jest.fn() }, +})); +jest.mock('os', () => ({ + ...jest.requireActual('os'), + platform: jest.fn(), + arch: jest.fn(), + homedir: jest.fn(), +})); +jest.mock('child_process', () => ({ spawn: jest.fn() })); + +const files = new Map(); +const errors = new Map(); +const stat = jest.mocked<(file: PathLike) => Promise>(fs.stat); +const readFile = jest.mocked<(file: PathLike, encoding: 'utf8') => Promise>(fs.readFile); +const access = jest.mocked(fs.access); +const spawnMock = jest.mocked<(command: string, args: readonly string[], options: SpawnOptions) => ChildProcess>(spawn); +const home = 'C:\\Users\\fixture'; +const local = `${home}\\AppData\\Local`; +const roaming = `${home}\\AppData\\Roaming`; +const signal = () => new AbortController().signal; +const runtime: CopilotCli = { command: 'C:\\Tools\\copilot.exe', args: [], source: 'standalone' }; + +function missing(file: string): Error { + return Object.assign(new Error(`Missing fixture: ${file}`), { code: 'ENOENT' }); +} + +function addFile(file: string, content = ''): void { + files.set(file, content); +} + +function setPlatform(platform: NodeJS.Platform): void { + jest.mocked(os.platform).mockReturnValue(platform); + jest.mocked(os.homedir).mockReturnValue(platform === 'win32' ? home : '/home/fixture'); +} + +function appFixture(platform: NodeJS.Platform, version = '1.0.83'): string { + setPlatform(platform); + const p = platform === 'win32' ? path.win32 : path.posix; + const root = + platform === 'win32' + ? `${local}\\Programs\\GitHub Copilot` + : platform === 'darwin' + ? '/Applications/GitHub Copilot.app/Contents' + : '/usr/lib/GitHub Copilot'; + addFile( + platform === 'win32' + ? p.join(root, 'github.exe') + : platform === 'darwin' + ? p.join(root, 'MacOS', 'github') + : '/usr/bin/github' + ); + const metadata = p.join(platform === 'darwin' ? p.join(root, 'Resources') : root, 'copilot-sdk', 'cliVersion.d.ts'); + addFile( + metadata, + `export declare const COPILOT_CLI_VERSION = "${version}";\nexport declare const COPILOT_CLI_USE_NPM_PACKAGE = false;` + ); + const cache = + platform === 'win32' ? local : platform === 'darwin' ? '/home/fixture/Library/Caches' : '/home/fixture/.cache'; + const command = p.join( + cache, + 'github-copilot-sdk', + 'cli', + version.replace(/\+/g, '_'), + platform === 'win32' ? 'copilot.exe' : 'copilot' + ); + addFile(command); + return command; +} + +function npmFixture(options: { native?: 'nested' | 'hoisted'; arch?: string; shim?: string } = {}): { + directory: string; + launcher: string; + native: string; +} { + const directory = `${roaming}\\npm`; + const packageDirectory = `${directory}\\node_modules\\@github\\copilot`; + const nativeName = `copilot-win32-${options.arch ?? 'x64'}`; + addFile(`${directory}\\${options.shim ?? 'copilot.cmd'}`); + addFile( + `${packageDirectory}\\package.json`, + JSON.stringify({ + name: '@github/copilot', + version: '1.0.83', + bin: { copilot: 'npm-loader.js' }, + optionalDependencies: { [`@github/${nativeName}`]: '1.0.83' }, + }) + ); + const launcher = `${packageDirectory}\\npm-loader.js`; + addFile(launcher); + const nativeDirectory = + options.native === 'hoisted' + ? `${directory}\\node_modules\\@github\\${nativeName}` + : `${packageDirectory}\\node_modules\\@github\\${nativeName}`; + const native = `${nativeDirectory}\\copilot.exe`; + if (options.native) { + addFile( + `${nativeDirectory}\\package.json`, + JSON.stringify({ name: `@github/${nativeName}`, version: '1.0.83' }) + ); + addFile(native); + } + return { directory, launcher, native }; +} + +function childFixture(pid: number | undefined = 4101): ChildProcess { + return Object.assign(new EventEmitter(), { + pid, + stdout: new PassThrough(), + stderr: new PassThrough(), + stdin: null, + kill: jest.fn(() => true), + }) as unknown as ChildProcess; +} + +async function flush(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +beforeEach(() => { + jest.resetAllMocks(); + jest.replaceProperty(process, 'env', { + PATH: '', + LOCALAPPDATA: local, + APPDATA: roaming, + ProgramFiles: 'C:\\Program Files', + SystemRoot: 'C:\\Windows', + COPILOT_HOME: 'C:\\Copilot Home', + }); + jest.spyOn(process, 'kill').mockReturnValue(true); + setPlatform('win32'); + jest.mocked(os.arch).mockReturnValue('x64'); + files.clear(); + errors.clear(); + stat.mockImplementation(async (file) => { + const name = String(file); + if (errors.has(name)) { + throw errors.get(name); + } + if (!files.has(name)) { + throw missing(name); + } + return { isFile: () => true } as Stats; + }); + readFile.mockImplementation(async (file) => { + const name = String(file); + if (errors.has(name)) { + throw errors.get(name); + } + const content = files.get(name); + if (content === undefined) { + throw missing(name); + } + return content; + }); + access.mockResolvedValue(undefined); +}); + +afterEach(() => { + expect(spawnMock).not.toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ shell: true }) + ); + jest.restoreAllMocks(); +}); + +describe('Copilot CLI filesystem discovery', () => { + test('prefers the first standalone PATH CLI, even when an app is installed', async () => { + appFixture('win32'); + process.env.PATH = 'C:\\First;C:\\Second'; + addFile('C:\\First\\copilot.exe'); + addFile('C:\\Second\\copilot.exe'); + await expect(findCopilotCli(signal())).resolves.toEqual({ + command: 'C:\\First\\copilot.exe', + args: [], + source: 'standalone', + }); + expect(readFile).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + test('ignores empty, relative, drive-relative, and current-drive PATH entries', async () => { + process.env.PATH = ';.;tools;C:tools;\\tools;;"C:\\Absolute Tools"'; + addFile('C:\\Absolute Tools\\copilot.exe'); + await expect(findCopilotCli(signal())).resolves.toMatchObject({ command: 'C:\\Absolute Tools\\copilot.exe' }); + expect(stat.mock.calls.map((call) => String(call[0]))).toEqual(['C:\\Absolute Tools\\copilot.exe']); + }); + + test('handles case-insensitive Windows Path and environment variable names', async () => { + delete process.env.PATH; + process.env.Path = 'C:\\Tools'; + addFile(runtime.command); + await expect(findCopilotCli(signal())).resolves.toEqual(runtime); + }); + + test.each(['nested', 'hoisted'] as const)( + 'uses the %s npm native package instead of executing a cmd shim', + async (native) => { + const fixture = npmFixture({ native }); + await expect(findCopilotCli(signal())).resolves.toEqual({ + command: fixture.native, + args: [], + source: 'standalone', + }); + expect(spawnMock).not.toHaveBeenCalled(); + } + ); + + test('resolves an arm64 native package behind a PowerShell shim', async () => { + jest.mocked(os.arch).mockReturnValue('arm64'); + const fixture = npmFixture({ native: 'nested', arch: 'arm64', shim: 'copilot.ps1' }); + await expect(findCopilotCli(signal())).resolves.toMatchObject({ command: fixture.native }); + }); + + test('uses an absolute Node executable and the actual npm launcher without a native package', async () => { + const fixture = npmFixture(); + process.env.PATH = 'relative;;C:\\Node'; + addFile('C:\\Node\\node.exe'); + await expect(findCopilotCli(signal())).resolves.toEqual({ + command: 'C:\\Node\\node.exe', + args: [fixture.launcher], + source: 'standalone', + }); + }); + + test('prefers node.exe adjacent to the npm shim', async () => { + const fixture = npmFixture(); + process.env.PATH = 'C:\\OtherNode'; + addFile('C:\\OtherNode\\node.exe'); + addFile(`${fixture.directory}\\node.exe`); + await expect(findCopilotCli(signal())).resolves.toMatchObject({ + command: `${fixture.directory}\\node.exe`, + args: [fixture.launcher], + }); + }); + + test('does not mistake an arbitrary cmd file for a usable CLI', async () => { + process.env.PATH = 'C:\\Tools'; + addFile('C:\\Tools\\copilot.cmd'); + await expect(findCopilotCli(signal())).resolves.toBeUndefined(); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + test('does not run an npm shim without a native package or an absolute Node runtime', async () => { + npmFixture(); + await expect(findCopilotCli(signal())).resolves.toBeUndefined(); + }); + + test('rejects an npm launcher that escapes its package', async () => { + const fixture = npmFixture(); + const metadata = `${fixture.directory}\\node_modules\\@github\\copilot\\package.json`; + addFile(metadata, JSON.stringify({ name: '@github/copilot', bin: { copilot: '..\\evil.js' } })); + await expect(findCopilotCli(signal())).rejects.toMatchObject({ name: 'CopilotCliMetadataError' }); + }); + + test('surfaces malformed and mismatched npm metadata', async () => { + const fixture = npmFixture({ native: 'nested' }); + addFile( + path.win32.join(path.win32.dirname(fixture.native), 'package.json'), + '{"name":"wrong","version":"9.0.0"}' + ); + await expect(findCopilotCli(signal())).rejects.toMatchObject({ name: 'CopilotCliMetadataError' }); + addFile(`${fixture.directory}\\node_modules\\@github\\copilot\\package.json`, '{'); + await expect(findCopilotCli(signal())).rejects.toBeInstanceOf(SyntaxError); + }); + + test('supports the default WinGet package location without PATH', async () => { + const command = `${local}\\Microsoft\\WinGet\\Packages\\GitHub.Copilot_Microsoft.Winget.Source_8wekyb3d8bbwe\\copilot.exe`; + addFile(command); + await expect(findCopilotCli(signal())).resolves.toEqual({ command, args: [], source: 'standalone' }); + }); + + test.each(['linux', 'darwin'] as const)( + 'supports executable POSIX native or script CLI paths on %s', + async (platform) => { + setPlatform(platform); + process.env.PATH = ':.:relative:/opt/copilot/bin'; + addFile('/opt/copilot/bin/copilot', '#!/usr/bin/env node'); + await expect(findCopilotCli(signal())).resolves.toEqual({ + command: '/opt/copilot/bin/copilot', + args: [], + source: 'standalone', + }); + expect(access).toHaveBeenCalledWith('/opt/copilot/bin/copilot', expect.any(Number)); + expect(spawnMock).not.toHaveBeenCalled(); + } + ); + + test('supports a trusted user-local POSIX install absent from PATH', async () => { + setPlatform('linux'); + addFile('/home/fixture/.local/bin/copilot'); + await expect(findCopilotCli(signal())).resolves.toMatchObject({ command: '/home/fixture/.local/bin/copilot' }); + }); + + test.each(['win32', 'darwin', 'linux'] as const)( + 'uses the installed app pin, not the newest cache on %s', + async (platform) => { + const command = appFixture(platform, '1.0.83'); + addFile(command.replace('1.0.83', '99.0.0')); + await expect(findCopilotCli(signal())).resolves.toEqual({ command, args: [], source: 'app' }); + expect(spawnMock).not.toHaveBeenCalled(); + } + ); + + test('supports a user Applications macOS app', async () => { + const command = appFixture('darwin'); + for (const [name, content] of [...files]) { + if (name.startsWith('/Applications/')) { + files.delete(name); + addFile(name.replace('/Applications/', '/home/fixture/Applications/'), content); + } + } + await expect(findCopilotCli(signal())).resolves.toMatchObject({ command, source: 'app' }); + }); + + test('respects an absolute XDG cache home without reading COPILOT_HOME', async () => { + const oldCommand = appFixture('linux'); + const command = oldCommand.replace('/home/fixture/.cache', '/custom/cache'); + files.delete(oldCommand); + addFile(command); + process.env.XDG_CACHE_HOME = '/custom/cache'; + await expect(findCopilotCli(signal())).resolves.toMatchObject({ command, source: 'app' }); + expect(readFile.mock.calls.map((call) => String(call[0]))).toEqual([ + '/usr/lib/GitHub Copilot/copilot-sdk/cliVersion.d.ts', + ]); + }); + + test('ignores a relative XDG cache home', async () => { + const command = appFixture('linux'); + process.env.XDG_CACHE_HOME = 'relative'; + await expect(findCopilotCli(signal())).resolves.toMatchObject({ command }); + }); + + test('requires installed app evidence instead of accepting a stale extracted CLI cache', async () => { + const command = appFixture('win32'); + files.delete(`${local}\\Programs\\GitHub Copilot\\github.exe`); + await expect(findCopilotCli(signal())).resolves.toBeUndefined(); + expect(stat.mock.calls.map((call) => call[0])).not.toContain(command); + expect(readFile).not.toHaveBeenCalled(); + }); + + test('returns no CLI for an installed app that has not extracted its pinned runtime', async () => { + const command = appFixture('win32'); + files.delete(command); + addFile(command.replace('1.0.83', '99.0.0')); + await expect(findCopilotCli(signal())).resolves.toBeUndefined(); + }); + + test('returns no CLI for a layout without readable pin metadata', async () => { + appFixture('win32'); + files.delete(`${local}\\Programs\\GitHub Copilot\\copilot-sdk\\cliVersion.d.ts`); + await expect(findCopilotCli(signal())).resolves.toBeUndefined(); + }); + + test('supports an all-users Windows application install', async () => { + const command = appFixture('win32'); + for (const [name, content] of [...files]) { + if (name.startsWith(`${local}\\Programs\\GitHub Copilot\\`)) { + files.delete(name); + addFile(name.replace(`${local}\\Programs`, 'C:\\Program Files'), content); + } + } + await expect(findCopilotCli(signal())).resolves.toMatchObject({ command, source: 'app' }); + }); + + test('supports an extracted Linux AppImage usr layout on an absolute PATH', async () => { + const command = appFixture('linux'); + process.env.PATH = '/opt/copilot-app/usr/bin'; + for (const [name, content] of [...files]) { + if (name.startsWith('/usr/')) { + files.delete(name); + addFile(`/opt/copilot-app${name}`, content); + } + } + await expect(findCopilotCli(signal())).resolves.toMatchObject({ command, source: 'app' }); + }); + + test.each(['../../other', '', 'latest', '1.0.83";\nexport declare const COPILOT_CLI_VERSION = "2.0.0'])( + 'rejects unsafe or ambiguous app version metadata %j', + async (version) => { + appFixture('win32'); + addFile( + `${local}\\Programs\\GitHub Copilot\\copilot-sdk\\cliVersion.d.ts`, + `export declare const COPILOT_CLI_VERSION = "${version}";` + ); + await expect(findCopilotCli(signal())).rejects.toMatchObject({ name: 'CopilotCliMetadataError' }); + } + ); + + test.each(['ENOENT', 'ENOTDIR'])('treats %s as normal absence', async (code) => { + process.env.PATH = 'C:\\Tools'; + errors.set(runtime.command, Object.assign(new Error('not present'), { code })); + await expect(findCopilotCli(signal())).resolves.toBeUndefined(); + }); + + test.each(['EACCES', 'EIO'])('preserves unexpected filesystem failure %s', async (code) => { + process.env.PATH = 'C:\\Tools'; + const error = Object.assign(new Error('filesystem failed'), { code }); + errors.set(runtime.command, error); + await expect(findCopilotCli(signal())).rejects.toBe(error); + }); + + test('preserves an executable-permission failure', async () => { + setPlatform('linux'); + addFile('/home/fixture/.local/bin/copilot'); + const error = Object.assign(new Error('not executable'), { code: 'EACCES' }); + access.mockRejectedValueOnce(error); + await expect(findCopilotCli(signal())).rejects.toBe(error); + }); + + test('does no filesystem work when already cancelled', async () => { + const controller = new AbortController(); + controller.abort(); + await expect(findCopilotCli(controller.signal)).rejects.toMatchObject({ name: 'AbortError' }); + expect(stat).not.toHaveBeenCalled(); + expect(readFile).not.toHaveBeenCalled(); + }); + + test('checks cancellation after a filesystem await before selecting a CLI', async () => { + const controller = new AbortController(); + stat.mockImplementationOnce(async () => { + controller.abort(); + return { isFile: () => true } as Stats; + }); + await expect(findCopilotCli(controller.signal)).rejects.toMatchObject({ name: 'AbortError' }); + expect(stat).toHaveBeenCalledTimes(1); + }); + + test('checks cancellation after reading app metadata', async () => { + appFixture('win32'); + const controller = new AbortController(); + readFile.mockImplementationOnce(async () => { + controller.abort(); + return 'export declare const COPILOT_CLI_VERSION = "1.0.83";'; + }); + await expect(findCopilotCli(controller.signal)).rejects.toMatchObject({ name: 'AbortError' }); + }); +}); + +describe('Copilot CLI process execution', () => { + test('uses an argument array, a neutral cwd, inherited environment, and closed stdin', async () => { + const child = childFixture(); + spawnMock.mockReturnValue(child); + const promise = runCopilotCli( + { ...runtime, args: ['C:\\Package With Spaces\\npm-loader.js'] }, + ['plugin', 'install', 'owner/repo:path;literal'], + signal() + ); + expect(spawnMock).toHaveBeenCalledWith( + runtime.command, + ['C:\\Package With Spaces\\npm-loader.js', 'plugin', 'install', 'owner/repo:path;literal'], + { + cwd: home, + env: process.env, + windowsHide: true, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + detached: false, + } + ); + child.stdout!.emit('data', Buffer.from('installed\n')); + child.stderr!.emit('data', Buffer.from('diagnostic')); + child.emit('close', 0, null); + await expect(promise).resolves.toBe('installed\n'); + expect(process.env.COPILOT_HOME).toBe('C:\\Copilot Home'); + }); + + test('preserves UTF-8 characters split across stdout chunks', async () => { + const child = childFixture(); + spawnMock.mockReturnValue(child); + const promise = runCopilotCli(runtime, ['plugin', 'list'], signal()); + const data = Buffer.from(' • dotnet'); + child.stdout!.emit('data', data.subarray(0, 3)); + child.stdout!.emit('data', data.subarray(3)); + child.emit('close', 0, null); + await expect(promise).resolves.toBe(' • dotnet'); + }); + + test('rejects a nonzero exit with useful stderr and exit details', async () => { + const child = childFixture(); + spawnMock.mockReturnValue(child); + const promise = runCopilotCli(runtime, ['plugin', 'list'], signal()); + child.stderr!.emit('data', Buffer.from('permission denied')); + child.emit('close', 7, null); + await expect(promise).rejects.toMatchObject({ + name: 'CopilotCliProcessError', + message: expect.stringContaining('7, signal null: permission denied'), + }); + }); + + test('rejects termination by a signal instead of treating it as successful empty output', async () => { + const child = childFixture(); + spawnMock.mockReturnValue(child); + const promise = runCopilotCli(runtime, ['plugin', 'list'], signal()); + child.emit('close', null, 'SIGTERM'); + await expect(promise).rejects.toMatchObject({ + name: 'CopilotCliProcessError', + message: expect.stringContaining('SIGTERM'), + }); + }); + + test('preserves spawn failures and waits for close after error', async () => { + const child = childFixture(); + Object.defineProperty(child, 'pid', { value: undefined }); + spawnMock.mockReturnValue(child); + const error = Object.assign(new Error('cannot launch'), { code: 'ENOENT' }); + const promise = runCopilotCli(runtime, [], signal()); + const result = promise.then( + () => 'resolved', + (reason) => reason + ); + let settled = false; + void result.then(() => { + settled = true; + }); + child.emit('error', error); + await flush(); + expect(settled).toBe(false); + child.emit('close', -2, null); + await expect(result).resolves.toBe(error); + }); + + test('preserves a synchronous spawn error', async () => { + const error = new Error('spawn failed'); + spawnMock.mockImplementationOnce(() => { + throw error; + }); + await expect(runCopilotCli(runtime, [], signal())).rejects.toBe(error); + }); + + test('does not spawn for a pre-aborted operation', async () => { + const controller = new AbortController(); + controller.abort(); + await expect(runCopilotCli(runtime, [], controller.signal)).rejects.toMatchObject({ name: 'AbortError' }); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + test('catches cancellation occurring during spawn before the abort listener is registered', async () => { + const child = childFixture(); + const killer = childFixture(4102); + const controller = new AbortController(); + spawnMock + .mockImplementationOnce(() => { + controller.abort(); + return child; + }) + .mockReturnValueOnce(killer); + const result = runCopilotCli(runtime, [], controller.signal).catch((error) => error); + expect(spawnMock).toHaveBeenCalledTimes(2); + child.emit('close', 1, null); + killer.emit('close', 0, null); + await expect(result).resolves.toMatchObject({ name: 'AbortError' }); + }); + + test('does not start taskkill when a failed spawn has no process ID', async () => { + const child = childFixture(); + Object.defineProperty(child, 'pid', { value: undefined }); + spawnMock.mockReturnValue(child); + const controller = new AbortController(); + const result = runCopilotCli(runtime, [], controller.signal).catch((error) => error); + controller.abort(); + child.emit('error', missing(runtime.command)); + child.emit('close', -2, null); + await expect(result).resolves.toMatchObject({ name: 'AbortError' }); + expect(spawnMock).toHaveBeenCalledTimes(1); + }); + + test.each(['child-first', 'killer-first'])( + 'waits for both CLI close and Windows tree termination (%s)', + async (order) => { + const child = childFixture(); + const killer = childFixture(4102); + spawnMock.mockReturnValueOnce(child).mockReturnValueOnce(killer); + const controller = new AbortController(); + const result = runCopilotCli(runtime, [], controller.signal).catch((error) => error); + let settled = false; + void result.then(() => { + settled = true; + }); + controller.abort(); + expect(spawnMock).toHaveBeenLastCalledWith( + 'C:\\Windows\\System32\\taskkill.exe', + ['/PID', '4101', '/T', '/F'], + expect.objectContaining({ shell: false, windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] }) + ); + expect(child.kill).not.toHaveBeenCalled(); + (order === 'child-first' ? child : killer).emit('close', 0, null); + await flush(); + expect(settled).toBe(false); + (order === 'child-first' ? killer : child).emit('close', 0, null); + await expect(result).resolves.toMatchObject({ name: 'AbortError' }); + } + ); + + test('uses a private POSIX process group to cancel CLI and Git descendants', async () => { + setPlatform('linux'); + const child = childFixture(); + spawnMock.mockReturnValue(child); + const controller = new AbortController(); + const result = runCopilotCli({ ...runtime, command: '/usr/bin/copilot' }, [], controller.signal).catch( + (error) => error + ); + controller.abort(); + expect(spawnMock).toHaveBeenCalledWith( + '/usr/bin/copilot', + [], + expect.objectContaining({ detached: true, cwd: '/home/fixture' }) + ); + expect(process.kill).toHaveBeenCalledWith(-4101, 'SIGKILL'); + child.emit('close', null, 'SIGKILL'); + await expect(result).resolves.toMatchObject({ name: 'AbortError' }); + }); + + test('tolerates an already-exited POSIX process group during cancellation', async () => { + setPlatform('linux'); + const child = childFixture(); + spawnMock.mockReturnValue(child); + jest.mocked(process.kill).mockImplementationOnce(() => { + throw Object.assign(new Error('gone'), { code: 'ESRCH' }); + }); + const controller = new AbortController(); + const result = runCopilotCli(runtime, [], controller.signal).catch((error) => error); + controller.abort(); + child.emit('close', 0, null); + await expect(result).resolves.toMatchObject({ name: 'AbortError' }); + }); + + test.each(['stdout', 'stderr'] as const)( + 'bounds %s and waits for process-tree cleanup on overflow', + async (stream) => { + const child = childFixture(); + const killer = childFixture(4102); + spawnMock.mockReturnValueOnce(child).mockReturnValueOnce(killer); + const result = runCopilotCli(runtime, [], signal()).catch((error) => error); + let settled = false; + void result.then(() => { + settled = true; + }); + child[stream]!.emit('data', Buffer.alloc(1024 * 1024 + 1)); + await flush(); + expect(settled).toBe(false); + expect(spawnMock).toHaveBeenCalledTimes(2); + child.emit('close', 1, null); + killer.emit('close', 0, null); + await expect(result).resolves.toMatchObject({ name: 'CopilotCliOutputLimitError' }); + } + ); + + test('applies one combined output limit to stdout and stderr', async () => { + const child = childFixture(); + const killer = childFixture(4102); + spawnMock.mockReturnValueOnce(child).mockReturnValueOnce(killer); + const result = runCopilotCli(runtime, [], signal()).catch((error) => error); + child.stdout!.emit('data', Buffer.alloc(600 * 1024)); + child.stderr!.emit('data', Buffer.alloc(600 * 1024)); + child.emit('close', 1, null); + killer.emit('close', 0, null); + await expect(result).resolves.toMatchObject({ name: 'CopilotCliOutputLimitError' }); + }); + + test('surfaces Windows tree-kill failures and still waits for the child', async () => { + const child = childFixture(); + const killer = childFixture(4102); + spawnMock.mockReturnValueOnce(child).mockReturnValueOnce(killer); + const controller = new AbortController(); + const result = runCopilotCli(runtime, [], controller.signal).catch((error) => error); + let settled = false; + void result.then(() => { + settled = true; + }); + controller.abort(); + killer.stderr!.emit('data', Buffer.from('access denied')); + killer.emit('close', 1, null); + await flush(); + expect(child.kill).toHaveBeenCalledWith('SIGKILL'); + expect(settled).toBe(false); + child.emit('close', 1, null); + await expect(result).resolves.toMatchObject({ + name: 'CopilotCliTerminationError', + cause: expect.any(AggregateError), + }); + }); + + test('reports taskkill spawn failure without releasing the gate before child close', async () => { + const child = childFixture(); + const killer = childFixture(4102); + spawnMock.mockReturnValueOnce(child).mockReturnValueOnce(killer); + const controller = new AbortController(); + const result = runCopilotCli(runtime, [], controller.signal).catch((error) => error); + controller.abort(); + killer.emit('error', new Error('taskkill unavailable')); + killer.emit('close', -2, null); + await flush(); + expect(child.kill).toHaveBeenCalledWith('SIGKILL'); + child.emit('close', 1, null); + await expect(result).resolves.toMatchObject({ name: 'CopilotCliTerminationError' }); + }); + + test('removes the abort listener after completion, so later cancellation cannot kill a reused PID', async () => { + const child = childFixture(); + spawnMock.mockReturnValue(child); + const controller = new AbortController(); + const promise = runCopilotCli(runtime, [], controller.signal); + child.emit('close', 0, null); + await promise; + controller.abort(); + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(process.kill).not.toHaveBeenCalled(); + }); +}); + +describe('Copilot plain-text plugin inventory', () => { + test('parses marketplace identities, disabled plugins, and built-in plugins', () => { + expect( + parsePluginList( + 'Installed plugins:\n • dotnet-dnceng@dotnet-arcade-skills (v0.1.0)\n • dotnet-test@dotnet-agent-skills (v0.1.0) [disabled]\n • dotnet@dotnet-agent-skills (v0.1.0)\n\nBuilt-in Plugins (bundled with the CLI):\n • computer-use\n' + ) + ).toEqual([ + { name: 'dotnet-dnceng@dotnet-arcade-skills', enabled: true, kind: 'installed' }, + { name: 'dotnet-test@dotnet-agent-skills', enabled: false, kind: 'installed' }, + { name: 'dotnet@dotnet-agent-skills', enabled: true, kind: 'installed' }, + { name: 'computer-use', enabled: true, kind: 'builtin' }, + ]); + }); + + test('supports direct installs, ANSI colors, CRLF, and prerelease versions', () => { + expect( + parsePluginList( + '\x1b[1mInstalled plugins:\x1b[0m\r\n • dotnet (v0.2.4)\r\n • other (v1.2.3-preview.1+build.2) [disabled]\r\n' + ) + ).toEqual([ + { name: 'dotnet', enabled: true, kind: 'installed' }, + { name: 'other', enabled: false, kind: 'installed' }, + ]); + }); + + test.each([ + 'No plugins installed.', + "No plugins installed.\n\nUse 'copilot plugin install ' to install a plugin.\n", + ])('accepts the explicit empty inventory format %j', (output) => { + expect(parsePluginList(output)).toEqual([]); + }); + + test('keeps external plugins separate from installed plugins', () => { + expect( + parsePluginList('No plugins installed.\nExternal Plugins (via --plugin-dir):\n • dotnet (v1.0.0)\n') + ).toEqual([{ name: 'dotnet', enabled: true, kind: 'external' }]); + }); + + test('allows explicit empty installed inventory with bundled plugins', () => { + expect( + parsePluginList('No plugins installed.\nBuilt-in Plugins (bundled with the CLI):\n • computer-use\n') + ).toEqual([{ name: 'computer-use', enabled: true, kind: 'builtin' }]); + }); + + test.each([ + '', + ' \n', + '[]', + '{"plugins":[]}', + 'Warning: could not read plugins', + 'Installed plugins:', + 'Installed plugins:\n • dotnet (v0.', + 'Installed plugins:\n • dotnet (v1.2.3', + 'Installed plugins:\n • dotnet [dis', + 'Installed plugins:\n • dotnet [unknown]', + 'Installed plugins:\n • dotnet (v1.0.0)\n • broken (', + 'Installed plugins:\n • dotnet\nUnknown plugins:\n • other', + 'Installed plugins:\n • dotnet\nBuilt-in Plugins (bundled with the CLI):', + 'Installed plugins:\n • dotnet\nInstalled plugins:\n • other', + 'Installed plugins:\n • dotnet\n • dotnet', + 'No plugins installed.\nInstalled plugins:\n • dotnet', + 'No plugins installed.\nNo plugins installed.', + "Use 'copilot plugin install ' to install a plugin.", + 'No plugins installed.\nUnknown error', + 'Built-in Plugins (bundled with the CLI):\n • computer-use', + ' • dotnet (v1.0.0)', + 'Installed plugins:\n • dotnet@@market', + 'Installed plugins:\n • ../dotnet', + 'Installed plugins:\n • dotnet\n description: extra unknown output', + ])('rejects malformed, truncated, or ambiguous inventory %j', (output) => { + expect(() => parsePluginList(output)).toThrow(expect.objectContaining({ name: 'CopilotPluginInventoryError' })); + }); +}); diff --git a/test/lsptoolshost/unitTests/dotnetPlugin.test.ts b/test/lsptoolshost/unitTests/dotnetPlugin.test.ts new file mode 100644 index 0000000000..d2f72950ae --- /dev/null +++ b/test/lsptoolshost/unitTests/dotnetPlugin.test.ts @@ -0,0 +1,521 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals'; +import * as vscode from 'vscode'; +import * as cli from '../../../src/shared/copilot/copilotCli'; +import { + DotnetPluginManager, + dotnetPluginCacheKey, + dotnetPluginOptOutKey, + registerDotnetPlugin, + uninstallDotnetPluginCommand, +} from '../../../src/shared/copilot/dotnetPlugin'; +import { commonOptions } from '../../../src/shared/options'; +import { TelemetryEventNames } from '../../../src/shared/telemetryEventNames'; + +jest.mock('vscode', () => ({ + workspace: { + get isTrusted() { + return true; + }, + }, + window: { showInformationMessage: jest.fn(), showWarningMessage: jest.fn() }, + commands: { registerCommand: jest.fn() }, + env: { openExternal: jest.fn() }, + Uri: { parse: (value: string) => value }, + l10n: { t: (value: string) => value }, + ExtensionMode: { Production: 1, Development: 2, Test: 3 }, +})); +jest.mock('../../../src/shared/options', () => ({ + commonOptions: { + get disableAIFeatures() { + return false; + }, + }, +})); +jest.mock('../../../src/shared/copilot/copilotCli', () => ({ + findCopilotCli: jest.fn(), + runCopilotCli: jest.fn(), + parsePluginList: jest.fn(), +})); + +const find = jest.mocked(cli.findCopilotCli); +const run = jest.mocked(cli.runCopilotCli); +const parse = jest.mocked(cli.parsePluginList); +const showInformation = jest.mocked<(message: string, ...items: string[]) => Thenable>( + vscode.window.showInformationMessage +); +const runtime: cli.CopilotCli = { command: 'copilot', args: [], source: 'standalone' }; +const plugin: cli.CopilotPlugin = { name: 'dotnet', enabled: true, kind: 'installed' }; +const managers: DotnetPluginManager[] = []; + +class MemoryState implements vscode.Memento { + readonly values = new Map(); + readonly update = jest.fn(async (key: string, value: unknown) => { + if (value === undefined) { + this.values.delete(key); + } else { + this.values.set(key, value); + } + }); + keys(): readonly string[] { + return [...this.values.keys()]; + } + get(key: string): T | undefined; + get(key: string, fallback: T): T; + get(key: string, fallback?: T): T | undefined { + return this.values.has(key) ? (this.values.get(key) as T) : fallback; + } +} + +function fixture() { + const state = new MemoryState(); + const context = { + globalState: state, + extension: { packageJSON: { version: '1.2.3' } }, + subscriptions: new Array(), + extensionMode: vscode.ExtensionMode.Production, + }; + const reporter = { sendTelemetryEvent: jest.fn(), sendTelemetryErrorEvent: jest.fn() }; + const channel = { error: jest.fn(), info: jest.fn() }; + const manager = new DotnetPluginManager(context, reporter, channel); + managers.push(manager); + return { state, context, reporter, channel, manager }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +beforeEach(() => { + jest.resetAllMocks(); + jest.useFakeTimers(); + find.mockResolvedValue(runtime); + run.mockResolvedValue('inventory'); + parse.mockReturnValue([plugin]); + jest.mocked(vscode.env.openExternal).mockResolvedValue(true); + jest.mocked(vscode.commands.registerCommand).mockReturnValue({ dispose: jest.fn() }); +}); + +afterEach(() => { + managers.splice(0).forEach((manager) => manager.dispose()); + jest.restoreAllMocks(); + jest.useRealTimers(); +}); + +describe('Copilot .NET plugin lifecycle', () => { + test('installs and confirms before caching and notifying', async () => { + const { manager, state, reporter } = fixture(); + parse.mockReturnValueOnce([]).mockReturnValueOnce([plugin]); + await manager.install(); + expect(run.mock.calls.map((call) => call[1])).toEqual([ + ['plugin', 'list'], + ['plugin', 'install', 'dotnet/skills:plugins/dotnet'], + ['plugin', 'list'], + ]); + expect(state.get(dotnetPluginCacheKey)).toEqual({ + extensionVersion: '1.2.3', + outcome: 'alreadyInstalled', + source: 'standalone', + }); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + 'Installed the C# LSP .NET plugin for GitHub Copilot', + 'Learn More' + ); + expect(reporter.sendTelemetryEvent).toHaveBeenCalledTimes(1); + expect(reporter.sendTelemetryEvent).toHaveBeenCalledWith(TelemetryEventNames.CopilotDotnetPlugin, { + outcome: 'installed', + source: 'standalone', + cached: 'false', + }); + expect(jest.getTimerCount()).toBe(0); + }); + + test.each(['alreadyInstalled', 'alreadyInstalledDisabled', 'conflictingPlugin'])( + 'cached %s skips discovery and all CLI calls', + async (outcome) => { + const { manager, state, reporter } = fixture(); + state.values.set(dotnetPluginCacheKey, { extensionVersion: '1.2.3', outcome, source: 'app' }); + await manager.install(); + expect(find).not.toHaveBeenCalled(); + expect(run).not.toHaveBeenCalled(); + expect(state.update).not.toHaveBeenCalled(); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + expect(reporter.sendTelemetryEvent).toHaveBeenCalledWith(TelemetryEventNames.CopilotDotnetPlugin, { + outcome, + source: 'app', + cached: 'true', + }); + } + ); + + test.each([ + ['0.1.0', 'alreadyInstalled'], + ['9.0.0', 'alreadyInstalled'], + ['1.2.3', 'invalid'], + ])('invalidates old or malformed cache (%s, %s)', async (extensionVersion, outcome) => { + const { manager, state } = fixture(); + state.values.set(dotnetPluginCacheKey, { extensionVersion, outcome, source: 'app' }); + await manager.install(); + expect(state.update).toHaveBeenNthCalledWith(1, dotnetPluginCacheKey, undefined); + expect(find).toHaveBeenCalledTimes(1); + expect(state.get(dotnetPluginCacheKey)).toEqual({ + extensionVersion: '1.2.3', + outcome: 'alreadyInstalled', + source: 'standalone', + }); + }); + + test.each<[cli.CopilotPlugin, string]>([ + [plugin, 'alreadyInstalled'], + [{ ...plugin, name: 'dotnet@dotnet-agent-skills', enabled: false }, 'alreadyInstalledDisabled'], + [{ ...plugin, name: 'dotnet@different-marketplace' }, 'conflictingPlugin'], + [{ ...plugin, kind: 'builtin' }, 'conflictingPlugin'], + ])('preserves and caches existing plugin %j', async (existing, outcome) => { + const { manager, state } = fixture(); + parse.mockReturnValue([existing]); + await manager.install(); + expect(run).toHaveBeenCalledTimes(1); + expect(state.get(dotnetPluginCacheKey)).toMatchObject({ outcome }); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + }); + + test('unavailable Copilot stays uncached and is discovered on the next activation', async () => { + const { manager, state, context, reporter, channel } = fixture(); + find.mockResolvedValueOnce(undefined); + await manager.install(); + expect(run).not.toHaveBeenCalled(); + expect(state.update).not.toHaveBeenCalled(); + expect(reporter.sendTelemetryEvent).toHaveBeenCalledWith(TelemetryEventNames.CopilotDotnetPlugin, { + outcome: 'copilotNotAvailable', + source: 'none', + cached: 'false', + }); + const next = new DotnetPluginManager(context, reporter, channel); + managers.push(next); + await next.install(); + expect(find).toHaveBeenCalledTimes(2); + expect(run).toHaveBeenCalledTimes(1); + }); + + test.each(['optedOut', 'aiDisabled', 'untrustedWorkspace'])('cheap gate %s precedes cache', async (outcome) => { + const { manager, state, context, reporter } = fixture(); + state.values.set(dotnetPluginCacheKey, { + extensionVersion: '1.2.3', + outcome: 'alreadyInstalled', + source: 'app', + }); + if (outcome === 'optedOut') { + state.values.set(dotnetPluginOptOutKey, true); + context.extension.packageJSON.version = '2.0.0'; + } else if (outcome === 'aiDisabled') { + jest.spyOn(commonOptions, 'disableAIFeatures', 'get').mockReturnValue(true); + } else { + jest.spyOn(vscode.workspace, 'isTrusted', 'get').mockReturnValue(false); + } + await manager.install(); + expect(find).not.toHaveBeenCalled(); + expect(state.update).not.toHaveBeenCalled(); + expect(reporter.sendTelemetryEvent).toHaveBeenCalledWith(TelemetryEventNames.CopilotDotnetPlugin, { + outcome, + source: 'none', + cached: 'false', + }); + }); + + test.each(['discovery', 'inventory', 'install', 'confirmation'])( + 'failure during %s is not cached', + async (stage) => { + const { manager, state, reporter, channel } = fixture(); + const error = new Error('private path or output'); + if (stage === 'discovery') { + find.mockRejectedValue(error); + } else if (stage === 'inventory') { + parse.mockImplementation(() => { + throw error; + }); + } else if (stage === 'install') { + parse.mockReturnValue([]); + run.mockResolvedValueOnce('empty').mockRejectedValueOnce(error); + } else { + parse.mockReturnValue([]); + } + await manager.install(); + expect(state.update).not.toHaveBeenCalled(); + expect(channel.error).toHaveBeenCalled(); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + expect(reporter.sendTelemetryEvent).toHaveBeenCalledTimes(1); + expect(reporter.sendTelemetryEvent.mock.calls[0][1]).toMatchObject({ outcome: 'installFailed' }); + expect(reporter.sendTelemetryErrorEvent.mock.calls[0][1]).toMatchObject({ + stage, + outcome: 'installFailed', + }); + expect(JSON.stringify(reporter.sendTelemetryErrorEvent.mock.calls)).not.toContain('private path'); + } + ); + + test('cache write failure does not turn successful installation into failure', async () => { + const { manager, state, reporter } = fixture(); + parse.mockReturnValueOnce([]).mockReturnValueOnce([plugin]); + state.update.mockRejectedValueOnce(new Error('storage unavailable')); + await manager.install(); + expect(reporter.sendTelemetryEvent.mock.calls[0][1]).toMatchObject({ outcome: 'installed' }); + expect(reporter.sendTelemetryEvent).toHaveBeenCalledTimes(1); + expect(reporter.sendTelemetryErrorEvent.mock.calls[0][1]).toMatchObject({ + stage: 'cache', + outcome: 'installed', + }); + expect(vscode.window.showInformationMessage).toHaveBeenCalled(); + }); + + test('uses one overall timeout and cancels a running command', async () => { + const { manager, state, reporter } = fixture(); + const started = deferred(); + run.mockImplementation(async (_cli, _args, signal) => { + started.resolve(signal); + return await new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }); + const pending = manager.install(); + const signal = await started.promise; + await jest.advanceTimersByTimeAsync(120_000); + await pending; + expect(signal.aborted).toBe(true); + expect(state.update).not.toHaveBeenCalled(); + expect(reporter.sendTelemetryErrorEvent.mock.calls[0][1]).toMatchObject({ 'error.name': 'TimeoutError' }); + expect(jest.getTimerCount()).toBe(0); + }); + + test('discovery timeout prevents a late result from starting CLI work', async () => { + const { manager, reporter } = fixture(); + const found = deferred(); + const started = deferred(); + find.mockImplementation(async () => { + started.resolve(); + return await found.promise; + }); + const pending = manager.install(); + await started.promise; + await jest.advanceTimersByTimeAsync(120_000); + await pending; + found.resolve(runtime); + await Promise.resolve(); + expect(run).not.toHaveBeenCalled(); + expect(reporter.sendTelemetryEvent.mock.calls[0][1]).toMatchObject({ outcome: 'installFailed' }); + }); + + test('uninstall bypasses cached results and persists opt-out across versions', async () => { + const { manager, state, context, reporter, channel } = fixture(); + state.values.set(dotnetPluginCacheKey, { + extensionVersion: '1.2.3', + outcome: 'conflictingPlugin', + source: 'app', + }); + parse.mockReturnValueOnce([{ ...plugin, name: 'dotnet@dotnet-agent-skills' }]).mockReturnValueOnce([]); + await manager.uninstall(); + expect(state.update).toHaveBeenNthCalledWith(1, dotnetPluginOptOutKey, true); + expect(state.get(dotnetPluginCacheKey)).toBeUndefined(); + expect(run.mock.calls.map((call) => call[1])).toEqual([ + ['plugin', 'list'], + ['plugin', 'uninstall', 'dotnet@dotnet-agent-skills'], + ['plugin', 'list'], + ]); + expect(reporter.sendTelemetryEvent).toHaveBeenCalledWith(TelemetryEventNames.CopilotDotnetPluginUninstall, { + outcome: 'uninstalled', + source: 'standalone', + }); + context.extension.packageJSON.version = '2.0.0'; + const next = new DotnetPluginManager(context, reporter, channel); + managers.push(next); + find.mockClear(); + await next.install(); + expect(find).not.toHaveBeenCalled(); + expect(reporter.sendTelemetryEvent).toHaveBeenLastCalledWith(TelemetryEventNames.CopilotDotnetPlugin, { + outcome: 'optedOut', + source: 'none', + cached: 'false', + }); + }); + + test.each(['alreadyAbsent', 'copilotNotAvailable', 'uninstallFailed'])( + 'uninstall %s leaves the durable opt-out set', + async (outcome) => { + const { manager, state, reporter } = fixture(); + if (outcome === 'alreadyAbsent') { + parse.mockReturnValue([]); + } + if (outcome === 'copilotNotAvailable') { + find.mockResolvedValue(undefined); + } + if (outcome === 'uninstallFailed') { + run.mockRejectedValue(new Error('failed')); + } + await manager.uninstall(); + expect(state.get(dotnetPluginOptOutKey)).toBe(true); + expect(reporter.sendTelemetryEvent.mock.calls[0][0]).toBe(TelemetryEventNames.CopilotDotnetPluginUninstall); + expect(reporter.sendTelemetryEvent.mock.calls[0][1]).toMatchObject({ outcome }); + } + ); + + test('failed opt-out persistence aborts removal', async () => { + const { manager, state, reporter } = fixture(); + state.update.mockRejectedValueOnce(new Error('storage failed')); + await manager.uninstall(); + expect(find).not.toHaveBeenCalled(); + expect(run).not.toHaveBeenCalled(); + expect(reporter.sendTelemetryErrorEvent.mock.calls[0][1]).toMatchObject({ stage: 'optOut' }); + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + 'Could not disable automatic installation. See the C# output for details.' + ); + }); + + test('uninstall waits for active installation and suppresses its toast/cache', async () => { + const { manager, state, reporter } = fixture(); + const started = deferred(); + const installed = deferred(); + run.mockImplementation(async (_cli, args) => { + if (args[1] === 'install') { + started.resolve(); + return await installed.promise; + } + return 'inventory'; + }); + parse + .mockReturnValueOnce([]) + .mockReturnValueOnce([plugin]) + .mockReturnValueOnce([plugin]) + .mockReturnValueOnce([]); + const installing = manager.install(); + await started.promise; + const uninstalling = manager.uninstall(); + expect(run.mock.calls.some((call) => call[1][1] === 'uninstall')).toBe(false); + installed.resolve('installed'); + await Promise.all([installing, uninstalling]); + expect(run.mock.calls.map((call) => call[1][1])).toEqual([ + 'list', + 'install', + 'list', + 'list', + 'uninstall', + 'list', + ]); + expect(state.get(dotnetPluginCacheKey)).toBeUndefined(); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalledWith( + 'Installed the C# LSP .NET plugin for GitHub Copilot', + 'Learn More' + ); + expect(reporter.sendTelemetryEvent.mock.calls.map((call) => call[0])).toEqual([ + TelemetryEventNames.CopilotDotnetPlugin, + TelemetryEventNames.CopilotDotnetPluginUninstall, + ]); + }); + + test('pending uninstall prevents queued automatic installation', async () => { + const { manager } = fixture(); + parse.mockReturnValue([]); + await Promise.all([manager.install(), manager.uninstall()]); + expect(run.mock.calls.map((call) => call[1])).toEqual([['plugin', 'list']]); + }); + + test('disposal cancels background work and suppresses notifications', async () => { + const { manager, state } = fixture(); + const started = deferred(); + find.mockImplementation(async (signal) => { + started.resolve(signal); + return await new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }); + const pending = manager.install(); + const signal = await started.promise; + manager.dispose(); + await pending; + expect(signal.aborted).toBe(true); + expect(run).not.toHaveBeenCalled(); + expect(state.update).not.toHaveBeenCalled(); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + }); + + test.each(['dismissed', 'opened', 'refused', 'throws'])( + 'documentation action %s cannot change install outcome', + async (action) => { + const { manager, reporter, channel } = fixture(); + parse.mockReturnValueOnce([]).mockReturnValueOnce([plugin]); + showInformation.mockResolvedValue(action === 'dismissed' ? undefined : 'Learn More'); + if (action === 'refused') { + jest.mocked(vscode.env.openExternal).mockResolvedValue(false); + } + if (action === 'throws') { + jest.mocked(vscode.env.openExternal).mockRejectedValue(new Error('browser failed')); + } + await manager.install(); + await Promise.resolve(); + expect(reporter.sendTelemetryEvent).toHaveBeenCalledTimes(1); + expect(reporter.sendTelemetryEvent.mock.calls[0][1]).toMatchObject({ outcome: 'installed' }); + if (action === 'dismissed') { + expect(vscode.env.openExternal).not.toHaveBeenCalled(); + } else { + expect(vscode.env.openExternal).toHaveBeenCalledWith( + 'https://github.com/dotnet/vscode-csharp/blob/main/docs/Copilot-Dotnet-Plugin.md' + ); + } + if (action === 'refused' || action === 'throws') { + expect(channel.error).toHaveBeenCalled(); + } + } + ); + + test('telemetry failures are logged without recursion or escaping', async () => { + const { manager, reporter, channel } = fixture(); + reporter.sendTelemetryEvent.mockImplementation(() => { + throw new Error('telemetry failed'); + }); + reporter.sendTelemetryErrorEvent.mockImplementation(() => { + throw new Error('telemetry failed'); + }); + find.mockRejectedValue(new Error('discovery failed')); + await expect(manager.install()).resolves.toBeUndefined(); + expect(reporter.sendTelemetryEvent).toHaveBeenCalledTimes(1); + expect(reporter.sendTelemetryErrorEvent).toHaveBeenCalledTimes(1); + expect(channel.error).toHaveBeenCalled(); + }); + + test('registration returns before deferred work and owns its disposables', async () => { + const { context, reporter, channel } = fixture(); + const install = jest.spyOn(DotnetPluginManager.prototype, 'install').mockResolvedValue(); + const uninstall = jest.spyOn(DotnetPluginManager.prototype, 'uninstall').mockResolvedValue(); + expect(registerDotnetPlugin(context, reporter, channel)).toBeUndefined(); + expect(install).not.toHaveBeenCalled(); + expect(vscode.commands.registerCommand).toHaveBeenCalledWith( + uninstallDotnetPluginCommand, + expect.any(Function) + ); + await jest.advanceTimersByTimeAsync(0); + expect(install).toHaveBeenCalledTimes(1); + const handler = jest.mocked(vscode.commands.registerCommand).mock.calls[0][1]; + await handler(); + expect(uninstall).toHaveBeenCalledTimes(1); + context.subscriptions.forEach((subscription) => subscription.dispose()); + }); + + test('test extension hosts never start automatic plugin operations', async () => { + const { context, reporter, channel } = fixture(); + context.extensionMode = vscode.ExtensionMode.Test; + const install = jest.spyOn(DotnetPluginManager.prototype, 'install'); + registerDotnetPlugin(context, reporter, channel); + await jest.advanceTimersByTimeAsync(0); + expect(install).not.toHaveBeenCalled(); + expect(find).not.toHaveBeenCalled(); + context.subscriptions.forEach((subscription) => subscription.dispose()); + }); +}); From 2b42d1b131f7f4b0bf3815f81f18465dacb9fee5 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Thu, 17 Sep 2026 11:48:29 -0700 Subject: [PATCH 2/5] simplify... --- docs/Copilot-Dotnet-Plugin.md | 41 +- src/shared/copilot/copilotCli.ts | 433 ++--------- src/shared/copilot/dotnetPlugin.ts | 592 ++++++-------- .../lsptoolshost/unitTests/copilotCli.test.ts | 724 ++++-------------- .../unitTests/dotnetPlugin.test.ts | 395 +++++----- 5 files changed, 641 insertions(+), 1544 deletions(-) diff --git a/docs/Copilot-Dotnet-Plugin.md b/docs/Copilot-Dotnet-Plugin.md index ce34ea878f..e200e11075 100644 --- a/docs/Copilot-Dotnet-Plugin.md +++ b/docs/Copilot-Dotnet-Plugin.md @@ -1,44 +1,20 @@ # C# LSP .NET plugin for GitHub Copilot -The C# extension automatically installs the [.NET team's `dotnet` plugin](https://github.com/dotnet/skills/tree/main/plugins/dotnet) when a compatible GitHub Copilot CLI or GitHub Copilot app runtime is available on the machine running the extension. +The C# extension automatically installs the [official .NET `dotnet` plugin](https://github.com/dotnet/skills/tree/main/plugins/dotnet) when a compatible GitHub Copilot CLI is available on PATH or a GitHub Copilot app runtime is installed on the machine running the extension. ## Why it is installed -The plugin provides .NET development skills and a C# language-server declaration for GitHub Copilot. These help Copilot work with .NET projects and use C# language intelligence. Only the base `dotnet` plugin is installed, not the other plugins in the `dotnet/skills` repository. +The plugin provides .NET development skills and a C# language-server declaration for GitHub Copilot. These help Copilot work with .NET projects and use C# language intelligence. The plugin's C# language server requires the **.NET 10 SDK** and `dotnet` on PATH. Installing the plugin does not install that SDK or start the language server. Start a new Copilot session, or restart an existing one, to load the plugin. -This is separate from the C# extension's own language server and from VS Code Chat plugins. - -## How installation works - -Installation runs in the background and does not delay C# extension startup or language-server initialization. The extension uses: - -```text -copilot plugin install dotnet/skills:plugins/dotnet -``` - -The extension prefers an available standalone CLI, otherwise it looks for the installed GitHub app's extracted CLI. It does not install Copilot or launch the app. If the app has never extracted its CLI, installation is skipped; a later VS Code launch can try again after the app has been used. - -App discovery supports standard Windows installation folders, macOS Applications folders, and Linux packaged or extracted app layouts. Nonstandard app locations and opaque Linux AppImages may not be discoverable; a standalone Copilot CLI on PATH can be used in those cases. - -Copilot controls where plugins are installed. The subprocess inherits the extension host's environment, including `COPILOT_HOME`; the normal default is the user's `.copilot` directory. The app and standalone CLI share the plugin when they use the same configuration directory. A configuration override used only by an already-running app is not inherited by a subprocess started by VS Code. - -In Remote SSH, WSL, and dev-container workspaces, only Copilot on the **extension host** is considered. The extension does not install into a separate local desktop host. - -Existing installations, including disabled plugins, are left unchanged. The extension does not update or re-enable them, and it leaves conflicting same-name plugins alone. Automatic installation is skipped in untrusted workspaces and when the existing VS Code `chat.disableAIFeatures` setting is enabled. - -Installed and conflicting-plugin results are cached privately for the current C# extension version to avoid repeated CLI launches. An extension version change invalidates the cache. External plugin removal, enablement changes, or resolution of a conflict might therefore not be noticed until the next extension update. Unavailable runtimes and failures are not cached. - ## Uninstall and prevent automatic reinstallation Open the Command Palette and run: **.NET: Uninstall Copilot C# LSP plugin** -This command records a private opt-out in VS Code's extension state, then checks the current CLI inventory and uninstalls the plugin. It always bypasses the automatic-install cache. The opt-out persists across C# extension updates and is not synced to other machines. - -If removal fails or Copilot is unavailable, the opt-out remains in effect as long as it was saved successfully. The command reports any failure; see the **C#** output channel for details. +This command uninstalls the `dotnet` plugin if it exists and opts out of automatic installation. You can also remove the plugin directly from a terminal: @@ -52,14 +28,13 @@ For a marketplace installation, use: copilot plugin uninstall dotnet@dotnet-agent-skills ``` -**Removing it only through Copilot CLI does not opt out of the C# extension's automatic installation.** Use the extension's uninstall command to prevent reinstallation, even if the plugin has already been removed. - -To use the plugin again, install it manually with the installation command above. This does not clear the extension's automatic-install opt-out. There is no additional public C# setting for this feature. +To use the plugin again, install it manually with the installation command below. +```text +copilot plugin install dotnet/skills:plugins/dotnet +``` ## Troubleshooting Open **View > Output** and select **C#**. Discovery, inventory, installation, and removal failures are logged without affecting normal C# features. Each install or uninstall operation has an overall two-minute timeout. -If installation is skipped, make sure Copilot is installed on the extension host. For the GitHub app, use the app once so its CLI can be extracted, then restart VS Code. An incompatible CLI listing format, unavailable Git/network access, permissions, or organization policy can prevent installation. The extension does not change credentials, install missing prerequisites, or bypass policy. - -Installation status, cached status, skips, failures, and manual uninstall outcomes are reported through the extension's existing telemetry mechanism, subject to VS Code telemetry controls. These events do not include CLI output, file paths, configuration contents, or credentials. +If installation is skipped, make sure the Copilot CLI is available on PATH on the extension host. For the GitHub Copilot app, use the app once so its CLI can be extracted, then restart VS Code. An incompatible CLI listing format, unavailable Git/network access, permissions, or organization policy can prevent installation. diff --git a/src/shared/copilot/copilotCli.ts b/src/shared/copilot/copilotCli.ts index 0487972569..1f6403ad0b 100644 --- a/src/shared/copilot/copilotCli.ts +++ b/src/shared/copilot/copilotCli.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ChildProcess, spawn } from 'child_process'; -import { constants, promises as fs } from 'fs'; +import { execFile } from 'child_process'; +import { existsSync, promises as fs } from 'fs'; import * as os from 'os'; import * as path from 'path'; import { stripVTControlCharacters } from 'util'; @@ -13,7 +13,6 @@ export type CopilotCliSource = 'standalone' | 'app'; export interface CopilotCli { command: string; - args: readonly string[]; source: CopilotCliSource; } @@ -23,60 +22,6 @@ export interface CopilotPlugin { kind: 'installed' | 'builtin' | 'external'; } -function namedError(name: string, message: string, cause?: unknown): Error { - return Object.assign(new Error(message, { cause }), { name }); -} - -function isMissing(error: unknown): boolean { - const code = (error as NodeJS.ErrnoException | undefined)?.code; - return code === 'ENOENT' || code === 'ENOTDIR'; -} - -async function fileExists(file: string, signal: AbortSignal, executable = false): Promise { - signal.throwIfAborted(); - try { - const stat = await fs.stat(file); - signal.throwIfAborted(); - if (!stat.isFile()) { - return false; - } - if (executable && os.platform() !== 'win32') { - await fs.access(file, constants.X_OK); - signal.throwIfAborted(); - } - return true; - } catch (error) { - signal.throwIfAborted(); - if (isMissing(error)) { - return false; - } - throw error; - } -} - -async function readMetadata(file: string, signal: AbortSignal): Promise { - signal.throwIfAborted(); - try { - const text = await fs.readFile(file, 'utf8'); - signal.throwIfAborted(); - return text; - } catch (error) { - signal.throwIfAborted(); - if (isMissing(error)) { - return undefined; - } - throw error; - } -} - -function environment(name: string): string | undefined { - if (os.platform() !== 'win32') { - return process.env[name]; - } - const key = Object.keys(process.env).find((key) => key.toLowerCase() === name.toLowerCase()); - return key ? process.env[key] : undefined; -} - function platformPath(): typeof path.win32 { return os.platform() === 'win32' ? path.win32 : path.posix; } @@ -90,136 +35,36 @@ function absolute(value: string | undefined): value is string { } function environmentPath(name: string): string | undefined { - const value = environment(name); + // process.env already resolves names case-insensitively on Windows. + const value = process.env[name]; return absolute(value) ? value : undefined; } function pathDirectories(): string[] { - return (environment('PATH') ?? '') + return (process.env.PATH ?? '') .split(platformPath().delimiter) .map((directory) => directory.trim().replace(/^"(.*)"$/, '$1')) .filter(absolute); } -async function npmCli( - directory: string, - directories: readonly string[], - signal: AbortSignal -): Promise { - const p = platformPath(); - const packageDirectory = p.join(directory, 'node_modules', '@github', 'copilot'); - const metadata = await readMetadata(p.join(packageDirectory, 'package.json'), signal); - if (metadata === undefined) { - return undefined; - } - const packageJson = JSON.parse(metadata); - const launcher = typeof packageJson.bin === 'string' ? packageJson.bin : packageJson.bin?.copilot; - if (packageJson.name !== '@github/copilot' || typeof launcher !== 'string') { - throw namedError('CopilotCliMetadataError', `Invalid Copilot npm package metadata in ${packageDirectory}`); - } - const launcherPath = p.resolve(packageDirectory, launcher); - const relativeLauncher = p.relative(packageDirectory, launcherPath); - if (relativeLauncher.startsWith('..') || p.isAbsolute(relativeLauncher) || !/\.[cm]?js$/i.test(launcherPath)) { - throw namedError('CopilotCliMetadataError', `Invalid Copilot npm launcher in ${packageDirectory}`); - } - if (!(await fileExists(launcherPath, signal))) { - return undefined; - } - - // npm may keep optional packages nested or hoist them beside @github/copilot. - const nativeName = `copilot-win32-${os.arch()}`; - const nativeVersion = packageJson.optionalDependencies?.[`@github/${nativeName}`]; - if (typeof nativeVersion === 'string') { - for (const root of [p.join(packageDirectory, 'node_modules', '@github'), p.dirname(packageDirectory)]) { - const nativeDirectory = p.join(root, nativeName); - const nativeMetadata = await readMetadata(p.join(nativeDirectory, 'package.json'), signal); - if (nativeMetadata === undefined) { - continue; - } - const nativePackage = JSON.parse(nativeMetadata); - if (nativePackage.name !== `@github/${nativeName}` || nativePackage.version !== nativeVersion) { - throw namedError( - 'CopilotCliMetadataError', - `Mismatched Copilot native npm package in ${nativeDirectory}` - ); - } - const command = p.join(nativeDirectory, 'copilot.exe'); - if (await fileExists(command, signal, true)) { - return { command, args: [], source: 'standalone' }; - } - } - } - for (const nodeDirectory of new Set([directory, ...directories])) { - const node = p.join(nodeDirectory, 'node.exe'); - if (await fileExists(node, signal, true)) { - return { command: node, args: [launcherPath], source: 'standalone' }; - } - } - return undefined; -} - -export async function findCopilotCli(signal: AbortSignal): Promise { - signal.throwIfAborted(); +// The GitHub Copilot app does not expose the CLI directly; it downloads a pinned build into its cache. +async function appCli(directories: readonly string[]): Promise { const p = platformPath(); const platform = os.platform(); const home = os.homedir(); - const directories = pathDirectories(); - const standaloneDirectories = [...directories, p.join(home, '.local', 'bin')]; - if (platform === 'win32') { - const appData = environmentPath('APPDATA'); - if (appData) { - standaloneDirectories.push(p.join(appData, 'npm')); - } - for (const root of [environmentPath('LOCALAPPDATA'), environmentPath('ProgramFiles')]) { - if (root) { - const winget = - root === environmentPath('LOCALAPPDATA') - ? p.join(root, 'Microsoft', 'WinGet') - : p.join(root, 'WinGet'); - standaloneDirectories.push( - p.join(winget, 'Links'), - p.join(winget, 'Packages', 'GitHub.Copilot_Microsoft.Winget.Source_8wekyb3d8bbwe') - ); - } - } - } else { - standaloneDirectories.push('/usr/local/bin', '/usr/bin'); - if (platform === 'darwin') { - standaloneDirectories.push('/opt/homebrew/bin'); - } - } - for (const directory of new Set(standaloneDirectories.filter(absolute))) { - const command = p.join(directory, platform === 'win32' ? 'copilot.exe' : 'copilot'); - if (await fileExists(command, signal, true)) { - return { command, args: [], source: 'standalone' }; - } - if (platform === 'win32') { - for (const shim of ['copilot.cmd', 'copilot.ps1', 'copilot']) { - if (await fileExists(p.join(directory, shim), signal)) { - const cli = await npmCli(directory, directories, signal); - if (cli) { - return cli; - } - break; - } - } - } - } - const apps: { executable: string; resources: string }[] = []; let cache: string | undefined; if (platform === 'win32') { - const local = environmentPath('LOCALAPPDATA'); - cache = local; - const appDirectories = [ - ...(local ? [p.join(local, 'Programs', 'GitHub Copilot')] : []), + cache = environmentPath('LOCALAPPDATA'); + const roots = [ + ...(cache ? [p.join(cache, 'Programs', 'GitHub Copilot')] : []), ...['ProgramFiles', 'ProgramFiles(x86)'] .map(environmentPath) .filter((root): root is string => root !== undefined) .map((root) => p.join(root, 'GitHub Copilot')), ...directories, ]; - for (const root of new Set(appDirectories)) { + for (const root of new Set(roots)) { apps.push({ executable: p.join(root, 'github.exe'), resources: root }); } } else if (platform === 'darwin') { @@ -239,22 +84,22 @@ export async function findCopilotCli(signal: AbortSignal): Promise { - if (child.pid === undefined) { - return; - } - if (os.platform() !== 'win32') { - try { - process.kill(-child.pid, 'SIGKILL'); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { - throw error; - } +export async function findCopilotCli(): Promise { + const p = platformPath(); + const platform = os.platform(); + const directories = pathDirectories(); + for (const directory of new Set(directories)) { + const names = platform === 'win32' ? ['copilot.exe', 'copilot.cmd', 'copilot.bat'] : ['copilot']; + if (names.some((name) => existsSync(p.join(directory, name)))) { + return { command: 'copilot', source: 'standalone' }; } - return; } - const systemRoot = environmentPath('SystemRoot') ?? 'C:\\Windows'; - await new Promise((resolve, reject) => { - // Killing only the CLI leaves Git children running. /T is scoped to our PID; - // do not kill the root first, or taskkill can no longer discover its children. - const killer = spawn( - path.win32.join(systemRoot, 'System32', 'taskkill.exe'), - ['/PID', String(child.pid), '/T', '/F'], - { - windowsHide: true, - shell: false, - cwd: os.homedir(), - env: process.env, - stdio: ['ignore', 'ignore', 'pipe'], - } - ); - let error: Error | undefined; - let stderr = ''; - killer.stderr?.on('data', (data: Buffer) => { - stderr = (stderr + data.toString()).slice(0, 4096); - }); - killer.on('error', (failure: Error) => { - error = failure; - }); - killer.on('close', (code) => { - if (error) { - reject(error); - } else if (code !== 0) { - reject(namedError('CopilotCliTerminationError', `taskkill exited with code ${code}: ${stderr}`)); - } else { - resolve(); - } - }); - }); + return await appCli(directories); } export async function runCopilotCli(cli: CopilotCli, args: readonly string[], signal: AbortSignal): Promise { signal.throwIfAborted(); - return new Promise((resolve, reject) => { - const child = spawn(cli.command, [...cli.args, ...args], { - windowsHide: true, - shell: false, - cwd: os.homedir(), - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], - // A private POSIX process group lets cancellation include spawned Git work. - // The child stays referenced and is always awaited; it is not a background job. - detached: os.platform() !== 'win32', - }); - const stdout: Buffer[] = []; - const stderr: Buffer[] = []; - let bytes = 0; - let failure: Error | undefined; - let termination: Promise | undefined; - const stop = (error: Error) => { - failure ??= error; - termination ??= terminateProcessTree(child).catch((terminationError) => { - failure = namedError( - 'CopilotCliTerminationError', - 'Could not terminate the Copilot CLI process tree', - new AggregateError([failure, terminationError]) - ); - // Still wait for the owned child to exit even if tree termination fails. - try { - child.kill('SIGKILL'); - } catch (killError) { - failure = namedError( - 'CopilotCliTerminationError', - 'Could not terminate the Copilot CLI process', - new AggregateError([failure, killError]) - ); - } - }); - }; - const onAbort = () => - stop( - signal.reason instanceof Error - ? signal.reason - : namedError('AbortError', 'Copilot CLI operation cancelled', signal.reason) - ); - const capture = (buffers: Buffer[], data: Buffer | string) => { - if (failure) { - return; - } - const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data); - bytes += buffer.length; - if (bytes > maxOutputBytes) { - stop(namedError('CopilotCliOutputLimitError', `Copilot CLI output exceeded ${maxOutputBytes} bytes`)); - } else { - buffers.push(buffer); - } - }; - child.stdout?.on('data', (data: Buffer) => capture(stdout, data)); - child.stderr?.on('data', (data: Buffer) => capture(stderr, data)); - child.on('error', (error: Error) => { - failure ??= error; - }); - child.on('close', (code, exitSignal) => { - signal.removeEventListener('abort', onAbort); - // close includes pipe closure; also wait for taskkill itself before releasing - // the caller's gate, otherwise a second install can race tree termination. - void (async () => { - await termination; - if (failure) { - reject(failure); - } else if (code !== 0) { + return await new Promise((resolve, reject) => { + const child = execFile( + cli.command, + [...args], + { + windowsHide: true, + shell: cli.source === 'standalone', + cwd: os.homedir(), + env: process.env, + signal, + }, + (error, stdout, stderr) => { + if (!error) { + resolve(stdout); + } else if (signal.aborted) { + reject(signal.reason); + } else if (typeof error.code === 'number' || error.signal) { reject( - namedError( - 'CopilotCliProcessError', - `Copilot CLI exited with code ${code}, signal ${exitSignal}: ${Buffer.concat(stderr).toString('utf8')}` - ) + new Error(`Copilot CLI exited with code ${error.code}, signal ${error.signal}: ${stderr}`, { + cause: error, + }) ); } else { - resolve(Buffer.concat(stdout).toString('utf8')); + reject(error); } - })(); - }); - signal.addEventListener('abort', onAbort, { once: true }); - if (signal.aborted) { - onAbort(); - } + } + ); + child.stdin?.end(); }); } +const sections: [RegExp, CopilotPlugin['kind']][] = [ + [/^Installed plugins:$/i, 'installed'], + [/^Built-in Plugins \(bundled with the CLI\):$/i, 'builtin'], + [/^External Plugins \(via --plugin-dir\):$/i, 'external'], +]; + export function parsePluginList(output: string): CopilotPlugin[] { - const lines = stripVTControlCharacters(output).split(/\r?\n/); const plugins: CopilotPlugin[] = []; - const sections = new Set(); - let section: CopilotPlugin['kind'] | undefined; - let sectionCount = 0; - let explicitlyEmpty = false; - let installHint = false; - const invalid = () => - namedError('CopilotPluginInventoryError', 'Unrecognized or incomplete Copilot plugin inventory'); - for (const raw of lines) { - const line = raw.trim(); - if (!line) { - continue; - } - if (line === 'No plugins installed.') { - if (explicitlyEmpty || sections.has('installed')) { - throw invalid(); - } - explicitlyEmpty = true; - continue; - } - if (line === "Use 'copilot plugin install ' to install a plugin.") { - if (!explicitlyEmpty || installHint) { - throw invalid(); - } - installHint = true; - continue; - } - const heading: CopilotPlugin['kind'] | undefined = /^Installed plugins:$/i.test(line) - ? 'installed' - : /^Built-in Plugins \(bundled with the CLI\):$/i.test(line) - ? 'builtin' - : /^External Plugins \(via --plugin-dir\):$/i.test(line) - ? 'external' - : undefined; - if (heading) { - if ( - (section && sectionCount === 0) || - sections.has(heading) || - (heading === 'installed' && explicitlyEmpty) - ) { - throw invalid(); - } - section = heading; - sectionCount = 0; - sections.add(heading); + let kind: CopilotPlugin['kind'] | undefined; + let recognized = false; + for (const line of stripVTControlCharacters(output).split(/\r?\n/)) { + const text = line.trim(); + const heading = sections.find(([pattern]) => pattern.test(text))?.[1]; + if (heading || text === 'No plugins installed.') { + kind = heading; + recognized = true; continue; } - const entry = - /^\s+• ([a-zA-Z0-9][a-zA-Z0-9._-]*(?:@[a-zA-Z0-9][a-zA-Z0-9._-]*)?)(?: \(v\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?\))?( \[disabled\])?$/.exec( - raw - ); - if (!section || !entry || plugins.some((plugin) => plugin.kind === section && plugin.name === entry[1])) { - throw invalid(); + // The name is passed back to the CLI as an argument, so it must not look like a flag. + const entry = /^• ([a-zA-Z0-9][\w.-]*(?:@[a-zA-Z0-9][\w.-]*)?)(?: \(v[\w.+-]+\))?( \[disabled\])?$/.exec(text); + if (kind && entry) { + plugins.push({ name: entry[1], enabled: !entry[2], kind }); } - plugins.push({ name: entry[1], enabled: !entry[2], kind: section }); - sectionCount++; } - if ((!explicitlyEmpty && !sections.has('installed')) || (section && sectionCount === 0)) { - throw invalid(); + if (!recognized) { + // Report a CLI output format change instead of silently reinstalling or skipping removal. + throw new Error('Unrecognized Copilot plugin inventory'); } return plugins; } diff --git a/src/shared/copilot/dotnetPlugin.ts b/src/shared/copilot/dotnetPlugin.ts index 74611cae56..17849b1665 100644 --- a/src/shared/copilot/dotnetPlugin.ts +++ b/src/shared/copilot/dotnetPlugin.ts @@ -7,7 +7,14 @@ import * as vscode from 'vscode'; import { commonOptions } from '../options'; import { ITelemetryReporter } from '../telemetryReporter'; import { TelemetryEventNames } from '../telemetryEventNames'; -import type { CopilotCli, CopilotCliSource, CopilotPlugin } from './copilotCli'; +import { + CopilotCli, + CopilotCliSource, + CopilotPlugin, + findCopilotCli, + parsePluginList, + runCopilotCli, +} from './copilotCli'; export const uninstallDotnetPluginCommand = 'dotnet.copilot.uninstallDotnetPlugin'; export const dotnetPluginOptOutKey = 'csharp.copilotDotnetPlugin.autoInstallDisabled'; @@ -24,376 +31,279 @@ type Outcome = | 'optedOut' | 'aiDisabled' | 'untrustedWorkspace' - | 'uninstallRequested' - | 'disposed' + | 'cancelled' | 'installFailed' | 'uninstalled' | 'alreadyAbsent' | 'uninstallFailed'; -type Stage = 'eligibility' | 'cache' | 'discovery' | 'inventory' | 'install' | 'confirmation' | 'optOut' | 'uninstall'; -type Runtime = typeof import('./copilotCli'); -type Context = { - globalState: vscode.Memento; - extension: Pick, 'packageJSON'>; -}; -type Channel = Pick; +type Stage = 'optOut' | 'cache' | 'discovery' | 'inventory' | 'install' | 'uninstall'; +type Source = CopilotCliSource | 'none'; type Cache = { extensionVersion: string; outcome: CachedOutcome; source: CopilotCliSource }; -type Operation = { +type InstallResult = { outcome: Outcome; - source: CopilotCliSource | 'none'; - cached: boolean; - stage: Stage; - signal: AbortSignal; + source: Source; + cache?: Omit; +}; + +export type DotnetPluginHost = { + context: { + globalState: vscode.Memento; + extension: Pick, 'packageJSON'>; + }; + reporter: ITelemetryReporter; + channel: Pick; }; export function registerDotnetPlugin( - context: Context & Pick, + context: DotnetPluginHost['context'] & Pick, reporter: ITelemetryReporter, - channel: Channel + channel: DotnetPluginHost['channel'] ): void { - const manager = new DotnetPluginManager(context, reporter, channel); + const host: DotnetPluginHost = { context, reporter, channel }; + const controller = new AbortController(); + // Other integration suites must not install into the developer's real Copilot profile. + let operation = + context.extensionMode === vscode.ExtensionMode.Test + ? Promise.resolve() + : installDotnetPlugin(host, controller.signal); + context.subscriptions.push( + { dispose: () => controller.abort(named('AbortError', 'The C# extension was deactivated.')) }, + vscode.commands.registerCommand(uninstallDotnetPluginCommand, async () => { + operation = operation.then(async () => uninstallDotnetPlugin(host, controller.signal)); + await operation; + }) + ); +} + +/** + * Installs only when AI is enabled, the workspace is trusted, the user has not opted out, Copilot is available, + * and no existing or conflicting .NET plugin is found. Stable results are cached per extension version. + */ +export async function installDotnetPlugin(host: DotnetPluginHost, signal: AbortSignal): Promise { + let stage: Stage = 'optOut'; + let source: Source = 'none'; + let done = () => {}; try { - context.subscriptions.push( - manager, - vscode.commands.registerCommand(uninstallDotnetPluginCommand, async () => manager.uninstall()) - ); - // Other integration suites must not install into the developer's real Copilot profile. - if (context.extensionMode !== vscode.ExtensionMode.Test) { - const scheduled = setImmediate(() => { - void manager.install(); - }); - context.subscriptions.push({ dispose: () => clearImmediate(scheduled) }); + const blocked = blockedReason(host.context); + if (blocked) { + report(host, TelemetryEventNames.CopilotDotnetPlugin, blocked, 'none', false); + return; + } + + stage = 'cache'; + const cached = readCache(host.context); + if (cached) { + report(host, TelemetryEventNames.CopilotDotnetPlugin, cached.outcome, cached.source, true); + return; } - } catch (error) { - manager.dispose(); - channel.error('Failed to register the Copilot .NET plugin integration', error); - } -} -export class DotnetPluginManager implements vscode.Disposable { - private pending = Promise.resolve(); - private installation: Promise | undefined; - private controller: AbortController | undefined; - private disposed = false; - private uninstallRequested = false; + stage = 'discovery'; + const deadlineResult = deadline(signal); + const operation = deadlineResult.signal; + done = deadlineResult.done; + const cli = await findCopilotCli(); + if (!cli) { + return await completeInstallation(host, { outcome: 'copilotNotAvailable', source: 'none' }); + } - constructor( - private readonly context: Context, - private readonly reporter: ITelemetryReporter, - private readonly channel: Channel - ) {} + source = cli.source; + stage = 'inventory'; + const plugins = await listPlugins(cli, operation); + const existing = plugins.filter(isDotnetPlugin); + if (existing.length > 0) { + const outcome = enabledOutcome(existing); + stage = 'cache'; + return await completeInstallation(host, { + outcome, + source, + cache: { outcome, source: cli.source }, + }); + } - public dispose(): void { - this.disposed = true; - this.controller?.abort(new Error('Copilot plugin operation disposed')); - } + if (plugins.some(isConflictingPlugin)) { + host.channel.info('Skipping Copilot .NET plugin installation: another plugin uses its name.'); + stage = 'cache'; + return await completeInstallation(host, { + outcome: 'conflictingPlugin', + source, + cache: { outcome: 'conflictingPlugin', source: cli.source }, + }); + } - public async install(): Promise { - this.installation ??= this.enqueue(async () => { - const result = await this.operate(false, async (operation) => this.installCore(operation)); - if (result.outcome === 'installed' && !this.disposed && !this.uninstallRequested) { - void this.showInstalled(); - } + stage = 'install'; + await runCopilotCli(cli, ['plugin', 'install', pluginSource], operation); + stage = 'cache'; + return await completeInstallation(host, { + outcome: 'installed', + source, + cache: { outcome: 'alreadyInstalled', source: cli.source }, }); - await this.installation; + } catch (error) { + const outcome = signal.aborted ? 'cancelled' : 'installFailed'; + const failure = signal.aborted ? signal.reason : error; + reportError(host, stage, outcome, failure); + finishInstallation(host, { outcome, source }); + } finally { + done(); } +} - public async uninstall(): Promise { - this.uninstallRequested = true; - // Start persisting the opt-out immediately, even if removal must wait for an active install. - const optOut = this.persistOptOut(); - await this.enqueue(async () => { - const result = await this.operate(true, async (operation) => { - operation.stage = 'optOut'; - const persisted = await interruptible(optOut, operation.signal); - if (!persisted.success) { - throw persisted.error; - } - await this.clearCache(operation); - const runtime = await this.loadRuntime(operation); - const cli = await this.discover(runtime, operation); - if (!cli) { - operation.outcome = 'copilotNotAvailable'; - return; - } - const plugins = await this.inventory(runtime, cli, operation, 'inventory'); - const targets = plugins.filter(isDotnetPlugin); - if (targets.length === 0) { - if (plugins.some(isConflictingPlugin)) { - throw new Error('A different plugin uses the dotnet name; it has not been removed.'); - } - operation.outcome = 'alreadyAbsent'; - return; - } - for (const plugin of targets) { - operation.stage = 'uninstall'; - operation.signal.throwIfAborted(); - await runtime.runCopilotCli(cli, ['plugin', 'uninstall', plugin.name], operation.signal); - } - const remaining = await this.inventory(runtime, cli, operation, 'confirmation'); - if (remaining.some(isDotnetPlugin)) { - throw new Error('Copilot still lists the .NET plugin after uninstalling it.'); - } - operation.outcome = 'uninstalled'; - }); - if (!this.disposed) { - void this.showUninstallResult(result); - } - }); +async function completeInstallation(host: DotnetPluginHost, result: InstallResult): Promise { + if (result.cache) { + await host.context.globalState.update(dotnetPluginCacheKey, { + extensionVersion: host.context.extension.packageJSON.version, + ...result.cache, + } satisfies Cache); } + finishInstallation(host, result); +} - private async enqueue(work: () => Promise): Promise { - const next = this.pending.then(work); - // Keep the gate usable even if an unexpected failure escapes an operation's boundary. - this.pending = next.catch((error) => { - this.channel.error('Copilot .NET plugin operation failed', error); - }); - await this.pending; +function finishInstallation(host: DotnetPluginHost, result: InstallResult): void { + report(host, TelemetryEventNames.CopilotDotnetPlugin, result.outcome, result.source, false); + if (result.outcome === 'installed') { + void showInstalled(); } +} - private async operate(uninstall: boolean, work: (operation: Operation) => Promise): Promise { - const controller = new AbortController(); - this.controller = controller; - const operation: Operation = { - outcome: uninstall ? 'uninstallFailed' : 'installFailed', - source: 'none', - cached: false, - stage: 'eligibility', - signal: controller.signal, - }; - const timeout = setTimeout(() => { - const error = new Error('Copilot plugin operation timed out'); - error.name = 'TimeoutError'; - controller.abort(error); - }, operationTimeoutMs); - try { - if (this.disposed) { - operation.outcome = 'disposed'; +export async function uninstallDotnetPlugin(host: DotnetPluginHost, signal: AbortSignal): Promise { + let stage: Stage = 'optOut'; + let outcome: Outcome; + let source: Source = 'none'; + const { signal: operation, done } = deadline(signal); + try { + // Persist the opt-out first so that a failed removal still stops automatic installation. + await host.context.globalState.update(dotnetPluginOptOutKey, true); + stage = 'cache'; + await host.context.globalState.update(dotnetPluginCacheKey, undefined); + stage = 'discovery'; + const cli = await findCopilotCli(); + source = cli?.source ?? 'none'; + if (!cli) { + outcome = 'copilotNotAvailable'; + } else { + stage = 'inventory'; + const plugins = await listPlugins(cli, operation); + const targets = plugins.filter(isDotnetPlugin); + if (targets.length === 0 && plugins.some(isConflictingPlugin)) { + throw new Error('A different plugin uses the dotnet name; it has not been removed.'); + } else if (targets.length === 0) { + outcome = 'alreadyAbsent'; } else { - await work(operation); + stage = 'uninstall'; + for (const target of targets) { + await runCopilotCli(cli, ['plugin', 'uninstall', target.name], operation); + } + outcome = 'uninstalled'; } - } catch (error) { - operation.outcome = this.disposed ? 'disposed' : uninstall ? 'uninstallFailed' : 'installFailed'; - this.reportError(operation, controller.signal.aborted ? controller.signal.reason : error); - } finally { - clearTimeout(timeout); - this.controller = undefined; } - this.reportOutcome(operation, uninstall); - return operation; + } catch (error) { + outcome = signal.aborted ? 'cancelled' : 'uninstallFailed'; + reportError(host, stage, outcome, signal.aborted ? signal.reason : error); + } finally { + done(); } - private async installCore(operation: Operation): Promise { - const skip = this.skipReason(); - if (skip) { - operation.outcome = skip; - return; - } - operation.stage = 'cache'; - const stored = this.context.globalState.get(dotnetPluginCacheKey); - if (isCache(stored) && stored.extensionVersion === this.context.extension.packageJSON.version) { - operation.outcome = stored.outcome; - operation.source = stored.source; - operation.cached = true; - return; - } - if (stored !== undefined) { - await this.clearCache(operation); - } - const runtime = await this.loadRuntime(operation); - const cli = await this.discover(runtime, operation); - if (!cli) { - operation.outcome = 'copilotNotAvailable'; - return; - } - const plugins = await this.inventory(runtime, cli, operation, 'inventory'); - const installed = plugins.filter(isDotnetPlugin); - if (installed.length > 0) { - operation.outcome = installed.some((plugin) => plugin.enabled) - ? 'alreadyInstalled' - : 'alreadyInstalledDisabled'; - await this.cache(operation, operation.outcome, cli.source); - return; - } - if (plugins.some(isConflictingPlugin)) { - operation.outcome = 'conflictingPlugin'; - this.channel.info('Skipping Copilot .NET plugin installation because a different plugin uses its name.'); - await this.cache(operation, operation.outcome, cli.source); - return; - } - const lateSkip = this.skipReason(); - if (lateSkip) { - operation.outcome = lateSkip; - return; - } - operation.stage = 'install'; - operation.signal.throwIfAborted(); - await runtime.runCopilotCli(cli, ['plugin', 'install', pluginSource], operation.signal); - const confirmed = (await this.inventory(runtime, cli, operation, 'confirmation')).filter(isDotnetPlugin); - if (confirmed.length === 0) { - throw new Error('Copilot did not list the .NET plugin after installation.'); - } - operation.outcome = 'installed'; - await this.cache( - operation, - confirmed.some((plugin) => plugin.enabled) ? 'alreadyInstalled' : 'alreadyInstalledDisabled', - cli.source - ); - } + report(host, TelemetryEventNames.CopilotDotnetPluginUninstall, outcome, source); + void showUninstallResult(outcome, stage); +} - private skipReason(): Outcome | undefined { - if (this.disposed) { - return 'disposed'; - } - if (this.context.globalState.get(dotnetPluginOptOutKey, false)) { - return 'optedOut'; - } - if (this.uninstallRequested) { - return 'uninstallRequested'; - } - if (commonOptions.disableAIFeatures) { - return 'aiDisabled'; - } - if (!vscode.workspace.isTrusted) { - return 'untrustedWorkspace'; - } - return undefined; +function blockedReason(context: DotnetPluginHost['context']): Outcome | undefined { + if (context.globalState.get(dotnetPluginOptOutKey, false)) { + return 'optedOut'; } - - private async persistOptOut(): Promise<{ success: true } | { success: false; error: unknown }> { - try { - await this.context.globalState.update(dotnetPluginOptOutKey, true); - return { success: true }; - } catch (error) { - return { success: false, error }; - } + if (commonOptions.disableAIFeatures) { + return 'aiDisabled'; } - - private async loadRuntime(operation: Operation): Promise { - operation.stage = 'discovery'; - return await interruptible(import('./copilotCli'), operation.signal); + if (!vscode.workspace.isTrusted) { + return 'untrustedWorkspace'; } + return undefined; +} - private async discover(runtime: Runtime, operation: Operation): Promise { - operation.stage = 'discovery'; - const cli = await interruptible(runtime.findCopilotCli(operation.signal), operation.signal); - operation.source = cli?.source ?? 'none'; - return cli; - } +function readCache(context: DotnetPluginHost['context']): Cache | undefined { + const cache = context.globalState.get(dotnetPluginCacheKey); + return cache?.extensionVersion === context.extension.packageJSON.version ? cache : undefined; +} - private async inventory( - runtime: Runtime, - cli: CopilotCli, - operation: Operation, - stage: 'inventory' | 'confirmation' - ): Promise { - operation.stage = stage; - operation.signal.throwIfAborted(); - // The runner observes cancellation and waits for its process to exit before releasing the gate. - const output = await runtime.runCopilotCli(cli, ['plugin', 'list'], operation.signal); - operation.signal.throwIfAborted(); - return runtime.parsePluginList(output); - } +function deadline(signal: AbortSignal): { signal: AbortSignal; done: () => void } { + const timer = new AbortController(); + const handle = setTimeout( + () => timer.abort(named('TimeoutError', 'The Copilot CLI did not respond in time.')), + operationTimeoutMs + ); + return { signal: AbortSignal.any([signal, timer.signal]), done: () => clearTimeout(handle) }; +} - private async clearCache(operation: Operation): Promise { - try { - await interruptible(this.context.globalState.update(dotnetPluginCacheKey, undefined), operation.signal); - } catch (error) { - this.reportError({ ...operation, stage: 'cache' }, error); - operation.signal.throwIfAborted(); - } - } +async function listPlugins(cli: CopilotCli, signal: AbortSignal): Promise { + return parsePluginList(await runCopilotCli(cli, ['plugin', 'list'], signal)); +} - private async cache(operation: Operation, outcome: CachedOutcome, source: CopilotCliSource): Promise { - if (this.skipReason()) { - return; - } - const value: Cache = { - extensionVersion: this.context.extension.packageJSON.version, - outcome, - source, - }; - try { - await interruptible(this.context.globalState.update(dotnetPluginCacheKey, value), operation.signal); - } catch (error) { - this.reportError({ ...operation, stage: 'cache' }, error); - } - } +function enabledOutcome(plugins: CopilotPlugin[]): CachedOutcome { + return plugins.some((plugin) => plugin.enabled) ? 'alreadyInstalled' : 'alreadyInstalledDisabled'; +} - private reportOutcome(operation: Operation, uninstall: boolean): void { - try { - const properties: Record = { outcome: operation.outcome, source: operation.source }; - if (!uninstall) { - properties.cached = String(operation.cached); - } - this.reporter.sendTelemetryEvent( - uninstall ? TelemetryEventNames.CopilotDotnetPluginUninstall : TelemetryEventNames.CopilotDotnetPlugin, - properties - ); - } catch (error) { - this.channel.error('Failed to report Copilot .NET plugin telemetry', error); - } - } +function report( + host: DotnetPluginHost, + event: TelemetryEventNames, + outcome: Outcome, + source: Source, + cached?: boolean +): void { + host.reporter.sendTelemetryEvent(event, { + outcome, + source, + ...(cached === undefined ? {} : { cached: String(cached) }), + }); +} - private reportError(operation: Operation, error: unknown): void { - this.channel.error(`Copilot .NET plugin ${operation.stage} failed`, error); - try { - this.reporter.sendTelemetryErrorEvent(TelemetryEventNames.CopilotDotnetPluginError, { - stage: operation.stage, - outcome: operation.outcome, - 'error.name': telemetryErrorName(error), - }); - } catch (telemetryError) { - this.channel.error('Failed to report Copilot .NET plugin error telemetry', telemetryError); - } - } +function reportError(host: DotnetPluginHost, stage: Stage, outcome: Outcome, error: unknown): void { + host.channel.error(`Copilot .NET plugin ${stage} failed`, error); + host.reporter.sendTelemetryErrorEvent(TelemetryEventNames.CopilotDotnetPluginError, { + stage, + outcome, + 'error.name': telemetryErrorName(error), + }); +} - private async showInstalled(): Promise { - try { - const learnMore = vscode.l10n.t('Learn More'); - const selected = await vscode.window.showInformationMessage( - vscode.l10n.t('Installed the C# LSP .NET plugin for GitHub Copilot'), - learnMore - ); - if (selected === learnMore && !this.disposed) { - if (!(await vscode.env.openExternal(vscode.Uri.parse(documentationUrl)))) { - this.channel.error('Could not open the Copilot .NET plugin documentation.'); - } - } - } catch (error) { - this.channel.error('Failed to show the Copilot .NET plugin notification or documentation', error); - } +async function showInstalled(): Promise { + const learnMore = vscode.l10n.t('Learn More'); + const selected = await vscode.window.showInformationMessage( + vscode.l10n.t('Installed the C# LSP .NET plugin for GitHub Copilot'), + learnMore + ); + if (selected === learnMore) { + await vscode.env.openExternal(vscode.Uri.parse(documentationUrl)); } +} - private async showUninstallResult(operation: Operation): Promise { - try { - if (operation.outcome === 'uninstalled' || operation.outcome === 'alreadyAbsent') { - await vscode.window.showInformationMessage( - operation.outcome === 'uninstalled' - ? vscode.l10n.t('Uninstalled the Copilot C# LSP plugin. Automatic installation is disabled.') - : vscode.l10n.t( - 'The Copilot C# LSP plugin is not installed. Automatic installation is disabled.' - ) - ); - } else { - await vscode.window.showWarningMessage( - operation.outcome === 'copilotNotAvailable' - ? vscode.l10n.t( - 'Automatic installation is disabled, but Copilot is unavailable to uninstall the C# LSP plugin.' - ) - : operation.stage === 'optOut' - ? vscode.l10n.t('Could not disable automatic installation. See the C# output for details.') - : vscode.l10n.t( - 'Could not uninstall the Copilot C# LSP plugin. Automatic installation is disabled. See the C# output for details.' - ) - ); - } - } catch (error) { - this.channel.error('Failed to show the Copilot .NET plugin uninstall result', error); - } +async function showUninstallResult(outcome: Outcome, stage: Stage): Promise { + if (outcome === 'uninstalled' || outcome === 'alreadyAbsent') { + await vscode.window.showInformationMessage( + outcome === 'uninstalled' + ? vscode.l10n.t('Uninstalled the Copilot C# LSP plugin. Automatic installation is disabled.') + : vscode.l10n.t('The Copilot C# LSP plugin is not installed. Automatic installation is disabled.'), + { modal: true } + ); + } else { + await vscode.window.showWarningMessage( + outcome === 'copilotNotAvailable' + ? vscode.l10n.t( + 'Automatic installation is disabled, but Copilot is unavailable to uninstall the C# LSP plugin.' + ) + : stage === 'optOut' + ? vscode.l10n.t('Could not disable automatic installation. See the C# output for details.') + : vscode.l10n.t( + 'Could not uninstall the Copilot C# LSP plugin. Automatic installation is disabled. See the C# output for details.' + ), + { modal: true } + ); } } +function named(name: string, message: string): Error { + return Object.assign(new Error(message), { name }); +} + function isDotnetPlugin(plugin: CopilotPlugin): boolean { return plugin.kind === 'installed' && (plugin.name === 'dotnet' || plugin.name === 'dotnet@dotnet-agent-skills'); } @@ -402,37 +312,7 @@ function isConflictingPlugin(plugin: CopilotPlugin): boolean { return (plugin.name === 'dotnet' || plugin.name.startsWith('dotnet@')) && !isDotnetPlugin(plugin); } -function isCache(value: unknown): value is Cache { - if (typeof value !== 'object' || value === null) { - return false; - } - return ( - 'extensionVersion' in value && - typeof value.extensionVersion === 'string' && - 'outcome' in value && - (value.outcome === 'alreadyInstalled' || - value.outcome === 'alreadyInstalledDisabled' || - value.outcome === 'conflictingPlugin') && - 'source' in value && - (value.source === 'standalone' || value.source === 'app') - ); -} - function telemetryErrorName(error: unknown): string { const allowedNames = ['Error', 'AbortError', 'TimeoutError', 'TypeError', 'RangeError', 'SyntaxError']; return error instanceof Error && allowedNames.includes(error.name) ? error.name : 'Error'; } - -async function interruptible(work: PromiseLike, signal: AbortSignal): Promise { - return await new Promise((resolve, reject) => { - const aborted = () => reject(signal.reason); - if (signal.aborted) { - aborted(); - } else { - signal.addEventListener('abort', aborted, { once: true }); - } - void Promise.resolve(work) - .then(resolve, reject) - .finally(() => signal.removeEventListener('abort', aborted)); - }); -} diff --git a/test/lsptoolshost/unitTests/copilotCli.test.ts b/test/lsptoolshost/unitTests/copilotCli.test.ts index 7da535cb51..8dfc1ddd9a 100644 --- a/test/lsptoolshost/unitTests/copilotCli.test.ts +++ b/test/lsptoolshost/unitTests/copilotCli.test.ts @@ -4,9 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals'; -import { ChildProcess, spawn, SpawnOptions } from 'child_process'; +import { ChildProcess, execFile, ExecFileException, ExecFileOptionsWithStringEncoding } from 'child_process'; import { EventEmitter } from 'events'; -import { promises as fs, PathLike, Stats } from 'fs'; +import { existsSync, promises as fs, PathLike } from 'fs'; import * as os from 'os'; import * as path from 'path'; import { PassThrough } from 'stream'; @@ -14,28 +14,35 @@ import { CopilotCli, findCopilotCli, parsePluginList, runCopilotCli } from '../. jest.mock('fs', () => ({ ...jest.requireActual('fs'), - promises: { stat: jest.fn(), readFile: jest.fn(), access: jest.fn() }, + existsSync: jest.fn(), + promises: { readFile: jest.fn() }, })); jest.mock('os', () => ({ ...jest.requireActual('os'), platform: jest.fn(), - arch: jest.fn(), homedir: jest.fn(), })); -jest.mock('child_process', () => ({ spawn: jest.fn() })); +jest.mock('child_process', () => ({ execFile: jest.fn() })); const files = new Map(); -const errors = new Map(); -const stat = jest.mocked<(file: PathLike) => Promise>(fs.stat); +const exists = jest.mocked(existsSync); const readFile = jest.mocked<(file: PathLike, encoding: 'utf8') => Promise>(fs.readFile); -const access = jest.mocked(fs.access); -const spawnMock = jest.mocked<(command: string, args: readonly string[], options: SpawnOptions) => ChildProcess>(spawn); +type ExecFileCallback = (error: ExecFileException | null, stdout: string, stderr: string) => void; +const execFileMock = + jest.mocked< + ( + command: string, + args: readonly string[], + options: ExecFileOptionsWithStringEncoding, + callback: ExecFileCallback + ) => ChildProcess + >(execFile); const home = 'C:\\Users\\fixture'; const local = `${home}\\AppData\\Local`; const roaming = `${home}\\AppData\\Roaming`; const signal = () => new AbortController().signal; -const runtime: CopilotCli = { command: 'C:\\Tools\\copilot.exe', args: [], source: 'standalone' }; - +const runtime: CopilotCli = { command: 'copilot', source: 'standalone' }; +const appRuntime: CopilotCli = { command: 'C:\\Tools\\copilot.exe', source: 'app' }; function missing(file: string): Error { return Object.assign(new Error(`Missing fixture: ${file}`), { code: 'ENOENT' }); } @@ -65,10 +72,9 @@ function appFixture(platform: NodeJS.Platform, version = '1.0.83'): string { ? p.join(root, 'MacOS', 'github') : '/usr/bin/github' ); - const metadata = p.join(platform === 'darwin' ? p.join(root, 'Resources') : root, 'copilot-sdk', 'cliVersion.d.ts'); addFile( - metadata, - `export declare const COPILOT_CLI_VERSION = "${version}";\nexport declare const COPILOT_CLI_USE_NPM_PACKAGE = false;` + p.join(platform === 'darwin' ? p.join(root, 'Resources') : root, 'copilot-sdk', 'cliVersion.d.ts'), + `export declare const COPILOT_CLI_VERSION = "${version}";` ); const cache = platform === 'win32' ? local : platform === 'darwin' ? '/home/fixture/Library/Caches' : '/home/fixture/.cache'; @@ -83,55 +89,28 @@ function appFixture(platform: NodeJS.Platform, version = '1.0.83'): string { return command; } -function npmFixture(options: { native?: 'nested' | 'hoisted'; arch?: string; shim?: string } = {}): { - directory: string; - launcher: string; - native: string; -} { - const directory = `${roaming}\\npm`; - const packageDirectory = `${directory}\\node_modules\\@github\\copilot`; - const nativeName = `copilot-win32-${options.arch ?? 'x64'}`; - addFile(`${directory}\\${options.shim ?? 'copilot.cmd'}`); - addFile( - `${packageDirectory}\\package.json`, - JSON.stringify({ - name: '@github/copilot', - version: '1.0.83', - bin: { copilot: 'npm-loader.js' }, - optionalDependencies: { [`@github/${nativeName}`]: '1.0.83' }, - }) - ); - const launcher = `${packageDirectory}\\npm-loader.js`; - addFile(launcher); - const nativeDirectory = - options.native === 'hoisted' - ? `${directory}\\node_modules\\@github\\${nativeName}` - : `${packageDirectory}\\node_modules\\@github\\${nativeName}`; - const native = `${nativeDirectory}\\copilot.exe`; - if (options.native) { - addFile( - `${nativeDirectory}\\package.json`, - JSON.stringify({ name: `@github/${nativeName}`, version: '1.0.83' }) - ); - addFile(native); - } - return { directory, launcher, native }; -} - -function childFixture(pid: number | undefined = 4101): ChildProcess { +function childFixture(): ChildProcess { return Object.assign(new EventEmitter(), { - pid, - stdout: new PassThrough(), - stderr: new PassThrough(), - stdin: null, - kill: jest.fn(() => true), + stdin: new PassThrough(), }) as unknown as ChildProcess; } -async function flush(): Promise { - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); +function executionFixture(): { child: ChildProcess; callback: () => ExecFileCallback } { + const child = childFixture(); + let callback: ExecFileCallback | undefined; + execFileMock.mockImplementationOnce((_command, _args, _options, value) => { + callback = value; + return child; + }); + return { + child, + callback: () => { + if (!callback) { + throw new Error('Copilot CLI was not executed'); + } + return callback; + }, + }; } beforeEach(() => { @@ -144,403 +123,179 @@ beforeEach(() => { SystemRoot: 'C:\\Windows', COPILOT_HOME: 'C:\\Copilot Home', }); - jest.spyOn(process, 'kill').mockReturnValue(true); setPlatform('win32'); - jest.mocked(os.arch).mockReturnValue('x64'); files.clear(); - errors.clear(); - stat.mockImplementation(async (file) => { - const name = String(file); - if (errors.has(name)) { - throw errors.get(name); - } - if (!files.has(name)) { - throw missing(name); - } - return { isFile: () => true } as Stats; - }); + exists.mockImplementation((file) => files.has(String(file))); readFile.mockImplementation(async (file) => { const name = String(file); - if (errors.has(name)) { - throw errors.get(name); - } const content = files.get(name); if (content === undefined) { throw missing(name); } return content; }); - access.mockResolvedValue(undefined); }); afterEach(() => { - expect(spawnMock).not.toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ shell: true }) - ); jest.restoreAllMocks(); }); describe('Copilot CLI filesystem discovery', () => { - test('prefers the first standalone PATH CLI, even when an app is installed', async () => { + test('prefers the first CLI on PATH over an installed app', async () => { appFixture('win32'); process.env.PATH = 'C:\\First;C:\\Second'; addFile('C:\\First\\copilot.exe'); addFile('C:\\Second\\copilot.exe'); - await expect(findCopilotCli(signal())).resolves.toEqual({ - command: 'C:\\First\\copilot.exe', - args: [], + await expect(findCopilotCli()).resolves.toEqual({ + command: 'copilot', source: 'standalone', }); expect(readFile).not.toHaveBeenCalled(); - expect(spawnMock).not.toHaveBeenCalled(); + expect(execFileMock).not.toHaveBeenCalled(); }); test('ignores empty, relative, drive-relative, and current-drive PATH entries', async () => { process.env.PATH = ';.;tools;C:tools;\\tools;;"C:\\Absolute Tools"'; addFile('C:\\Absolute Tools\\copilot.exe'); - await expect(findCopilotCli(signal())).resolves.toMatchObject({ command: 'C:\\Absolute Tools\\copilot.exe' }); - expect(stat.mock.calls.map((call) => String(call[0]))).toEqual(['C:\\Absolute Tools\\copilot.exe']); + await expect(findCopilotCli()).resolves.toMatchObject({ command: 'copilot' }); + expect(exists.mock.calls.map((call) => String(call[0]))).toEqual(['C:\\Absolute Tools\\copilot.exe']); }); - test('handles case-insensitive Windows Path and environment variable names', async () => { - delete process.env.PATH; - process.env.Path = 'C:\\Tools'; - addFile(runtime.command); - await expect(findCopilotCli(signal())).resolves.toEqual(runtime); - }); - - test.each(['nested', 'hoisted'] as const)( - 'uses the %s npm native package instead of executing a cmd shim', - async (native) => { - const fixture = npmFixture({ native }); - await expect(findCopilotCli(signal())).resolves.toEqual({ - command: fixture.native, - args: [], - source: 'standalone', - }); - expect(spawnMock).not.toHaveBeenCalled(); - } - ); - - test('resolves an arm64 native package behind a PowerShell shim', async () => { - jest.mocked(os.arch).mockReturnValue('arm64'); - const fixture = npmFixture({ native: 'nested', arch: 'arm64', shim: 'copilot.ps1' }); - await expect(findCopilotCli(signal())).resolves.toMatchObject({ command: fixture.native }); + test.each(['copilot.exe', 'copilot.cmd', 'copilot.bat'])('recognizes %s on PATH', async (name) => { + process.env.PATH = 'C:\\Tools'; + addFile(`C:\\Tools\\${name}`); + await expect(findCopilotCli()).resolves.toEqual({ command: 'copilot', source: 'standalone' }); + expect(readFile).not.toHaveBeenCalled(); }); - test('uses an absolute Node executable and the actual npm launcher without a native package', async () => { - const fixture = npmFixture(); - process.env.PATH = 'relative;;C:\\Node'; - addFile('C:\\Node\\node.exe'); - await expect(findCopilotCli(signal())).resolves.toEqual({ - command: 'C:\\Node\\node.exe', - args: [fixture.launcher], + test('supports a native or script CLI path on POSIX', async () => { + setPlatform('linux'); + process.env.PATH = ':.:relative:/opt/copilot/bin'; + addFile('/opt/copilot/bin/copilot', '#!/usr/bin/env node'); + await expect(findCopilotCli()).resolves.toEqual({ + command: 'copilot', source: 'standalone', }); + expect(execFileMock).not.toHaveBeenCalled(); }); - test('prefers node.exe adjacent to the npm shim', async () => { - const fixture = npmFixture(); - process.env.PATH = 'C:\\OtherNode'; - addFile('C:\\OtherNode\\node.exe'); - addFile(`${fixture.directory}\\node.exe`); - await expect(findCopilotCli(signal())).resolves.toMatchObject({ - command: `${fixture.directory}\\node.exe`, - args: [fixture.launcher], - }); - }); - - test('does not mistake an arbitrary cmd file for a usable CLI', async () => { + test('ignores standalone CLI installations outside PATH', async () => { process.env.PATH = 'C:\\Tools'; - addFile('C:\\Tools\\copilot.cmd'); - await expect(findCopilotCli(signal())).resolves.toBeUndefined(); - expect(spawnMock).not.toHaveBeenCalled(); - }); - - test('does not run an npm shim without a native package or an absolute Node runtime', async () => { - npmFixture(); - await expect(findCopilotCli(signal())).resolves.toBeUndefined(); - }); - - test('rejects an npm launcher that escapes its package', async () => { - const fixture = npmFixture(); - const metadata = `${fixture.directory}\\node_modules\\@github\\copilot\\package.json`; - addFile(metadata, JSON.stringify({ name: '@github/copilot', bin: { copilot: '..\\evil.js' } })); - await expect(findCopilotCli(signal())).rejects.toMatchObject({ name: 'CopilotCliMetadataError' }); - }); - - test('surfaces malformed and mismatched npm metadata', async () => { - const fixture = npmFixture({ native: 'nested' }); - addFile( - path.win32.join(path.win32.dirname(fixture.native), 'package.json'), - '{"name":"wrong","version":"9.0.0"}' - ); - await expect(findCopilotCli(signal())).rejects.toMatchObject({ name: 'CopilotCliMetadataError' }); - addFile(`${fixture.directory}\\node_modules\\@github\\copilot\\package.json`, '{'); - await expect(findCopilotCli(signal())).rejects.toBeInstanceOf(SyntaxError); - }); - - test('supports the default WinGet package location without PATH', async () => { - const command = `${local}\\Microsoft\\WinGet\\Packages\\GitHub.Copilot_Microsoft.Winget.Source_8wekyb3d8bbwe\\copilot.exe`; - addFile(command); - await expect(findCopilotCli(signal())).resolves.toEqual({ command, args: [], source: 'standalone' }); - }); - - test.each(['linux', 'darwin'] as const)( - 'supports executable POSIX native or script CLI paths on %s', - async (platform) => { - setPlatform(platform); - process.env.PATH = ':.:relative:/opt/copilot/bin'; - addFile('/opt/copilot/bin/copilot', '#!/usr/bin/env node'); - await expect(findCopilotCli(signal())).resolves.toEqual({ - command: '/opt/copilot/bin/copilot', - args: [], - source: 'standalone', - }); - expect(access).toHaveBeenCalledWith('/opt/copilot/bin/copilot', expect.any(Number)); - expect(spawnMock).not.toHaveBeenCalled(); + const commands = [ + `${roaming}\\npm\\copilot.exe`, + `${local}\\Microsoft\\WinGet\\Links\\copilot.exe`, + `${local}\\Programs\\GitHub Copilot\\copilot.exe`, + ]; + commands.forEach((command) => addFile(command)); + await expect(findCopilotCli()).resolves.toBeUndefined(); + for (const command of commands) { + expect(exists).not.toHaveBeenCalledWith(command); } - ); - - test('supports a trusted user-local POSIX install absent from PATH', async () => { - setPlatform('linux'); - addFile('/home/fixture/.local/bin/copilot'); - await expect(findCopilotCli(signal())).resolves.toMatchObject({ command: '/home/fixture/.local/bin/copilot' }); - }); - - test.each(['win32', 'darwin', 'linux'] as const)( - 'uses the installed app pin, not the newest cache on %s', - async (platform) => { - const command = appFixture(platform, '1.0.83'); - addFile(command.replace('1.0.83', '99.0.0')); - await expect(findCopilotCli(signal())).resolves.toEqual({ command, args: [], source: 'app' }); - expect(spawnMock).not.toHaveBeenCalled(); - } - ); - - test('supports a user Applications macOS app', async () => { - const command = appFixture('darwin'); - for (const [name, content] of [...files]) { - if (name.startsWith('/Applications/')) { - files.delete(name); - addFile(name.replace('/Applications/', '/home/fixture/Applications/'), content); - } - } - await expect(findCopilotCli(signal())).resolves.toMatchObject({ command, source: 'app' }); }); - test('respects an absolute XDG cache home without reading COPILOT_HOME', async () => { - const oldCommand = appFixture('linux'); - const command = oldCommand.replace('/home/fixture/.cache', '/custom/cache'); - files.delete(oldCommand); - addFile(command); - process.env.XDG_CACHE_HOME = '/custom/cache'; - await expect(findCopilotCli(signal())).resolves.toMatchObject({ command, source: 'app' }); - expect(readFile.mock.calls.map((call) => String(call[0]))).toEqual([ - '/usr/lib/GitHub Copilot/copilot-sdk/cliVersion.d.ts', - ]); + test.each(['win32', 'darwin', 'linux'] as const)('uses the installed app runtime on %s', async (platform) => { + const command = appFixture(platform); + await expect(findCopilotCli()).resolves.toEqual({ command, source: 'app' }); }); - test('ignores a relative XDG cache home', async () => { - const command = appFixture('linux'); - process.env.XDG_CACHE_HOME = 'relative'; - await expect(findCopilotCli(signal())).resolves.toMatchObject({ command }); - }); - - test('requires installed app evidence instead of accepting a stale extracted CLI cache', async () => { + test('requires installed app evidence instead of accepting a stale runtime cache', async () => { const command = appFixture('win32'); files.delete(`${local}\\Programs\\GitHub Copilot\\github.exe`); - await expect(findCopilotCli(signal())).resolves.toBeUndefined(); - expect(stat.mock.calls.map((call) => call[0])).not.toContain(command); + await expect(findCopilotCli()).resolves.toBeUndefined(); + expect(exists).not.toHaveBeenCalledWith(command); expect(readFile).not.toHaveBeenCalled(); }); - - test('returns no CLI for an installed app that has not extracted its pinned runtime', async () => { - const command = appFixture('win32'); - files.delete(command); - addFile(command.replace('1.0.83', '99.0.0')); - await expect(findCopilotCli(signal())).resolves.toBeUndefined(); - }); - - test('returns no CLI for a layout without readable pin metadata', async () => { - appFixture('win32'); - files.delete(`${local}\\Programs\\GitHub Copilot\\copilot-sdk\\cliVersion.d.ts`); - await expect(findCopilotCli(signal())).resolves.toBeUndefined(); - }); - - test('supports an all-users Windows application install', async () => { - const command = appFixture('win32'); - for (const [name, content] of [...files]) { - if (name.startsWith(`${local}\\Programs\\GitHub Copilot\\`)) { - files.delete(name); - addFile(name.replace(`${local}\\Programs`, 'C:\\Program Files'), content); - } - } - await expect(findCopilotCli(signal())).resolves.toMatchObject({ command, source: 'app' }); - }); - - test('supports an extracted Linux AppImage usr layout on an absolute PATH', async () => { - const command = appFixture('linux'); - process.env.PATH = '/opt/copilot-app/usr/bin'; - for (const [name, content] of [...files]) { - if (name.startsWith('/usr/')) { - files.delete(name); - addFile(`/opt/copilot-app${name}`, content); - } - } - await expect(findCopilotCli(signal())).resolves.toMatchObject({ command, source: 'app' }); - }); - - test.each(['../../other', '', 'latest', '1.0.83";\nexport declare const COPILOT_CLI_VERSION = "2.0.0'])( - 'rejects unsafe or ambiguous app version metadata %j', - async (version) => { - appFixture('win32'); - addFile( - `${local}\\Programs\\GitHub Copilot\\copilot-sdk\\cliVersion.d.ts`, - `export declare const COPILOT_CLI_VERSION = "${version}";` - ); - await expect(findCopilotCli(signal())).rejects.toMatchObject({ name: 'CopilotCliMetadataError' }); - } - ); - - test.each(['ENOENT', 'ENOTDIR'])('treats %s as normal absence', async (code) => { - process.env.PATH = 'C:\\Tools'; - errors.set(runtime.command, Object.assign(new Error('not present'), { code })); - await expect(findCopilotCli(signal())).resolves.toBeUndefined(); - }); - - test.each(['EACCES', 'EIO'])('preserves unexpected filesystem failure %s', async (code) => { - process.env.PATH = 'C:\\Tools'; - const error = Object.assign(new Error('filesystem failed'), { code }); - errors.set(runtime.command, error); - await expect(findCopilotCli(signal())).rejects.toBe(error); - }); - - test('preserves an executable-permission failure', async () => { - setPlatform('linux'); - addFile('/home/fixture/.local/bin/copilot'); - const error = Object.assign(new Error('not executable'), { code: 'EACCES' }); - access.mockRejectedValueOnce(error); - await expect(findCopilotCli(signal())).rejects.toBe(error); - }); - - test('does no filesystem work when already cancelled', async () => { - const controller = new AbortController(); - controller.abort(); - await expect(findCopilotCli(controller.signal)).rejects.toMatchObject({ name: 'AbortError' }); - expect(stat).not.toHaveBeenCalled(); - expect(readFile).not.toHaveBeenCalled(); - }); - - test('checks cancellation after a filesystem await before selecting a CLI', async () => { - const controller = new AbortController(); - stat.mockImplementationOnce(async () => { - controller.abort(); - return { isFile: () => true } as Stats; - }); - await expect(findCopilotCli(controller.signal)).rejects.toMatchObject({ name: 'AbortError' }); - expect(stat).toHaveBeenCalledTimes(1); - }); - - test('checks cancellation after reading app metadata', async () => { - appFixture('win32'); - const controller = new AbortController(); - readFile.mockImplementationOnce(async () => { - controller.abort(); - return 'export declare const COPILOT_CLI_VERSION = "1.0.83";'; - }); - await expect(findCopilotCli(controller.signal)).rejects.toMatchObject({ name: 'AbortError' }); - }); }); describe('Copilot CLI process execution', () => { - test('uses an argument array, a neutral cwd, inherited environment, and closed stdin', async () => { - const child = childFixture(); - spawnMock.mockReturnValue(child); - const promise = runCopilotCli( - { ...runtime, args: ['C:\\Package With Spaces\\npm-loader.js'] }, - ['plugin', 'install', 'owner/repo:path;literal'], - signal() - ); - expect(spawnMock).toHaveBeenCalledWith( + test('uses the shell for a standalone CLI with an argument array and closed stdin', async () => { + const fixture = executionFixture(); + const args = ['plugin', 'install', 'dotnet/skills:plugins/dotnet']; + const operation = signal(); + const promise = runCopilotCli(runtime, args, operation); + expect(execFileMock).toHaveBeenCalledWith( runtime.command, - ['C:\\Package With Spaces\\npm-loader.js', 'plugin', 'install', 'owner/repo:path;literal'], + args, { cwd: home, env: process.env, windowsHide: true, - shell: false, - stdio: ['ignore', 'pipe', 'pipe'], - detached: false, - } + shell: true, + signal: operation, + }, + expect.any(Function) ); - child.stdout!.emit('data', Buffer.from('installed\n')); - child.stderr!.emit('data', Buffer.from('diagnostic')); - child.emit('close', 0, null); + expect(fixture.child.stdin?.writableEnded).toBe(true); + fixture.callback()(null, 'installed\n', 'diagnostic'); await expect(promise).resolves.toBe('installed\n'); expect(process.env.COPILOT_HOME).toBe('C:\\Copilot Home'); }); - test('preserves UTF-8 characters split across stdout chunks', async () => { - const child = childFixture(); - spawnMock.mockReturnValue(child); + test('executes an app runtime directly', async () => { + const fixture = executionFixture(); + const promise = runCopilotCli(appRuntime, ['plugin', 'list'], signal()); + expect(execFileMock).toHaveBeenCalledWith( + appRuntime.command, + ['plugin', 'list'], + expect.objectContaining({ shell: false }), + expect.any(Function) + ); + fixture.callback()(null, '', ''); + await expect(promise).resolves.toBe(''); + }); + + test('preserves UTF-8 output', async () => { + const fixture = executionFixture(); const promise = runCopilotCli(runtime, ['plugin', 'list'], signal()); - const data = Buffer.from(' • dotnet'); - child.stdout!.emit('data', data.subarray(0, 3)); - child.stdout!.emit('data', data.subarray(3)); - child.emit('close', 0, null); + fixture.callback()(null, ' • dotnet', ''); await expect(promise).resolves.toBe(' • dotnet'); }); test('rejects a nonzero exit with useful stderr and exit details', async () => { - const child = childFixture(); - spawnMock.mockReturnValue(child); + const fixture = executionFixture(); const promise = runCopilotCli(runtime, ['plugin', 'list'], signal()); - child.stderr!.emit('data', Buffer.from('permission denied')); - child.emit('close', 7, null); + fixture.callback()(Object.assign(new Error('failed'), { code: 7 }), '', 'permission denied'); await expect(promise).rejects.toMatchObject({ - name: 'CopilotCliProcessError', - message: expect.stringContaining('7, signal null: permission denied'), + name: 'Error', + message: expect.stringContaining('7, signal undefined: permission denied'), }); }); test('rejects termination by a signal instead of treating it as successful empty output', async () => { - const child = childFixture(); - spawnMock.mockReturnValue(child); + const fixture = executionFixture(); const promise = runCopilotCli(runtime, ['plugin', 'list'], signal()); - child.emit('close', null, 'SIGTERM'); + fixture.callback()( + Object.assign(new Error('terminated'), { code: null, signal: 'SIGTERM' as NodeJS.Signals }), + '', + '' + ); await expect(promise).rejects.toMatchObject({ - name: 'CopilotCliProcessError', + name: 'Error', message: expect.stringContaining('SIGTERM'), }); }); - test('preserves spawn failures and waits for close after error', async () => { - const child = childFixture(); - Object.defineProperty(child, 'pid', { value: undefined }); - spawnMock.mockReturnValue(child); - const error = Object.assign(new Error('cannot launch'), { code: 'ENOENT' }); + test('preserves a generic execution error', async () => { + const fixture = executionFixture(); + const error = Object.assign(new Error('execution failed'), { code: 'ENOENT' }); const promise = runCopilotCli(runtime, [], signal()); - const result = promise.then( - () => 'resolved', - (reason) => reason - ); - let settled = false; - void result.then(() => { - settled = true; - }); - child.emit('error', error); - await flush(); - expect(settled).toBe(false); - child.emit('close', -2, null); - await expect(result).resolves.toBe(error); + fixture.callback()(error, '', ''); + await expect(promise).rejects.toBe(error); }); - test('preserves a synchronous spawn error', async () => { - const error = new Error('spawn failed'); - spawnMock.mockImplementationOnce(() => { + test('returns the abort reason reported by execFile', async () => { + const fixture = executionFixture(); + const controller = new AbortController(); + const reason = new Error('cancelled'); + const promise = runCopilotCli(runtime, [], controller.signal); + controller.abort(reason); + fixture.callback()(Object.assign(new Error('aborted'), { code: 'ABORT_ERR' }), '', ''); + await expect(promise).rejects.toBe(reason); + }); + + test('preserves a synchronous execFile error', async () => { + const error = new Error('execution failed'); + execFileMock.mockImplementationOnce(() => { throw error; }); await expect(runCopilotCli(runtime, [], signal())).rejects.toBe(error); @@ -550,180 +305,7 @@ describe('Copilot CLI process execution', () => { const controller = new AbortController(); controller.abort(); await expect(runCopilotCli(runtime, [], controller.signal)).rejects.toMatchObject({ name: 'AbortError' }); - expect(spawnMock).not.toHaveBeenCalled(); - }); - - test('catches cancellation occurring during spawn before the abort listener is registered', async () => { - const child = childFixture(); - const killer = childFixture(4102); - const controller = new AbortController(); - spawnMock - .mockImplementationOnce(() => { - controller.abort(); - return child; - }) - .mockReturnValueOnce(killer); - const result = runCopilotCli(runtime, [], controller.signal).catch((error) => error); - expect(spawnMock).toHaveBeenCalledTimes(2); - child.emit('close', 1, null); - killer.emit('close', 0, null); - await expect(result).resolves.toMatchObject({ name: 'AbortError' }); - }); - - test('does not start taskkill when a failed spawn has no process ID', async () => { - const child = childFixture(); - Object.defineProperty(child, 'pid', { value: undefined }); - spawnMock.mockReturnValue(child); - const controller = new AbortController(); - const result = runCopilotCli(runtime, [], controller.signal).catch((error) => error); - controller.abort(); - child.emit('error', missing(runtime.command)); - child.emit('close', -2, null); - await expect(result).resolves.toMatchObject({ name: 'AbortError' }); - expect(spawnMock).toHaveBeenCalledTimes(1); - }); - - test.each(['child-first', 'killer-first'])( - 'waits for both CLI close and Windows tree termination (%s)', - async (order) => { - const child = childFixture(); - const killer = childFixture(4102); - spawnMock.mockReturnValueOnce(child).mockReturnValueOnce(killer); - const controller = new AbortController(); - const result = runCopilotCli(runtime, [], controller.signal).catch((error) => error); - let settled = false; - void result.then(() => { - settled = true; - }); - controller.abort(); - expect(spawnMock).toHaveBeenLastCalledWith( - 'C:\\Windows\\System32\\taskkill.exe', - ['/PID', '4101', '/T', '/F'], - expect.objectContaining({ shell: false, windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] }) - ); - expect(child.kill).not.toHaveBeenCalled(); - (order === 'child-first' ? child : killer).emit('close', 0, null); - await flush(); - expect(settled).toBe(false); - (order === 'child-first' ? killer : child).emit('close', 0, null); - await expect(result).resolves.toMatchObject({ name: 'AbortError' }); - } - ); - - test('uses a private POSIX process group to cancel CLI and Git descendants', async () => { - setPlatform('linux'); - const child = childFixture(); - spawnMock.mockReturnValue(child); - const controller = new AbortController(); - const result = runCopilotCli({ ...runtime, command: '/usr/bin/copilot' }, [], controller.signal).catch( - (error) => error - ); - controller.abort(); - expect(spawnMock).toHaveBeenCalledWith( - '/usr/bin/copilot', - [], - expect.objectContaining({ detached: true, cwd: '/home/fixture' }) - ); - expect(process.kill).toHaveBeenCalledWith(-4101, 'SIGKILL'); - child.emit('close', null, 'SIGKILL'); - await expect(result).resolves.toMatchObject({ name: 'AbortError' }); - }); - - test('tolerates an already-exited POSIX process group during cancellation', async () => { - setPlatform('linux'); - const child = childFixture(); - spawnMock.mockReturnValue(child); - jest.mocked(process.kill).mockImplementationOnce(() => { - throw Object.assign(new Error('gone'), { code: 'ESRCH' }); - }); - const controller = new AbortController(); - const result = runCopilotCli(runtime, [], controller.signal).catch((error) => error); - controller.abort(); - child.emit('close', 0, null); - await expect(result).resolves.toMatchObject({ name: 'AbortError' }); - }); - - test.each(['stdout', 'stderr'] as const)( - 'bounds %s and waits for process-tree cleanup on overflow', - async (stream) => { - const child = childFixture(); - const killer = childFixture(4102); - spawnMock.mockReturnValueOnce(child).mockReturnValueOnce(killer); - const result = runCopilotCli(runtime, [], signal()).catch((error) => error); - let settled = false; - void result.then(() => { - settled = true; - }); - child[stream]!.emit('data', Buffer.alloc(1024 * 1024 + 1)); - await flush(); - expect(settled).toBe(false); - expect(spawnMock).toHaveBeenCalledTimes(2); - child.emit('close', 1, null); - killer.emit('close', 0, null); - await expect(result).resolves.toMatchObject({ name: 'CopilotCliOutputLimitError' }); - } - ); - - test('applies one combined output limit to stdout and stderr', async () => { - const child = childFixture(); - const killer = childFixture(4102); - spawnMock.mockReturnValueOnce(child).mockReturnValueOnce(killer); - const result = runCopilotCli(runtime, [], signal()).catch((error) => error); - child.stdout!.emit('data', Buffer.alloc(600 * 1024)); - child.stderr!.emit('data', Buffer.alloc(600 * 1024)); - child.emit('close', 1, null); - killer.emit('close', 0, null); - await expect(result).resolves.toMatchObject({ name: 'CopilotCliOutputLimitError' }); - }); - - test('surfaces Windows tree-kill failures and still waits for the child', async () => { - const child = childFixture(); - const killer = childFixture(4102); - spawnMock.mockReturnValueOnce(child).mockReturnValueOnce(killer); - const controller = new AbortController(); - const result = runCopilotCli(runtime, [], controller.signal).catch((error) => error); - let settled = false; - void result.then(() => { - settled = true; - }); - controller.abort(); - killer.stderr!.emit('data', Buffer.from('access denied')); - killer.emit('close', 1, null); - await flush(); - expect(child.kill).toHaveBeenCalledWith('SIGKILL'); - expect(settled).toBe(false); - child.emit('close', 1, null); - await expect(result).resolves.toMatchObject({ - name: 'CopilotCliTerminationError', - cause: expect.any(AggregateError), - }); - }); - - test('reports taskkill spawn failure without releasing the gate before child close', async () => { - const child = childFixture(); - const killer = childFixture(4102); - spawnMock.mockReturnValueOnce(child).mockReturnValueOnce(killer); - const controller = new AbortController(); - const result = runCopilotCli(runtime, [], controller.signal).catch((error) => error); - controller.abort(); - killer.emit('error', new Error('taskkill unavailable')); - killer.emit('close', -2, null); - await flush(); - expect(child.kill).toHaveBeenCalledWith('SIGKILL'); - child.emit('close', 1, null); - await expect(result).resolves.toMatchObject({ name: 'CopilotCliTerminationError' }); - }); - - test('removes the abort listener after completion, so later cancellation cannot kill a reused PID', async () => { - const child = childFixture(); - spawnMock.mockReturnValue(child); - const controller = new AbortController(); - const promise = runCopilotCli(runtime, [], controller.signal); - child.emit('close', 0, null); - await promise; - controller.abort(); - expect(spawnMock).toHaveBeenCalledTimes(1); - expect(process.kill).not.toHaveBeenCalled(); + expect(execFileMock).not.toHaveBeenCalled(); }); }); @@ -765,38 +347,18 @@ describe('Copilot plain-text plugin inventory', () => { ).toEqual([{ name: 'dotnet', enabled: true, kind: 'external' }]); }); - test('allows explicit empty installed inventory with bundled plugins', () => { - expect( - parsePluginList('No plugins installed.\nBuilt-in Plugins (bundled with the CLI):\n • computer-use\n') - ).toEqual([{ name: 'computer-use', enabled: true, kind: 'builtin' }]); + test.each([ + ['Installed plugins:\n • dotnet (v1.0.0)\n • broken (', ['dotnet']], + ['Installed plugins:\n • dotnet@@market', []], + ['Installed plugins:\n • ../dotnet', []], + ['Installed plugins:\n • --help', []], + ])('ignores entries it cannot safely interpret %j', (output, names) => { + expect(parsePluginList(output as string).map((plugin) => plugin.name)).toEqual(names); }); - test.each([ - '', - ' \n', - '[]', - '{"plugins":[]}', - 'Warning: could not read plugins', - 'Installed plugins:', - 'Installed plugins:\n • dotnet (v0.', - 'Installed plugins:\n • dotnet (v1.2.3', - 'Installed plugins:\n • dotnet [dis', - 'Installed plugins:\n • dotnet [unknown]', - 'Installed plugins:\n • dotnet (v1.0.0)\n • broken (', - 'Installed plugins:\n • dotnet\nUnknown plugins:\n • other', - 'Installed plugins:\n • dotnet\nBuilt-in Plugins (bundled with the CLI):', - 'Installed plugins:\n • dotnet\nInstalled plugins:\n • other', - 'Installed plugins:\n • dotnet\n • dotnet', - 'No plugins installed.\nInstalled plugins:\n • dotnet', - 'No plugins installed.\nNo plugins installed.', - "Use 'copilot plugin install ' to install a plugin.", - 'No plugins installed.\nUnknown error', - 'Built-in Plugins (bundled with the CLI):\n • computer-use', - ' • dotnet (v1.0.0)', - 'Installed plugins:\n • dotnet@@market', - 'Installed plugins:\n • ../dotnet', - 'Installed plugins:\n • dotnet\n description: extra unknown output', - ])('rejects malformed, truncated, or ambiguous inventory %j', (output) => { - expect(() => parsePluginList(output)).toThrow(expect.objectContaining({ name: 'CopilotPluginInventoryError' })); + test('rejects output with no recognizable inventory', () => { + expect(() => parsePluginList('Warning: could not read plugins')).toThrow( + 'Unrecognized Copilot plugin inventory' + ); }); }); diff --git a/test/lsptoolshost/unitTests/dotnetPlugin.test.ts b/test/lsptoolshost/unitTests/dotnetPlugin.test.ts index d2f72950ae..250d2676dd 100644 --- a/test/lsptoolshost/unitTests/dotnetPlugin.test.ts +++ b/test/lsptoolshost/unitTests/dotnetPlugin.test.ts @@ -7,10 +7,12 @@ import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globa import * as vscode from 'vscode'; import * as cli from '../../../src/shared/copilot/copilotCli'; import { - DotnetPluginManager, + DotnetPluginHost, dotnetPluginCacheKey, dotnetPluginOptOutKey, + installDotnetPlugin, registerDotnetPlugin, + uninstallDotnetPlugin, uninstallDotnetPluginCommand, } from '../../../src/shared/copilot/dotnetPlugin'; import { commonOptions } from '../../../src/shared/options'; @@ -48,9 +50,9 @@ const parse = jest.mocked(cli.parsePluginList); const showInformation = jest.mocked<(message: string, ...items: string[]) => Thenable>( vscode.window.showInformationMessage ); -const runtime: cli.CopilotCli = { command: 'copilot', args: [], source: 'standalone' }; +const runtime: cli.CopilotCli = { command: 'copilot', source: 'standalone' }; const plugin: cli.CopilotPlugin = { name: 'dotnet', enabled: true, kind: 'installed' }; -const managers: DotnetPluginManager[] = []; +const signal = () => new AbortController().signal; class MemoryState implements vscode.Memento { readonly values = new Map(); @@ -81,9 +83,8 @@ function fixture() { }; const reporter = { sendTelemetryEvent: jest.fn(), sendTelemetryErrorEvent: jest.fn() }; const channel = { error: jest.fn(), info: jest.fn() }; - const manager = new DotnetPluginManager(context, reporter, channel); - managers.push(manager); - return { state, context, reporter, channel, manager }; + const host: DotnetPluginHost = { context, reporter, channel }; + return { state, context, reporter, channel, host }; } function deferred() { @@ -107,20 +108,18 @@ beforeEach(() => { }); afterEach(() => { - managers.splice(0).forEach((manager) => manager.dispose()); jest.restoreAllMocks(); jest.useRealTimers(); }); -describe('Copilot .NET plugin lifecycle', () => { - test('installs and confirms before caching and notifying', async () => { - const { manager, state, reporter } = fixture(); - parse.mockReturnValueOnce([]).mockReturnValueOnce([plugin]); - await manager.install(); +describe('Copilot .NET plugin installation', () => { + test('installs before caching and notifying', async () => { + const { host, state, reporter } = fixture(); + parse.mockReturnValueOnce([]); + await installDotnetPlugin(host, signal()); expect(run.mock.calls.map((call) => call[1])).toEqual([ ['plugin', 'list'], ['plugin', 'install', 'dotnet/skills:plugins/dotnet'], - ['plugin', 'list'], ]); expect(state.get(dotnetPluginCacheKey)).toEqual({ extensionVersion: '1.2.3', @@ -143,9 +142,9 @@ describe('Copilot .NET plugin lifecycle', () => { test.each(['alreadyInstalled', 'alreadyInstalledDisabled', 'conflictingPlugin'])( 'cached %s skips discovery and all CLI calls', async (outcome) => { - const { manager, state, reporter } = fixture(); + const { host, state, reporter } = fixture(); state.values.set(dotnetPluginCacheKey, { extensionVersion: '1.2.3', outcome, source: 'app' }); - await manager.install(); + await installDotnetPlugin(host, signal()); expect(find).not.toHaveBeenCalled(); expect(run).not.toHaveBeenCalled(); expect(state.update).not.toHaveBeenCalled(); @@ -158,15 +157,14 @@ describe('Copilot .NET plugin lifecycle', () => { } ); - test.each([ - ['0.1.0', 'alreadyInstalled'], - ['9.0.0', 'alreadyInstalled'], - ['1.2.3', 'invalid'], - ])('invalidates old or malformed cache (%s, %s)', async (extensionVersion, outcome) => { - const { manager, state } = fixture(); - state.values.set(dotnetPluginCacheKey, { extensionVersion, outcome, source: 'app' }); - await manager.install(); - expect(state.update).toHaveBeenNthCalledWith(1, dotnetPluginCacheKey, undefined); + test('ignores a cache from another extension version', async () => { + const { host, state } = fixture(); + state.values.set(dotnetPluginCacheKey, { + extensionVersion: '0.1.0', + outcome: 'alreadyInstalled', + source: 'app', + }); + await installDotnetPlugin(host, signal()); expect(find).toHaveBeenCalledTimes(1); expect(state.get(dotnetPluginCacheKey)).toEqual({ extensionVersion: '1.2.3', @@ -181,18 +179,18 @@ describe('Copilot .NET plugin lifecycle', () => { [{ ...plugin, name: 'dotnet@different-marketplace' }, 'conflictingPlugin'], [{ ...plugin, kind: 'builtin' }, 'conflictingPlugin'], ])('preserves and caches existing plugin %j', async (existing, outcome) => { - const { manager, state } = fixture(); + const { host, state } = fixture(); parse.mockReturnValue([existing]); - await manager.install(); + await installDotnetPlugin(host, signal()); expect(run).toHaveBeenCalledTimes(1); expect(state.get(dotnetPluginCacheKey)).toMatchObject({ outcome }); expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); }); - test('unavailable Copilot stays uncached and is discovered on the next activation', async () => { - const { manager, state, context, reporter, channel } = fixture(); + test('unavailable Copilot stays uncached and is rediscovered on the next activation', async () => { + const { host, state, reporter } = fixture(); find.mockResolvedValueOnce(undefined); - await manager.install(); + await installDotnetPlugin(host, signal()); expect(run).not.toHaveBeenCalled(); expect(state.update).not.toHaveBeenCalled(); expect(reporter.sendTelemetryEvent).toHaveBeenCalledWith(TelemetryEventNames.CopilotDotnetPlugin, { @@ -200,15 +198,13 @@ describe('Copilot .NET plugin lifecycle', () => { source: 'none', cached: 'false', }); - const next = new DotnetPluginManager(context, reporter, channel); - managers.push(next); - await next.install(); + await installDotnetPlugin(host, signal()); expect(find).toHaveBeenCalledTimes(2); expect(run).toHaveBeenCalledTimes(1); }); - test.each(['optedOut', 'aiDisabled', 'untrustedWorkspace'])('cheap gate %s precedes cache', async (outcome) => { - const { manager, state, context, reporter } = fixture(); + test.each(['optedOut', 'aiDisabled', 'untrustedWorkspace'])('cheap gate %s precedes the cache', async (outcome) => { + const { host, state, context, reporter } = fixture(); state.values.set(dotnetPluginCacheKey, { extensionVersion: '1.2.3', outcome: 'alreadyInstalled', @@ -222,7 +218,7 @@ describe('Copilot .NET plugin lifecycle', () => { } else { jest.spyOn(vscode.workspace, 'isTrusted', 'get').mockReturnValue(false); } - await manager.install(); + await installDotnetPlugin(host, signal()); expect(find).not.toHaveBeenCalled(); expect(state.update).not.toHaveBeenCalled(); expect(reporter.sendTelemetryEvent).toHaveBeenCalledWith(TelemetryEventNames.CopilotDotnetPlugin, { @@ -232,113 +228,134 @@ describe('Copilot .NET plugin lifecycle', () => { }); }); - test.each(['discovery', 'inventory', 'install', 'confirmation'])( - 'failure during %s is not cached', - async (stage) => { - const { manager, state, reporter, channel } = fixture(); - const error = new Error('private path or output'); - if (stage === 'discovery') { - find.mockRejectedValue(error); - } else if (stage === 'inventory') { - parse.mockImplementation(() => { - throw error; - }); - } else if (stage === 'install') { - parse.mockReturnValue([]); - run.mockResolvedValueOnce('empty').mockRejectedValueOnce(error); - } else { - parse.mockReturnValue([]); - } - await manager.install(); - expect(state.update).not.toHaveBeenCalled(); - expect(channel.error).toHaveBeenCalled(); - expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); - expect(reporter.sendTelemetryEvent).toHaveBeenCalledTimes(1); - expect(reporter.sendTelemetryEvent.mock.calls[0][1]).toMatchObject({ outcome: 'installFailed' }); - expect(reporter.sendTelemetryErrorEvent.mock.calls[0][1]).toMatchObject({ - stage, - outcome: 'installFailed', + test.each(['discovery', 'inventory', 'install'])('failure during %s is not cached', async (stage) => { + const { host, state, reporter, channel } = fixture(); + const error = new Error('private path or output'); + if (stage === 'discovery') { + find.mockRejectedValue(error); + } else if (stage === 'inventory') { + parse.mockImplementation(() => { + throw error; }); - expect(JSON.stringify(reporter.sendTelemetryErrorEvent.mock.calls)).not.toContain('private path'); + } else if (stage === 'install') { + parse.mockReturnValue([]); + run.mockResolvedValueOnce('empty').mockRejectedValueOnce(error); } - ); + await installDotnetPlugin(host, signal()); + expect(state.update).not.toHaveBeenCalled(); + expect(channel.error).toHaveBeenCalled(); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + expect(reporter.sendTelemetryEvent).toHaveBeenCalledTimes(1); + expect(reporter.sendTelemetryEvent.mock.calls[0][1]).toMatchObject({ outcome: 'installFailed' }); + expect(reporter.sendTelemetryErrorEvent.mock.calls[0][1]).toMatchObject({ + stage, + outcome: 'installFailed', + }); + expect(JSON.stringify(reporter.sendTelemetryErrorEvent.mock.calls)).not.toContain('private path'); + }); - test('cache write failure does not turn successful installation into failure', async () => { - const { manager, state, reporter } = fixture(); - parse.mockReturnValueOnce([]).mockReturnValueOnce([plugin]); + test('cache write failure is reported like other installation failures', async () => { + const { host, state, reporter } = fixture(); + parse.mockReturnValueOnce([]); state.update.mockRejectedValueOnce(new Error('storage unavailable')); - await manager.install(); - expect(reporter.sendTelemetryEvent.mock.calls[0][1]).toMatchObject({ outcome: 'installed' }); + await installDotnetPlugin(host, signal()); expect(reporter.sendTelemetryEvent).toHaveBeenCalledTimes(1); + expect(reporter.sendTelemetryEvent.mock.calls[0][1]).toMatchObject({ outcome: 'installFailed' }); expect(reporter.sendTelemetryErrorEvent.mock.calls[0][1]).toMatchObject({ stage: 'cache', - outcome: 'installed', + outcome: 'installFailed', }); - expect(vscode.window.showInformationMessage).toHaveBeenCalled(); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); }); test('uses one overall timeout and cancels a running command', async () => { - const { manager, state, reporter } = fixture(); + const { host, state, reporter } = fixture(); const started = deferred(); - run.mockImplementation(async (_cli, _args, signal) => { - started.resolve(signal); + run.mockImplementation(async (_cli, _args, commandSignal) => { + started.resolve(commandSignal); return await new Promise((_resolve, reject) => { - signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + commandSignal.addEventListener('abort', () => reject(commandSignal.reason), { once: true }); }); }); - const pending = manager.install(); - const signal = await started.promise; + const pending = installDotnetPlugin(host, signal()); + const commandSignal = await started.promise; await jest.advanceTimersByTimeAsync(120_000); await pending; - expect(signal.aborted).toBe(true); + expect(commandSignal.aborted).toBe(true); expect(state.update).not.toHaveBeenCalled(); expect(reporter.sendTelemetryErrorEvent.mock.calls[0][1]).toMatchObject({ 'error.name': 'TimeoutError' }); expect(jest.getTimerCount()).toBe(0); }); - test('discovery timeout prevents a late result from starting CLI work', async () => { - const { manager, reporter } = fixture(); - const found = deferred(); - const started = deferred(); - find.mockImplementation(async () => { - started.resolve(); - return await found.promise; + test('deactivation cancels background work without reporting a failure', async () => { + const { host, state } = fixture(); + const controller = new AbortController(); + const started = deferred(); + parse.mockReturnValue([]); + run.mockImplementation(async (_cli, args, commandSignal) => { + if (args[1] !== 'install') { + return 'inventory'; + } + started.resolve(commandSignal); + return await new Promise((_resolve, reject) => { + commandSignal.addEventListener('abort', () => reject(commandSignal.reason), { once: true }); + }); }); - const pending = manager.install(); - await started.promise; - await jest.advanceTimersByTimeAsync(120_000); + const pending = installDotnetPlugin(host, controller.signal); + const commandSignal = await started.promise; + controller.abort(new Error('deactivated')); await pending; - found.resolve(runtime); + expect(commandSignal.aborted).toBe(true); + expect(state.update).not.toHaveBeenCalled(); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + expect(jest.getTimerCount()).toBe(0); + }); + + test.each(['dismissed', 'opened'])('handles documentation action %s', async (action) => { + const { host, reporter } = fixture(); + parse.mockReturnValueOnce([]); + showInformation.mockResolvedValue(action === 'dismissed' ? undefined : 'Learn More'); + await installDotnetPlugin(host, signal()); await Promise.resolve(); - expect(run).not.toHaveBeenCalled(); - expect(reporter.sendTelemetryEvent.mock.calls[0][1]).toMatchObject({ outcome: 'installFailed' }); + expect(reporter.sendTelemetryEvent).toHaveBeenCalledTimes(1); + expect(reporter.sendTelemetryEvent.mock.calls[0][1]).toMatchObject({ outcome: 'installed' }); + if (action === 'dismissed') { + expect(vscode.env.openExternal).not.toHaveBeenCalled(); + } else { + expect(vscode.env.openExternal).toHaveBeenCalledWith( + 'https://github.com/dotnet/vscode-csharp/blob/main/docs/Copilot-Dotnet-Plugin.md' + ); + } }); +}); - test('uninstall bypasses cached results and persists opt-out across versions', async () => { - const { manager, state, context, reporter, channel } = fixture(); +describe('Copilot .NET plugin removal', () => { + test('bypasses cached results and persists the opt-out across versions', async () => { + const { host, state, context, reporter } = fixture(); state.values.set(dotnetPluginCacheKey, { extensionVersion: '1.2.3', outcome: 'conflictingPlugin', source: 'app', }); parse.mockReturnValueOnce([{ ...plugin, name: 'dotnet@dotnet-agent-skills' }]).mockReturnValueOnce([]); - await manager.uninstall(); + await uninstallDotnetPlugin(host, signal()); expect(state.update).toHaveBeenNthCalledWith(1, dotnetPluginOptOutKey, true); expect(state.get(dotnetPluginCacheKey)).toBeUndefined(); expect(run.mock.calls.map((call) => call[1])).toEqual([ ['plugin', 'list'], ['plugin', 'uninstall', 'dotnet@dotnet-agent-skills'], - ['plugin', 'list'], ]); expect(reporter.sendTelemetryEvent).toHaveBeenCalledWith(TelemetryEventNames.CopilotDotnetPluginUninstall, { outcome: 'uninstalled', source: 'standalone', }); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + 'Uninstalled the Copilot C# LSP plugin. Automatic installation is disabled.', + { modal: true } + ); context.extension.packageJSON.version = '2.0.0'; - const next = new DotnetPluginManager(context, reporter, channel); - managers.push(next); find.mockClear(); - await next.install(); + await installDotnetPlugin(host, signal()); expect(find).not.toHaveBeenCalled(); expect(reporter.sendTelemetryEvent).toHaveBeenLastCalledWith(TelemetryEventNames.CopilotDotnetPlugin, { outcome: 'optedOut', @@ -348,9 +365,9 @@ describe('Copilot .NET plugin lifecycle', () => { }); test.each(['alreadyAbsent', 'copilotNotAvailable', 'uninstallFailed'])( - 'uninstall %s leaves the durable opt-out set', + 'outcome %s leaves the durable opt-out set', async (outcome) => { - const { manager, state, reporter } = fixture(); + const { host, state, reporter } = fixture(); if (outcome === 'alreadyAbsent') { parse.mockReturnValue([]); } @@ -360,161 +377,107 @@ describe('Copilot .NET plugin lifecycle', () => { if (outcome === 'uninstallFailed') { run.mockRejectedValue(new Error('failed')); } - await manager.uninstall(); + await uninstallDotnetPlugin(host, signal()); expect(state.get(dotnetPluginOptOutKey)).toBe(true); expect(reporter.sendTelemetryEvent.mock.calls[0][0]).toBe(TelemetryEventNames.CopilotDotnetPluginUninstall); expect(reporter.sendTelemetryEvent.mock.calls[0][1]).toMatchObject({ outcome }); + if (outcome === 'alreadyAbsent') { + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(expect.any(String), { modal: true }); + } else { + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith(expect.any(String), { modal: true }); + } } ); + test('refuses to remove a different plugin that uses the dotnet name', async () => { + const { host, reporter } = fixture(); + parse.mockReturnValue([{ ...plugin, name: 'dotnet@different-marketplace' }]); + await uninstallDotnetPlugin(host, signal()); + expect(run).toHaveBeenCalledTimes(1); + expect(reporter.sendTelemetryEvent.mock.calls[0][1]).toMatchObject({ outcome: 'uninstallFailed' }); + }); + test('failed opt-out persistence aborts removal', async () => { - const { manager, state, reporter } = fixture(); + const { host, state, reporter } = fixture(); state.update.mockRejectedValueOnce(new Error('storage failed')); - await manager.uninstall(); + await uninstallDotnetPlugin(host, signal()); expect(find).not.toHaveBeenCalled(); expect(run).not.toHaveBeenCalled(); expect(reporter.sendTelemetryErrorEvent.mock.calls[0][1]).toMatchObject({ stage: 'optOut' }); expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( - 'Could not disable automatic installation. See the C# output for details.' + 'Could not disable automatic installation. See the C# output for details.', + { modal: true } ); }); +}); - test('uninstall waits for active installation and suppresses its toast/cache', async () => { - const { manager, state, reporter } = fixture(); - const started = deferred(); - const installed = deferred(); +describe('Copilot .NET plugin registration', () => { + test('starts automatic installation and owns its disposables', async () => { + const { context, reporter, channel } = fixture(); + expect(registerDotnetPlugin(context, reporter, channel)).toBeUndefined(); + expect(find).toHaveBeenCalledTimes(1); + expect(vscode.commands.registerCommand).toHaveBeenCalledWith( + uninstallDotnetPluginCommand, + expect.any(Function) + ); + await jest.advanceTimersByTimeAsync(0); + context.subscriptions.forEach((subscription) => subscription.dispose()); + }); + + test('removal requested during registration waits for the startup installation', async () => { + const { context, reporter, channel } = fixture(); + const installing = deferred(); run.mockImplementation(async (_cli, args) => { if (args[1] === 'install') { - started.resolve(); - return await installed.promise; + return await installing.promise; } return 'inventory'; }); - parse - .mockReturnValueOnce([]) - .mockReturnValueOnce([plugin]) - .mockReturnValueOnce([plugin]) - .mockReturnValueOnce([]); - const installing = manager.install(); - await started.promise; - const uninstalling = manager.uninstall(); + parse.mockReturnValueOnce([]).mockReturnValueOnce([plugin]); + registerDotnetPlugin(context, reporter, channel); + const uninstalling = jest.mocked(vscode.commands.registerCommand).mock.calls[0][1](); + await jest.advanceTimersByTimeAsync(0); expect(run.mock.calls.some((call) => call[1][1] === 'uninstall')).toBe(false); - installed.resolve('installed'); - await Promise.all([installing, uninstalling]); - expect(run.mock.calls.map((call) => call[1][1])).toEqual([ - 'list', - 'install', - 'list', - 'list', - 'uninstall', - 'list', - ]); - expect(state.get(dotnetPluginCacheKey)).toBeUndefined(); - expect(vscode.window.showInformationMessage).not.toHaveBeenCalledWith( - 'Installed the C# LSP .NET plugin for GitHub Copilot', - 'Learn More' - ); + installing.resolve('installed'); + await uninstalling; + expect(run.mock.calls.map((call) => call[1][1])).toEqual(['list', 'install', 'list', 'uninstall']); expect(reporter.sendTelemetryEvent.mock.calls.map((call) => call[0])).toEqual([ TelemetryEventNames.CopilotDotnetPlugin, TelemetryEventNames.CopilotDotnetPluginUninstall, ]); + context.subscriptions.forEach((subscription) => subscription.dispose()); }); - test('pending uninstall prevents queued automatic installation', async () => { - const { manager } = fixture(); - parse.mockReturnValue([]); - await Promise.all([manager.install(), manager.uninstall()]); - expect(run.mock.calls.map((call) => call[1])).toEqual([['plugin', 'list']]); - }); - - test('disposal cancels background work and suppresses notifications', async () => { - const { manager, state } = fixture(); - const started = deferred(); - find.mockImplementation(async (signal) => { - started.resolve(signal); - return await new Promise((_resolve, reject) => { - signal.addEventListener('abort', () => reject(signal.reason), { once: true }); - }); - }); - const pending = manager.install(); - const signal = await started.promise; - manager.dispose(); - await pending; - expect(signal.aborted).toBe(true); - expect(run).not.toHaveBeenCalled(); - expect(state.update).not.toHaveBeenCalled(); - expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); - }); - - test.each(['dismissed', 'opened', 'refused', 'throws'])( - 'documentation action %s cannot change install outcome', - async (action) => { - const { manager, reporter, channel } = fixture(); - parse.mockReturnValueOnce([]).mockReturnValueOnce([plugin]); - showInformation.mockResolvedValue(action === 'dismissed' ? undefined : 'Learn More'); - if (action === 'refused') { - jest.mocked(vscode.env.openExternal).mockResolvedValue(false); - } - if (action === 'throws') { - jest.mocked(vscode.env.openExternal).mockRejectedValue(new Error('browser failed')); - } - await manager.install(); - await Promise.resolve(); - expect(reporter.sendTelemetryEvent).toHaveBeenCalledTimes(1); - expect(reporter.sendTelemetryEvent.mock.calls[0][1]).toMatchObject({ outcome: 'installed' }); - if (action === 'dismissed') { - expect(vscode.env.openExternal).not.toHaveBeenCalled(); - } else { - expect(vscode.env.openExternal).toHaveBeenCalledWith( - 'https://github.com/dotnet/vscode-csharp/blob/main/docs/Copilot-Dotnet-Plugin.md' - ); - } - if (action === 'refused' || action === 'throws') { - expect(channel.error).toHaveBeenCalled(); + test('runs only one removal at a time', async () => { + const { context, reporter, channel } = fixture(); + const firstRemoval = deferred(); + let removalCount = 0; + run.mockImplementation(async (_cli, args) => { + if (args[1] === 'uninstall' && ++removalCount === 1) { + return await firstRemoval.promise; } - } - ); - - test('telemetry failures are logged without recursion or escaping', async () => { - const { manager, reporter, channel } = fixture(); - reporter.sendTelemetryEvent.mockImplementation(() => { - throw new Error('telemetry failed'); - }); - reporter.sendTelemetryErrorEvent.mockImplementation(() => { - throw new Error('telemetry failed'); + return 'inventory'; }); - find.mockRejectedValue(new Error('discovery failed')); - await expect(manager.install()).resolves.toBeUndefined(); - expect(reporter.sendTelemetryEvent).toHaveBeenCalledTimes(1); - expect(reporter.sendTelemetryErrorEvent).toHaveBeenCalledTimes(1); - expect(channel.error).toHaveBeenCalled(); - }); - - test('registration returns before deferred work and owns its disposables', async () => { - const { context, reporter, channel } = fixture(); - const install = jest.spyOn(DotnetPluginManager.prototype, 'install').mockResolvedValue(); - const uninstall = jest.spyOn(DotnetPluginManager.prototype, 'uninstall').mockResolvedValue(); - expect(registerDotnetPlugin(context, reporter, channel)).toBeUndefined(); - expect(install).not.toHaveBeenCalled(); - expect(vscode.commands.registerCommand).toHaveBeenCalledWith( - uninstallDotnetPluginCommand, - expect.any(Function) - ); + parse.mockReturnValue([plugin]); + registerDotnetPlugin(context, reporter, channel); + await jest.advanceTimersByTimeAsync(0); + const command = jest.mocked(vscode.commands.registerCommand).mock.calls[0][1]; + const first = command(); + await jest.advanceTimersByTimeAsync(0); + const second = command(); await jest.advanceTimersByTimeAsync(0); - expect(install).toHaveBeenCalledTimes(1); - const handler = jest.mocked(vscode.commands.registerCommand).mock.calls[0][1]; - await handler(); - expect(uninstall).toHaveBeenCalledTimes(1); + expect(removalCount).toBe(1); + firstRemoval.resolve('uninstalled'); + await Promise.all([first, second]); + expect(removalCount).toBe(2); context.subscriptions.forEach((subscription) => subscription.dispose()); }); test('test extension hosts never start automatic plugin operations', async () => { const { context, reporter, channel } = fixture(); context.extensionMode = vscode.ExtensionMode.Test; - const install = jest.spyOn(DotnetPluginManager.prototype, 'install'); registerDotnetPlugin(context, reporter, channel); await jest.advanceTimersByTimeAsync(0); - expect(install).not.toHaveBeenCalled(); expect(find).not.toHaveBeenCalled(); context.subscriptions.forEach((subscription) => subscription.dispose()); }); From 06d21a69b88e773e542ddde8ffd87f9893784234 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Thu, 17 Sep 2026 18:16:48 -0700 Subject: [PATCH 3/5] setting --- docs/Copilot-Dotnet-Plugin.md | 30 +- l10n/bundle.l10n.json | 5 - package.json | 11 +- package.nls.json | 2 +- src/shared/copilot/dotnetPlugin.ts | 156 ++++----- src/shared/telemetryEventNames.ts | 1 - .../lsptoolshost/unitTests/copilotCli.test.ts | 2 +- .../unitTests/dotnetPlugin.test.ts | 313 ++++++------------ 8 files changed, 179 insertions(+), 341 deletions(-) diff --git a/docs/Copilot-Dotnet-Plugin.md b/docs/Copilot-Dotnet-Plugin.md index e200e11075..5f73a8ded9 100644 --- a/docs/Copilot-Dotnet-Plugin.md +++ b/docs/Copilot-Dotnet-Plugin.md @@ -1,6 +1,6 @@ # C# LSP .NET plugin for GitHub Copilot -The C# extension automatically installs the [official .NET `dotnet` plugin](https://github.com/dotnet/skills/tree/main/plugins/dotnet) when a compatible GitHub Copilot CLI is available on PATH or a GitHub Copilot app runtime is installed on the machine running the extension. +The C# extension automatically installs the [official .NET `dotnet` plugin](https://github.com/dotnet/skills/tree/main/plugins/dotnet) when a compatible GitHub Copilot CLI or GitHub Copilot App is installed. ## Why it is installed @@ -8,33 +8,25 @@ The plugin provides .NET development skills and a C# language-server declaration The plugin's C# language server requires the **.NET 10 SDK** and `dotnet` on PATH. Installing the plugin does not install that SDK or start the language server. Start a new Copilot session, or restart an existing one, to load the plugin. -## Uninstall and prevent automatic reinstallation +## Disable automatic installation and uninstall -Open the Command Palette and run: +Set `dotnet.copilotDotnetPlugin.enableAutoInstall` to `false` and restart the C# extension. This prevents future automatic installation but does not remove an already-installed plugin. -**.NET: Uninstall Copilot C# LSP plugin** +To uninstall make sure all Copilot / VSCode instances are closed, then: +1. For the Copilot CLI, run `copilot plugin uninstall dotnet@dotnet-agent-skills` +2. For the GitHub Copilot App, go to `Customize`, select the `Plugins` tab and right click to uninstall the `dotnet` plugin from `dotnet-agent-skills` -This command uninstalls the `dotnet` plugin if it exists and opts out of automatic installation. +## Install manually -You can also remove the plugin directly from a terminal: +Register the `dotnet-agent-skills` marketplace once, then install the plugin: ```text -copilot plugin uninstall dotnet -``` - -For a marketplace installation, use: - -```text -copilot plugin uninstall dotnet@dotnet-agent-skills -``` - -To use the plugin again, install it manually with the installation command below. -```text -copilot plugin install dotnet/skills:plugins/dotnet +copilot plugin marketplace add dotnet/skills +copilot plugin install dotnet@dotnet-agent-skills ``` ## Troubleshooting -Open **View > Output** and select **C#**. Discovery, inventory, installation, and removal failures are logged without affecting normal C# features. Each install or uninstall operation has an overall two-minute timeout. +Open **View > Output** and select **C#**. Discovery, inventory, and installation failures are logged without affecting normal C# features. Each installation operation has an overall two-minute timeout. If installation is skipped, make sure the Copilot CLI is available on PATH on the extension host. For the GitHub Copilot app, use the app once so its CLI can be extracted, then restart VS Code. An incompatible CLI listing format, unavailable Git/network access, permissions, or organization policy can prevent installation. diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 4a82c755ee..39eaddbddc 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -69,11 +69,6 @@ "Unable to generate assets to build and debug. {0}.": "Unable to generate assets to build and debug. {0}.", "Learn More": "Learn More", "Installed the C# LSP .NET plugin for GitHub Copilot": "Installed the C# LSP .NET plugin for GitHub Copilot", - "Uninstalled the Copilot C# LSP plugin. Automatic installation is disabled.": "Uninstalled the Copilot C# LSP plugin. Automatic installation is disabled.", - "The Copilot C# LSP plugin is not installed. Automatic installation is disabled.": "The Copilot C# LSP plugin is not installed. Automatic installation is disabled.", - "Automatic installation is disabled, but Copilot is unavailable to uninstall the C# LSP plugin.": "Automatic installation is disabled, but Copilot is unavailable to uninstall the C# LSP plugin.", - "Could not disable automatic installation. See the C# output for details.": "Could not disable automatic installation. See the C# output for details.", - "Could not uninstall the Copilot C# LSP plugin. Automatic installation is disabled. See the C# output for details.": "Could not uninstall the Copilot C# LSP plugin. Automatic installation is disabled. See the C# output for details.", "Cannot load Razor OmniSharp language server because the directory was not found: '{0}'": "Cannot load Razor OmniSharp language server because the directory was not found: '{0}'", "Run and Debug: auto-detection found {0} for a launch browser": "Run and Debug: auto-detection found {0} for a launch browser", "Run and Debug: A valid browser is not installed. Please install Edge or Chrome.": "Run and Debug: A valid browser is not installed. Please install Edge or Chrome.", diff --git a/package.json b/package.json index cabf76ade2..39533f8c65 100644 --- a/package.json +++ b/package.json @@ -738,6 +738,12 @@ "id": "ms-dotnettools.csharp.project", "order": 0, "properties": { + "dotnet.copilotDotnetPlugin.enableAutoInstall": { + "type": "boolean", + "default": true, + "description": "%configuration.dotnet.copilotDotnetPlugin.enableAutoInstall%", + "scope": "machine" + }, "dotnet.defaultSolution": { "type": "string", "description": "%configuration.dotnet.defaultSolution.description%", @@ -1859,11 +1865,6 @@ } ], "commands": [ - { - "command": "dotnet.copilot.uninstallDotnetPlugin", - "title": "%command.dotnet.copilot.uninstallDotnetPlugin%", - "category": ".NET" - }, { "command": "o.restart", "title": "%command.o.restart%", diff --git a/package.nls.json b/package.nls.json index ad2ae97baa..62f18e2ce4 100644 --- a/package.nls.json +++ b/package.nls.json @@ -1,5 +1,4 @@ { - "command.dotnet.copilot.uninstallDotnetPlugin": "Uninstall Copilot C# LSP plugin", "command.o.restart": "Restart OmniSharp", "command.o.pickProjectAndStart": "Select Project", "command.dotnet.openSolution": "Open Solution", @@ -31,6 +30,7 @@ "configuration.dotnet.autoInsert.enableAutoInsert": "Enable automatic adjustments of code constructs on typing, including documentation comment insertion, brace formatting adjustments, and raw string literal support.", "configuration.dotnet.formatting.organizeImportsOnFormat": "Specifies whether 'using' directives should be grouped and sorted during document formatting.", "configuration.dotnet.defaultSolution.description": "The path of the default solution to be opened in the workspace when multiple solutions are available.", + "configuration.dotnet.copilotDotnetPlugin.enableAutoInstall": "Automatically install the official .NET plugin for GitHub Copilot when a compatible Copilot installation is available. (Requires extension restart)", "configuration.dotnet.server.path": "Specifies the absolute path to the server (LSP or O#) executable. When left empty the version pinned to the C# Extension is used. (Requires extension restart)", "configuration.dotnet.server.componentPaths": "Allows overriding the folder path for built in components of the language server (for example, override the .roslynDevKit path in the extension directory to use locally built components). (Requires extension restart)", "configuration.dotnet.server.componentPaths.roslynDevKit": "Overrides the folder path for the .roslynDevKit component of the language server", diff --git a/src/shared/copilot/dotnetPlugin.ts b/src/shared/copilot/dotnetPlugin.ts index 17849b1665..cf03cb51ee 100644 --- a/src/shared/copilot/dotnetPlugin.ts +++ b/src/shared/copilot/dotnetPlugin.ts @@ -16,10 +16,11 @@ import { runCopilotCli, } from './copilotCli'; -export const uninstallDotnetPluginCommand = 'dotnet.copilot.uninstallDotnetPlugin'; -export const dotnetPluginOptOutKey = 'csharp.copilotDotnetPlugin.autoInstallDisabled'; +export const dotnetPluginAutoInstallKey = 'dotnet.copilotDotnetPlugin.enableAutoInstall'; export const dotnetPluginCacheKey = 'csharp.copilotDotnetPlugin.checkResult'; -const pluginSource = 'dotnet/skills:plugins/dotnet'; +const marketplaceName = 'dotnet-agent-skills'; +const marketplaceSource = 'dotnet/skills'; +const pluginSource = `dotnet@${marketplaceName}`; const documentationUrl = 'https://github.com/dotnet/vscode-csharp/blob/main/docs/Copilot-Dotnet-Plugin.md'; const operationTimeoutMs = 120_000; @@ -28,15 +29,12 @@ type Outcome = | CachedOutcome | 'installed' | 'copilotNotAvailable' - | 'optedOut' + | 'autoInstallDisabled' | 'aiDisabled' | 'untrustedWorkspace' | 'cancelled' - | 'installFailed' - | 'uninstalled' - | 'alreadyAbsent' - | 'uninstallFailed'; -type Stage = 'optOut' | 'cache' | 'discovery' | 'inventory' | 'install' | 'uninstall'; + | 'installFailed'; +type Stage = 'configuration' | 'cache' | 'discovery' | 'inventory' | 'marketplace' | 'install'; type Source = CopilotCliSource | 'none'; type Cache = { extensionVersion: string; outcome: CachedOutcome; source: CopilotCliSource }; type InstallResult = { @@ -51,7 +49,7 @@ export type DotnetPluginHost = { extension: Pick, 'packageJSON'>; }; reporter: ITelemetryReporter; - channel: Pick; + channel: Pick; }; export function registerDotnetPlugin( @@ -62,30 +60,26 @@ export function registerDotnetPlugin( const host: DotnetPluginHost = { context, reporter, channel }; const controller = new AbortController(); // Other integration suites must not install into the developer's real Copilot profile. - let operation = - context.extensionMode === vscode.ExtensionMode.Test - ? Promise.resolve() - : installDotnetPlugin(host, controller.signal); - context.subscriptions.push( - { dispose: () => controller.abort(named('AbortError', 'The C# extension was deactivated.')) }, - vscode.commands.registerCommand(uninstallDotnetPluginCommand, async () => { - operation = operation.then(async () => uninstallDotnetPlugin(host, controller.signal)); - await operation; - }) - ); + if (context.extensionMode !== vscode.ExtensionMode.Test) { + void installDotnetPlugin(host, controller.signal); + } + context.subscriptions.push({ + dispose: () => controller.abort(named('AbortError', 'The C# extension was deactivated.')), + }); } /** - * Installs only when AI is enabled, the workspace is trusted, the user has not opted out, Copilot is available, + * Installs only when automatic installation and AI are enabled, the workspace is trusted, Copilot is available, * and no existing or conflicting .NET plugin is found. Stable results are cached per extension version. */ export async function installDotnetPlugin(host: DotnetPluginHost, signal: AbortSignal): Promise { - let stage: Stage = 'optOut'; + let stage: Stage = 'configuration'; let source: Source = 'none'; let done = () => {}; try { - const blocked = blockedReason(host.context); + const blocked = blockedReason(); if (blocked) { + trace(host, `Automatic installation skipped (${blocked}).`); report(host, TelemetryEventNames.CopilotDotnetPlugin, blocked, 'none', false); return; } @@ -93,6 +87,7 @@ export async function installDotnetPlugin(host: DotnetPluginHost, signal: AbortS stage = 'cache'; const cached = readCache(host.context); if (cached) { + trace(host, `Using cached result ${cached.outcome} from ${cached.source} source.`); report(host, TelemetryEventNames.CopilotDotnetPlugin, cached.outcome, cached.source, true); return; } @@ -103,15 +98,18 @@ export async function installDotnetPlugin(host: DotnetPluginHost, signal: AbortS done = deadlineResult.done; const cli = await findCopilotCli(); if (!cli) { + trace(host, 'No compatible Copilot CLI found.'); return await completeInstallation(host, { outcome: 'copilotNotAvailable', source: 'none' }); } source = cli.source; + trace(host, `Using ${source} Copilot CLI source.`); stage = 'inventory'; const plugins = await listPlugins(cli, operation); const existing = plugins.filter(isDotnetPlugin); if (existing.length > 0) { const outcome = enabledOutcome(existing); + trace(host, `Existing plugin found (${outcome}).`); stage = 'cache'; return await completeInstallation(host, { outcome, @@ -121,7 +119,7 @@ export async function installDotnetPlugin(host: DotnetPluginHost, signal: AbortS } if (plugins.some(isConflictingPlugin)) { - host.channel.info('Skipping Copilot .NET plugin installation: another plugin uses its name.'); + host.channel.info('Skipping Copilot .NET plugin installation: a plugin by that name is already installed.'); stage = 'cache'; return await completeInstallation(host, { outcome: 'conflictingPlugin', @@ -130,8 +128,12 @@ export async function installDotnetPlugin(host: DotnetPluginHost, signal: AbortS }); } + stage = 'marketplace'; + await ensureMarketplace(cli, operation, host); stage = 'install'; + trace(host, `Installing ${pluginSource} using ${source} source.`); await runCopilotCli(cli, ['plugin', 'install', pluginSource], operation); + trace(host, `Installed ${pluginSource}.`); stage = 'cache'; return await completeInstallation(host, { outcome: 'installed', @@ -150,6 +152,7 @@ export async function installDotnetPlugin(host: DotnetPluginHost, signal: AbortS async function completeInstallation(host: DotnetPluginHost, result: InstallResult): Promise { if (result.cache) { + trace(host, `Caching ${result.cache.outcome} result for ${result.cache.source} source.`); await host.context.globalState.update(dotnetPluginCacheKey, { extensionVersion: host.context.extension.packageJSON.version, ...result.cache, @@ -165,51 +168,9 @@ function finishInstallation(host: DotnetPluginHost, result: InstallResult): void } } -export async function uninstallDotnetPlugin(host: DotnetPluginHost, signal: AbortSignal): Promise { - let stage: Stage = 'optOut'; - let outcome: Outcome; - let source: Source = 'none'; - const { signal: operation, done } = deadline(signal); - try { - // Persist the opt-out first so that a failed removal still stops automatic installation. - await host.context.globalState.update(dotnetPluginOptOutKey, true); - stage = 'cache'; - await host.context.globalState.update(dotnetPluginCacheKey, undefined); - stage = 'discovery'; - const cli = await findCopilotCli(); - source = cli?.source ?? 'none'; - if (!cli) { - outcome = 'copilotNotAvailable'; - } else { - stage = 'inventory'; - const plugins = await listPlugins(cli, operation); - const targets = plugins.filter(isDotnetPlugin); - if (targets.length === 0 && plugins.some(isConflictingPlugin)) { - throw new Error('A different plugin uses the dotnet name; it has not been removed.'); - } else if (targets.length === 0) { - outcome = 'alreadyAbsent'; - } else { - stage = 'uninstall'; - for (const target of targets) { - await runCopilotCli(cli, ['plugin', 'uninstall', target.name], operation); - } - outcome = 'uninstalled'; - } - } - } catch (error) { - outcome = signal.aborted ? 'cancelled' : 'uninstallFailed'; - reportError(host, stage, outcome, signal.aborted ? signal.reason : error); - } finally { - done(); - } - - report(host, TelemetryEventNames.CopilotDotnetPluginUninstall, outcome, source); - void showUninstallResult(outcome, stage); -} - -function blockedReason(context: DotnetPluginHost['context']): Outcome | undefined { - if (context.globalState.get(dotnetPluginOptOutKey, false)) { - return 'optedOut'; +function blockedReason(): Outcome | undefined { + if (!vscode.workspace.getConfiguration().get(dotnetPluginAutoInstallKey, true)) { + return 'autoInstallDisabled'; } if (commonOptions.disableAIFeatures) { return 'aiDisabled'; @@ -238,6 +199,32 @@ async function listPlugins(cli: CopilotCli, signal: AbortSignal): Promise { + const output = await runCopilotCli(cli, ['plugin', 'marketplace', 'list', '--json'], signal); + const inventory: unknown = JSON.parse(output); + if (!Array.isArray(inventory)) { + throw new Error('Unrecognized Copilot marketplace inventory'); + } + + const names: string[] = []; + for (const marketplace of inventory) { + if ( + typeof marketplace !== 'object' || + marketplace === null || + !('name' in marketplace) || + typeof marketplace.name !== 'string' + ) { + throw new Error('Unrecognized Copilot marketplace inventory'); + } + names.push(marketplace.name); + } + + if (!names.includes(marketplaceName)) { + trace(host, `Registering ${marketplaceName} marketplace.`); + await runCopilotCli(cli, ['plugin', 'marketplace', 'add', marketplaceSource], signal); + } +} + function enabledOutcome(plugins: CopilotPlugin[]): CachedOutcome { return plugins.some((plugin) => plugin.enabled) ? 'alreadyInstalled' : 'alreadyInstalledDisabled'; } @@ -249,6 +236,7 @@ function report( source: Source, cached?: boolean ): void { + host.channel.trace(`Copilot .NET plugin result: ${outcome} (source: ${source}, cached: ${cached ?? false})`); host.reporter.sendTelemetryEvent(event, { outcome, source, @@ -256,6 +244,10 @@ function report( }); } +function trace(host: DotnetPluginHost, message: string): void { + host.channel.trace(`Copilot .NET plugin: ${message}`); +} + function reportError(host: DotnetPluginHost, stage: Stage, outcome: Outcome, error: unknown): void { host.channel.error(`Copilot .NET plugin ${stage} failed`, error); host.reporter.sendTelemetryErrorEvent(TelemetryEventNames.CopilotDotnetPluginError, { @@ -276,30 +268,6 @@ async function showInstalled(): Promise { } } -async function showUninstallResult(outcome: Outcome, stage: Stage): Promise { - if (outcome === 'uninstalled' || outcome === 'alreadyAbsent') { - await vscode.window.showInformationMessage( - outcome === 'uninstalled' - ? vscode.l10n.t('Uninstalled the Copilot C# LSP plugin. Automatic installation is disabled.') - : vscode.l10n.t('The Copilot C# LSP plugin is not installed. Automatic installation is disabled.'), - { modal: true } - ); - } else { - await vscode.window.showWarningMessage( - outcome === 'copilotNotAvailable' - ? vscode.l10n.t( - 'Automatic installation is disabled, but Copilot is unavailable to uninstall the C# LSP plugin.' - ) - : stage === 'optOut' - ? vscode.l10n.t('Could not disable automatic installation. See the C# output for details.') - : vscode.l10n.t( - 'Could not uninstall the Copilot C# LSP plugin. Automatic installation is disabled. See the C# output for details.' - ), - { modal: true } - ); - } -} - function named(name: string, message: string): Error { return Object.assign(new Error(message), { name }); } diff --git a/src/shared/telemetryEventNames.ts b/src/shared/telemetryEventNames.ts index c937436079..04ae4f1da4 100644 --- a/src/shared/telemetryEventNames.ts +++ b/src/shared/telemetryEventNames.ts @@ -11,7 +11,6 @@ export enum TelemetryEventNames { CSharpActivated = 'CSharpActivated', CSharpLimitedActivation = 'CSharpLimitedActivation', CopilotDotnetPlugin = 'copilotDotnetPlugin', - CopilotDotnetPluginUninstall = 'copilotDotnetPlugin/uninstall', CopilotDotnetPluginError = 'copilotDotnetPlugin/error', // Events related to the roslyn language server. diff --git a/test/lsptoolshost/unitTests/copilotCli.test.ts b/test/lsptoolshost/unitTests/copilotCli.test.ts index 8dfc1ddd9a..a5b54351ab 100644 --- a/test/lsptoolshost/unitTests/copilotCli.test.ts +++ b/test/lsptoolshost/unitTests/copilotCli.test.ts @@ -210,7 +210,7 @@ describe('Copilot CLI filesystem discovery', () => { describe('Copilot CLI process execution', () => { test('uses the shell for a standalone CLI with an argument array and closed stdin', async () => { const fixture = executionFixture(); - const args = ['plugin', 'install', 'dotnet/skills:plugins/dotnet']; + const args = ['plugin', 'install', 'dotnet@dotnet-agent-skills']; const operation = signal(); const promise = runCopilotCli(runtime, args, operation); expect(execFileMock).toHaveBeenCalledWith( diff --git a/test/lsptoolshost/unitTests/dotnetPlugin.test.ts b/test/lsptoolshost/unitTests/dotnetPlugin.test.ts index 250d2676dd..0fd85d9a5f 100644 --- a/test/lsptoolshost/unitTests/dotnetPlugin.test.ts +++ b/test/lsptoolshost/unitTests/dotnetPlugin.test.ts @@ -8,24 +8,22 @@ import * as vscode from 'vscode'; import * as cli from '../../../src/shared/copilot/copilotCli'; import { DotnetPluginHost, + dotnetPluginAutoInstallKey, dotnetPluginCacheKey, - dotnetPluginOptOutKey, installDotnetPlugin, registerDotnetPlugin, - uninstallDotnetPlugin, - uninstallDotnetPluginCommand, } from '../../../src/shared/copilot/dotnetPlugin'; import { commonOptions } from '../../../src/shared/options'; import { TelemetryEventNames } from '../../../src/shared/telemetryEventNames'; jest.mock('vscode', () => ({ workspace: { + getConfiguration: jest.fn(), get isTrusted() { return true; }, }, - window: { showInformationMessage: jest.fn(), showWarningMessage: jest.fn() }, - commands: { registerCommand: jest.fn() }, + window: { showInformationMessage: jest.fn() }, env: { openExternal: jest.fn() }, Uri: { parse: (value: string) => value }, l10n: { t: (value: string) => value }, @@ -47,9 +45,6 @@ jest.mock('../../../src/shared/copilot/copilotCli', () => ({ const find = jest.mocked(cli.findCopilotCli); const run = jest.mocked(cli.runCopilotCli); const parse = jest.mocked(cli.parsePluginList); -const showInformation = jest.mocked<(message: string, ...items: string[]) => Thenable>( - vscode.window.showInformationMessage -); const runtime: cli.CopilotCli = { command: 'copilot', source: 'standalone' }; const plugin: cli.CopilotPlugin = { name: 'dotnet', enabled: true, kind: 'installed' }; const signal = () => new AbortController().signal; @@ -82,7 +77,7 @@ function fixture() { extensionMode: vscode.ExtensionMode.Production, }; const reporter = { sendTelemetryEvent: jest.fn(), sendTelemetryErrorEvent: jest.fn() }; - const channel = { error: jest.fn(), info: jest.fn() }; + const channel = { error: jest.fn(), info: jest.fn(), trace: jest.fn() }; const host: DotnetPluginHost = { context, reporter, channel }; return { state, context, reporter, channel, host }; } @@ -101,10 +96,13 @@ beforeEach(() => { jest.resetAllMocks(); jest.useFakeTimers(); find.mockResolvedValue(runtime); - run.mockResolvedValue('inventory'); + run.mockImplementation(async (_runtime, args) => + args[1] === 'marketplace' && args[2] === 'list' ? '[]' : 'inventory' + ); parse.mockReturnValue([plugin]); - jest.mocked(vscode.env.openExternal).mockResolvedValue(true); - jest.mocked(vscode.commands.registerCommand).mockReturnValue({ dispose: jest.fn() }); + jest.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: (_section: string, defaultValue?: T) => defaultValue, + } as vscode.WorkspaceConfiguration); }); afterEach(() => { @@ -119,17 +117,16 @@ describe('Copilot .NET plugin installation', () => { await installDotnetPlugin(host, signal()); expect(run.mock.calls.map((call) => call[1])).toEqual([ ['plugin', 'list'], - ['plugin', 'install', 'dotnet/skills:plugins/dotnet'], + ['plugin', 'marketplace', 'list', '--json'], + ['plugin', 'marketplace', 'add', 'dotnet/skills'], + ['plugin', 'install', 'dotnet@dotnet-agent-skills'], ]); expect(state.get(dotnetPluginCacheKey)).toEqual({ extensionVersion: '1.2.3', outcome: 'alreadyInstalled', source: 'standalone', }); - expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( - 'Installed the C# LSP .NET plugin for GitHub Copilot', - 'Learn More' - ); + expect(vscode.window.showInformationMessage).toHaveBeenCalledTimes(1); expect(reporter.sendTelemetryEvent).toHaveBeenCalledTimes(1); expect(reporter.sendTelemetryEvent).toHaveBeenCalledWith(TelemetryEventNames.CopilotDotnetPlugin, { outcome: 'installed', @@ -139,6 +136,22 @@ describe('Copilot .NET plugin installation', () => { expect(jest.getTimerCount()).toBe(0); }); + test('uses an already-registered marketplace without adding it again', async () => { + const { host } = fixture(); + parse.mockReturnValueOnce([]); + run.mockImplementation(async (_runtime, args) => + args[1] === 'marketplace' && args[2] === 'list' + ? '[{"name":"dotnet-agent-skills","source":"GitHub: dotnet/skills","isDefault":false}]' + : 'inventory' + ); + await installDotnetPlugin(host, signal()); + expect(run.mock.calls.map((call) => call[1])).toEqual([ + ['plugin', 'list'], + ['plugin', 'marketplace', 'list', '--json'], + ['plugin', 'install', 'dotnet@dotnet-agent-skills'], + ]); + }); + test.each(['alreadyInstalled', 'alreadyInstalledDisabled', 'conflictingPlugin'])( 'cached %s skips discovery and all CLI calls', async (outcome) => { @@ -203,56 +216,75 @@ describe('Copilot .NET plugin installation', () => { expect(run).toHaveBeenCalledTimes(1); }); - test.each(['optedOut', 'aiDisabled', 'untrustedWorkspace'])('cheap gate %s precedes the cache', async (outcome) => { - const { host, state, context, reporter } = fixture(); - state.values.set(dotnetPluginCacheKey, { - extensionVersion: '1.2.3', - outcome: 'alreadyInstalled', - source: 'app', - }); - if (outcome === 'optedOut') { - state.values.set(dotnetPluginOptOutKey, true); - context.extension.packageJSON.version = '2.0.0'; - } else if (outcome === 'aiDisabled') { - jest.spyOn(commonOptions, 'disableAIFeatures', 'get').mockReturnValue(true); - } else { - jest.spyOn(vscode.workspace, 'isTrusted', 'get').mockReturnValue(false); + test.each(['autoInstallDisabled', 'aiDisabled', 'untrustedWorkspace'])( + 'cheap gate %s precedes the cache', + async (outcome) => { + const { host, state, reporter } = fixture(); + state.values.set(dotnetPluginCacheKey, { + extensionVersion: '1.2.3', + outcome: 'alreadyInstalled', + source: 'app', + }); + if (outcome === 'autoInstallDisabled') { + jest.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: (section: string, defaultValue?: unknown) => + section === dotnetPluginAutoInstallKey ? false : defaultValue, + } as vscode.WorkspaceConfiguration); + } else if (outcome === 'aiDisabled') { + jest.spyOn(commonOptions, 'disableAIFeatures', 'get').mockReturnValue(true); + } else { + jest.spyOn(vscode.workspace, 'isTrusted', 'get').mockReturnValue(false); + } + await installDotnetPlugin(host, signal()); + expect(find).not.toHaveBeenCalled(); + expect(state.update).not.toHaveBeenCalled(); + expect(reporter.sendTelemetryEvent).toHaveBeenCalledWith(TelemetryEventNames.CopilotDotnetPlugin, { + outcome, + source: 'none', + cached: 'false', + }); } - await installDotnetPlugin(host, signal()); - expect(find).not.toHaveBeenCalled(); - expect(state.update).not.toHaveBeenCalled(); - expect(reporter.sendTelemetryEvent).toHaveBeenCalledWith(TelemetryEventNames.CopilotDotnetPlugin, { - outcome, - source: 'none', - cached: 'false', - }); - }); + ); - test.each(['discovery', 'inventory', 'install'])('failure during %s is not cached', async (stage) => { - const { host, state, reporter, channel } = fixture(); - const error = new Error('private path or output'); - if (stage === 'discovery') { - find.mockRejectedValue(error); - } else if (stage === 'inventory') { - parse.mockImplementation(() => { - throw error; + test.each(['discovery', 'inventory', 'marketplace', 'install'])( + 'failure during %s is not cached', + async (stage) => { + const { host, state, reporter, channel } = fixture(); + const error = new Error('private path or output'); + if (stage === 'discovery') { + find.mockRejectedValue(error); + } else if (stage === 'inventory') { + parse.mockImplementation(() => { + throw error; + }); + } else { + parse.mockReturnValue([]); + run.mockImplementation(async (_runtime, args) => { + if (stage === 'marketplace' && args[1] === 'marketplace') { + throw error; + } + if (args[1] === 'marketplace' && args[2] === 'list') { + return '[{"name":"dotnet-agent-skills"}]'; + } + if (stage === 'install' && args[1] === 'install') { + throw error; + } + return 'inventory'; + }); + } + await installDotnetPlugin(host, signal()); + expect(state.update).not.toHaveBeenCalled(); + expect(channel.error).toHaveBeenCalled(); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + expect(reporter.sendTelemetryEvent).toHaveBeenCalledTimes(1); + expect(reporter.sendTelemetryEvent.mock.calls[0][1]).toMatchObject({ outcome: 'installFailed' }); + expect(reporter.sendTelemetryErrorEvent.mock.calls[0][1]).toMatchObject({ + stage, + outcome: 'installFailed', }); - } else if (stage === 'install') { - parse.mockReturnValue([]); - run.mockResolvedValueOnce('empty').mockRejectedValueOnce(error); + expect(JSON.stringify(reporter.sendTelemetryErrorEvent.mock.calls)).not.toContain('private path'); } - await installDotnetPlugin(host, signal()); - expect(state.update).not.toHaveBeenCalled(); - expect(channel.error).toHaveBeenCalled(); - expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); - expect(reporter.sendTelemetryEvent).toHaveBeenCalledTimes(1); - expect(reporter.sendTelemetryEvent.mock.calls[0][1]).toMatchObject({ outcome: 'installFailed' }); - expect(reporter.sendTelemetryErrorEvent.mock.calls[0][1]).toMatchObject({ - stage, - outcome: 'installFailed', - }); - expect(JSON.stringify(reporter.sendTelemetryErrorEvent.mock.calls)).not.toContain('private path'); - }); + ); test('cache write failure is reported like other installation failures', async () => { const { host, state, reporter } = fixture(); @@ -293,6 +325,9 @@ describe('Copilot .NET plugin installation', () => { const started = deferred(); parse.mockReturnValue([]); run.mockImplementation(async (_cli, args, commandSignal) => { + if (args[1] === 'marketplace' && args[2] === 'list') { + return '[]'; + } if (args[1] !== 'install') { return 'inventory'; } @@ -310,105 +345,6 @@ describe('Copilot .NET plugin installation', () => { expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); expect(jest.getTimerCount()).toBe(0); }); - - test.each(['dismissed', 'opened'])('handles documentation action %s', async (action) => { - const { host, reporter } = fixture(); - parse.mockReturnValueOnce([]); - showInformation.mockResolvedValue(action === 'dismissed' ? undefined : 'Learn More'); - await installDotnetPlugin(host, signal()); - await Promise.resolve(); - expect(reporter.sendTelemetryEvent).toHaveBeenCalledTimes(1); - expect(reporter.sendTelemetryEvent.mock.calls[0][1]).toMatchObject({ outcome: 'installed' }); - if (action === 'dismissed') { - expect(vscode.env.openExternal).not.toHaveBeenCalled(); - } else { - expect(vscode.env.openExternal).toHaveBeenCalledWith( - 'https://github.com/dotnet/vscode-csharp/blob/main/docs/Copilot-Dotnet-Plugin.md' - ); - } - }); -}); - -describe('Copilot .NET plugin removal', () => { - test('bypasses cached results and persists the opt-out across versions', async () => { - const { host, state, context, reporter } = fixture(); - state.values.set(dotnetPluginCacheKey, { - extensionVersion: '1.2.3', - outcome: 'conflictingPlugin', - source: 'app', - }); - parse.mockReturnValueOnce([{ ...plugin, name: 'dotnet@dotnet-agent-skills' }]).mockReturnValueOnce([]); - await uninstallDotnetPlugin(host, signal()); - expect(state.update).toHaveBeenNthCalledWith(1, dotnetPluginOptOutKey, true); - expect(state.get(dotnetPluginCacheKey)).toBeUndefined(); - expect(run.mock.calls.map((call) => call[1])).toEqual([ - ['plugin', 'list'], - ['plugin', 'uninstall', 'dotnet@dotnet-agent-skills'], - ]); - expect(reporter.sendTelemetryEvent).toHaveBeenCalledWith(TelemetryEventNames.CopilotDotnetPluginUninstall, { - outcome: 'uninstalled', - source: 'standalone', - }); - expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( - 'Uninstalled the Copilot C# LSP plugin. Automatic installation is disabled.', - { modal: true } - ); - context.extension.packageJSON.version = '2.0.0'; - find.mockClear(); - await installDotnetPlugin(host, signal()); - expect(find).not.toHaveBeenCalled(); - expect(reporter.sendTelemetryEvent).toHaveBeenLastCalledWith(TelemetryEventNames.CopilotDotnetPlugin, { - outcome: 'optedOut', - source: 'none', - cached: 'false', - }); - }); - - test.each(['alreadyAbsent', 'copilotNotAvailable', 'uninstallFailed'])( - 'outcome %s leaves the durable opt-out set', - async (outcome) => { - const { host, state, reporter } = fixture(); - if (outcome === 'alreadyAbsent') { - parse.mockReturnValue([]); - } - if (outcome === 'copilotNotAvailable') { - find.mockResolvedValue(undefined); - } - if (outcome === 'uninstallFailed') { - run.mockRejectedValue(new Error('failed')); - } - await uninstallDotnetPlugin(host, signal()); - expect(state.get(dotnetPluginOptOutKey)).toBe(true); - expect(reporter.sendTelemetryEvent.mock.calls[0][0]).toBe(TelemetryEventNames.CopilotDotnetPluginUninstall); - expect(reporter.sendTelemetryEvent.mock.calls[0][1]).toMatchObject({ outcome }); - if (outcome === 'alreadyAbsent') { - expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(expect.any(String), { modal: true }); - } else { - expect(vscode.window.showWarningMessage).toHaveBeenCalledWith(expect.any(String), { modal: true }); - } - } - ); - - test('refuses to remove a different plugin that uses the dotnet name', async () => { - const { host, reporter } = fixture(); - parse.mockReturnValue([{ ...plugin, name: 'dotnet@different-marketplace' }]); - await uninstallDotnetPlugin(host, signal()); - expect(run).toHaveBeenCalledTimes(1); - expect(reporter.sendTelemetryEvent.mock.calls[0][1]).toMatchObject({ outcome: 'uninstallFailed' }); - }); - - test('failed opt-out persistence aborts removal', async () => { - const { host, state, reporter } = fixture(); - state.update.mockRejectedValueOnce(new Error('storage failed')); - await uninstallDotnetPlugin(host, signal()); - expect(find).not.toHaveBeenCalled(); - expect(run).not.toHaveBeenCalled(); - expect(reporter.sendTelemetryErrorEvent.mock.calls[0][1]).toMatchObject({ stage: 'optOut' }); - expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( - 'Could not disable automatic installation. See the C# output for details.', - { modal: true } - ); - }); }); describe('Copilot .NET plugin registration', () => { @@ -416,60 +352,7 @@ describe('Copilot .NET plugin registration', () => { const { context, reporter, channel } = fixture(); expect(registerDotnetPlugin(context, reporter, channel)).toBeUndefined(); expect(find).toHaveBeenCalledTimes(1); - expect(vscode.commands.registerCommand).toHaveBeenCalledWith( - uninstallDotnetPluginCommand, - expect.any(Function) - ); - await jest.advanceTimersByTimeAsync(0); - context.subscriptions.forEach((subscription) => subscription.dispose()); - }); - - test('removal requested during registration waits for the startup installation', async () => { - const { context, reporter, channel } = fixture(); - const installing = deferred(); - run.mockImplementation(async (_cli, args) => { - if (args[1] === 'install') { - return await installing.promise; - } - return 'inventory'; - }); - parse.mockReturnValueOnce([]).mockReturnValueOnce([plugin]); - registerDotnetPlugin(context, reporter, channel); - const uninstalling = jest.mocked(vscode.commands.registerCommand).mock.calls[0][1](); - await jest.advanceTimersByTimeAsync(0); - expect(run.mock.calls.some((call) => call[1][1] === 'uninstall')).toBe(false); - installing.resolve('installed'); - await uninstalling; - expect(run.mock.calls.map((call) => call[1][1])).toEqual(['list', 'install', 'list', 'uninstall']); - expect(reporter.sendTelemetryEvent.mock.calls.map((call) => call[0])).toEqual([ - TelemetryEventNames.CopilotDotnetPlugin, - TelemetryEventNames.CopilotDotnetPluginUninstall, - ]); - context.subscriptions.forEach((subscription) => subscription.dispose()); - }); - - test('runs only one removal at a time', async () => { - const { context, reporter, channel } = fixture(); - const firstRemoval = deferred(); - let removalCount = 0; - run.mockImplementation(async (_cli, args) => { - if (args[1] === 'uninstall' && ++removalCount === 1) { - return await firstRemoval.promise; - } - return 'inventory'; - }); - parse.mockReturnValue([plugin]); - registerDotnetPlugin(context, reporter, channel); - await jest.advanceTimersByTimeAsync(0); - const command = jest.mocked(vscode.commands.registerCommand).mock.calls[0][1]; - const first = command(); - await jest.advanceTimersByTimeAsync(0); - const second = command(); await jest.advanceTimersByTimeAsync(0); - expect(removalCount).toBe(1); - firstRemoval.resolve('uninstalled'); - await Promise.all([first, second]); - expect(removalCount).toBe(2); context.subscriptions.forEach((subscription) => subscription.dispose()); }); From 35552b4fbdde4e8b116c370f0fee0c0f34a4bcdd Mon Sep 17 00:00:00 2001 From: David Barbet Date: Thu, 17 Sep 2026 19:04:38 -0700 Subject: [PATCH 4/5] test script --- .../Test-CopilotDotnetPluginAppInstall.ps1 | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 scripts/Test-CopilotDotnetPluginAppInstall.ps1 diff --git a/scripts/Test-CopilotDotnetPluginAppInstall.ps1 b/scripts/Test-CopilotDotnetPluginAppInstall.ps1 new file mode 100644 index 0000000000..4d67f7b258 --- /dev/null +++ b/scripts/Test-CopilotDotnetPluginAppInstall.ps1 @@ -0,0 +1,254 @@ +#!/usr/bin/env pwsh + +<# +.SYNOPSIS + Manually tests C# extension plugin installation through the GitHub Copilot App runtime. + +.DESCRIPTION + Builds the extension, hides standalone Copilot CLI executables from PATH, and launches + an isolated Extension Development Host for manual verification. + +.PARAMETER SkipBuild + Skips npm run packageDev when the extension has already been built. + +.PARAMETER ValidateOnly + Validates App discovery and PATH isolation without building or launching VS Code. + +.PARAMETER UseExistingCopilotHome + Uses the current Copilot profile instead of a disposable COPILOT_HOME. This may modify + the plugins visible in the GitHub Copilot App. + +.EXAMPLE + ./scripts/Test-CopilotDotnetPluginAppInstall.ps1 + +.EXAMPLE + ./scripts/Test-CopilotDotnetPluginAppInstall.ps1 -SkipBuild + +.EXAMPLE + ./scripts/Test-CopilotDotnetPluginAppInstall.ps1 -ValidateOnly +#> +[CmdletBinding()] +param( + [switch] $SkipBuild, + [switch] $ValidateOnly, + [switch] $UseExistingCopilotHome +) + +$ErrorActionPreference = 'Stop' + +if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { + throw 'This script currently tests the Windows GitHub Copilot App install paths.' +} + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$standaloneNames = @('copilot.exe', 'copilot.cmd', 'copilot.bat') + +function Get-PathDirectories { + param([string] $PathValue) + + return @( + $PathValue -split ';' | + ForEach-Object { $_.Trim().Trim('"') } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + ) +} + +function Get-StandaloneCopilotExecutables { + param([string] $PathValue) + + $executables = foreach ($directory in Get-PathDirectories $PathValue) { + if (-not [System.IO.Path]::IsPathRooted($directory)) { + continue + } + + foreach ($name in $standaloneNames) { + $candidate = Join-Path $directory $name + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + $candidate + } + } + } + + return @($executables) +} + +function Get-PathWithoutStandaloneCopilot { + param([string] $PathValue) + + $directories = foreach ($directory in Get-PathDirectories $PathValue) { + $containsStandaloneCli = $false + if ([System.IO.Path]::IsPathRooted($directory)) { + foreach ($name in $standaloneNames) { + if (Test-Path -LiteralPath (Join-Path $directory $name) -PathType Leaf) { + $containsStandaloneCli = $true + break + } + } + } + + if (-not $containsStandaloneCli) { + $directory + } + } + + return $directories -join ';' +} + +function Find-GitHubCopilotAppRuntime { + param([string] $PathValue) + + if (-not [System.IO.Path]::IsPathRooted($env:LOCALAPPDATA)) { + throw 'LOCALAPPDATA must be an absolute path to locate the GitHub Copilot App CLI cache.' + } + + $roots = [System.Collections.Generic.List[string]]::new() + $roots.Add((Join-Path $env:LOCALAPPDATA 'Programs\GitHub Copilot')) + foreach ($programFilesRoot in @($env:ProgramFiles, ${env:ProgramFiles(x86)})) { + if ([System.IO.Path]::IsPathRooted($programFilesRoot)) { + $roots.Add((Join-Path $programFilesRoot 'GitHub Copilot')) + } + } + foreach ($directory in Get-PathDirectories $PathValue) { + if ([System.IO.Path]::IsPathRooted($directory)) { + $roots.Add($directory) + } + } + + $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($root in $roots) { + if (-not $seen.Add($root)) { + continue + } + + $appExecutable = Join-Path $root 'github.exe' + $metadataPath = Join-Path $root 'copilot-sdk\cliVersion.d.ts' + if (-not (Test-Path -LiteralPath $appExecutable -PathType Leaf) -or + -not (Test-Path -LiteralPath $metadataPath -PathType Leaf)) { + continue + } + + $metadata = Get-Content -LiteralPath $metadataPath -Raw + $versionMatch = [regex]::Match( + $metadata, + 'const COPILOT_CLI_VERSION\s*=\s*"(\d+\.\d+\.\d+[\w.+-]*)";' + ) + if (-not $versionMatch.Success) { + continue + } + + $version = $versionMatch.Groups[1].Value + $cacheVersion = [regex]::Replace($version, '[^a-zA-Z0-9._-]', '_') + $runtime = Join-Path $env:LOCALAPPDATA "github-copilot-sdk\cli\$cacheVersion\copilot.exe" + if (Test-Path -LiteralPath $runtime -PathType Leaf) { + return [pscustomobject]@{ + AppRoot = $root + Version = $version + Runtime = $runtime + } + } + } + + throw 'A complete GitHub Copilot App installation was not found. Open the App once so it can extract its CLI, then retry.' +} + +$codeCommand = Get-Command code.cmd -ErrorAction SilentlyContinue +if (-not $codeCommand) { + $codeCommand = Get-Command code -ErrorAction SilentlyContinue +} +if (-not $codeCommand) { + throw 'The VS Code command-line launcher was not found on PATH.' +} +$codeExecutable = Join-Path (Split-Path (Split-Path $codeCommand.Source -Parent) -Parent) 'Code.exe' +if (-not (Test-Path -LiteralPath $codeExecutable -PathType Leaf)) { + throw "The VS Code executable was not found at $codeExecutable." +} + +$originalPath = $env:PATH +$filteredPath = Get-PathWithoutStandaloneCopilot $originalPath +$standaloneBefore = Get-StandaloneCopilotExecutables $originalPath +$standaloneAfter = Get-StandaloneCopilotExecutables $filteredPath +if ($standaloneAfter.Count -ne 0) { + throw "Failed to hide standalone Copilot CLI executables:`n$($standaloneAfter -join [Environment]::NewLine)" +} + +$app = Find-GitHubCopilotAppRuntime $filteredPath + +Write-Host 'GitHub Copilot App-only test environment is valid.' -ForegroundColor Green +Write-Host " App root: $($app.AppRoot)" +Write-Host " App CLI version: $($app.Version)" +Write-Host " App CLI runtime: $($app.Runtime)" +Write-Host " Standalone CLIs hidden: $($standaloneBefore.Count)" +Write-Host " Standalone CLIs remaining: $($standaloneAfter.Count)" + +if ($ValidateOnly) { + return +} + +if (-not $SkipBuild) { + $npmCommand = Get-Command npm.cmd -ErrorAction SilentlyContinue + if (-not $npmCommand) { + $npmCommand = Get-Command npm -ErrorAction SilentlyContinue + } + if (-not $npmCommand) { + throw 'npm was not found on PATH.' + } + + Write-Host "`nBuilding the extension with npm run packageDev..." -ForegroundColor Cyan + Push-Location $repoRoot + try { + & $npmCommand.Source run packageDev + if ($LASTEXITCODE -ne 0) { + throw "npm run packageDev failed with exit code $LASTEXITCODE." + } + } + finally { + Pop-Location + } +} + +$testRoot = Join-Path ([System.IO.Path]::GetTempPath()) "vscode-csharp-copilot-app-$([guid]::NewGuid().ToString('N'))" +$userDataDirectory = Join-Path $testRoot 'vscode-user' +$copilotHome = Join-Path $testRoot 'copilot-home' +New-Item -ItemType Directory -Path $userDataDirectory -Force | Out-Null +if (-not $UseExistingCopilotHome) { + New-Item -ItemType Directory -Path $copilotHome -Force | Out-Null +} + +$hadCopilotHome = Test-Path Env:COPILOT_HOME +$originalCopilotHome = $env:COPILOT_HOME + +try { + $env:PATH = $filteredPath + if (-not $UseExistingCopilotHome) { + $env:COPILOT_HOME = $copilotHome + } + + Write-Host "`nLaunching an isolated Extension Development Host." -ForegroundColor Cyan + Write-Host '1. Trust the workspace if prompted.' + Write-Host '2. Open View > Output and select C#.' + Write-Host '3. Confirm "Copilot .NET plugin result: installed" and the installation notification.' + Write-Host "`nTemporary test root: $testRoot" + + $codeArguments = @( + '--new-window' + "--user-data-dir=`"$userDataDirectory`"" + '--disable-extension=github.copilot' + '--disable-extension=github.copilot-chat' + '--log=ms-dotnettools.csharp:trace' + "--extensionDevelopmentPath=`"$repoRoot`"" + "`"$repoRoot`"" + ) -join ' ' + Start-Process -FilePath $codeExecutable -ArgumentList $codeArguments + Write-Host 'VS Code launched. Complete the checks in that window.' -ForegroundColor Green +} +finally { + $env:PATH = $originalPath + if ($hadCopilotHome) { + $env:COPILOT_HOME = $originalCopilotHome + } + else { + Remove-Item Env:COPILOT_HOME -ErrorAction SilentlyContinue + } + + Write-Host "Test data is retained at $testRoot." +} \ No newline at end of file From 17a35bccf4b62c062977af2a07e40f359368be84 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Thu, 17 Sep 2026 19:04:55 -0700 Subject: [PATCH 5/5] remove test script --- .../Test-CopilotDotnetPluginAppInstall.ps1 | 254 ------------------ 1 file changed, 254 deletions(-) delete mode 100644 scripts/Test-CopilotDotnetPluginAppInstall.ps1 diff --git a/scripts/Test-CopilotDotnetPluginAppInstall.ps1 b/scripts/Test-CopilotDotnetPluginAppInstall.ps1 deleted file mode 100644 index 4d67f7b258..0000000000 --- a/scripts/Test-CopilotDotnetPluginAppInstall.ps1 +++ /dev/null @@ -1,254 +0,0 @@ -#!/usr/bin/env pwsh - -<# -.SYNOPSIS - Manually tests C# extension plugin installation through the GitHub Copilot App runtime. - -.DESCRIPTION - Builds the extension, hides standalone Copilot CLI executables from PATH, and launches - an isolated Extension Development Host for manual verification. - -.PARAMETER SkipBuild - Skips npm run packageDev when the extension has already been built. - -.PARAMETER ValidateOnly - Validates App discovery and PATH isolation without building or launching VS Code. - -.PARAMETER UseExistingCopilotHome - Uses the current Copilot profile instead of a disposable COPILOT_HOME. This may modify - the plugins visible in the GitHub Copilot App. - -.EXAMPLE - ./scripts/Test-CopilotDotnetPluginAppInstall.ps1 - -.EXAMPLE - ./scripts/Test-CopilotDotnetPluginAppInstall.ps1 -SkipBuild - -.EXAMPLE - ./scripts/Test-CopilotDotnetPluginAppInstall.ps1 -ValidateOnly -#> -[CmdletBinding()] -param( - [switch] $SkipBuild, - [switch] $ValidateOnly, - [switch] $UseExistingCopilotHome -) - -$ErrorActionPreference = 'Stop' - -if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { - throw 'This script currently tests the Windows GitHub Copilot App install paths.' -} - -$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path -$standaloneNames = @('copilot.exe', 'copilot.cmd', 'copilot.bat') - -function Get-PathDirectories { - param([string] $PathValue) - - return @( - $PathValue -split ';' | - ForEach-Object { $_.Trim().Trim('"') } | - Where-Object { -not [string]::IsNullOrWhiteSpace($_) } - ) -} - -function Get-StandaloneCopilotExecutables { - param([string] $PathValue) - - $executables = foreach ($directory in Get-PathDirectories $PathValue) { - if (-not [System.IO.Path]::IsPathRooted($directory)) { - continue - } - - foreach ($name in $standaloneNames) { - $candidate = Join-Path $directory $name - if (Test-Path -LiteralPath $candidate -PathType Leaf) { - $candidate - } - } - } - - return @($executables) -} - -function Get-PathWithoutStandaloneCopilot { - param([string] $PathValue) - - $directories = foreach ($directory in Get-PathDirectories $PathValue) { - $containsStandaloneCli = $false - if ([System.IO.Path]::IsPathRooted($directory)) { - foreach ($name in $standaloneNames) { - if (Test-Path -LiteralPath (Join-Path $directory $name) -PathType Leaf) { - $containsStandaloneCli = $true - break - } - } - } - - if (-not $containsStandaloneCli) { - $directory - } - } - - return $directories -join ';' -} - -function Find-GitHubCopilotAppRuntime { - param([string] $PathValue) - - if (-not [System.IO.Path]::IsPathRooted($env:LOCALAPPDATA)) { - throw 'LOCALAPPDATA must be an absolute path to locate the GitHub Copilot App CLI cache.' - } - - $roots = [System.Collections.Generic.List[string]]::new() - $roots.Add((Join-Path $env:LOCALAPPDATA 'Programs\GitHub Copilot')) - foreach ($programFilesRoot in @($env:ProgramFiles, ${env:ProgramFiles(x86)})) { - if ([System.IO.Path]::IsPathRooted($programFilesRoot)) { - $roots.Add((Join-Path $programFilesRoot 'GitHub Copilot')) - } - } - foreach ($directory in Get-PathDirectories $PathValue) { - if ([System.IO.Path]::IsPathRooted($directory)) { - $roots.Add($directory) - } - } - - $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($root in $roots) { - if (-not $seen.Add($root)) { - continue - } - - $appExecutable = Join-Path $root 'github.exe' - $metadataPath = Join-Path $root 'copilot-sdk\cliVersion.d.ts' - if (-not (Test-Path -LiteralPath $appExecutable -PathType Leaf) -or - -not (Test-Path -LiteralPath $metadataPath -PathType Leaf)) { - continue - } - - $metadata = Get-Content -LiteralPath $metadataPath -Raw - $versionMatch = [regex]::Match( - $metadata, - 'const COPILOT_CLI_VERSION\s*=\s*"(\d+\.\d+\.\d+[\w.+-]*)";' - ) - if (-not $versionMatch.Success) { - continue - } - - $version = $versionMatch.Groups[1].Value - $cacheVersion = [regex]::Replace($version, '[^a-zA-Z0-9._-]', '_') - $runtime = Join-Path $env:LOCALAPPDATA "github-copilot-sdk\cli\$cacheVersion\copilot.exe" - if (Test-Path -LiteralPath $runtime -PathType Leaf) { - return [pscustomobject]@{ - AppRoot = $root - Version = $version - Runtime = $runtime - } - } - } - - throw 'A complete GitHub Copilot App installation was not found. Open the App once so it can extract its CLI, then retry.' -} - -$codeCommand = Get-Command code.cmd -ErrorAction SilentlyContinue -if (-not $codeCommand) { - $codeCommand = Get-Command code -ErrorAction SilentlyContinue -} -if (-not $codeCommand) { - throw 'The VS Code command-line launcher was not found on PATH.' -} -$codeExecutable = Join-Path (Split-Path (Split-Path $codeCommand.Source -Parent) -Parent) 'Code.exe' -if (-not (Test-Path -LiteralPath $codeExecutable -PathType Leaf)) { - throw "The VS Code executable was not found at $codeExecutable." -} - -$originalPath = $env:PATH -$filteredPath = Get-PathWithoutStandaloneCopilot $originalPath -$standaloneBefore = Get-StandaloneCopilotExecutables $originalPath -$standaloneAfter = Get-StandaloneCopilotExecutables $filteredPath -if ($standaloneAfter.Count -ne 0) { - throw "Failed to hide standalone Copilot CLI executables:`n$($standaloneAfter -join [Environment]::NewLine)" -} - -$app = Find-GitHubCopilotAppRuntime $filteredPath - -Write-Host 'GitHub Copilot App-only test environment is valid.' -ForegroundColor Green -Write-Host " App root: $($app.AppRoot)" -Write-Host " App CLI version: $($app.Version)" -Write-Host " App CLI runtime: $($app.Runtime)" -Write-Host " Standalone CLIs hidden: $($standaloneBefore.Count)" -Write-Host " Standalone CLIs remaining: $($standaloneAfter.Count)" - -if ($ValidateOnly) { - return -} - -if (-not $SkipBuild) { - $npmCommand = Get-Command npm.cmd -ErrorAction SilentlyContinue - if (-not $npmCommand) { - $npmCommand = Get-Command npm -ErrorAction SilentlyContinue - } - if (-not $npmCommand) { - throw 'npm was not found on PATH.' - } - - Write-Host "`nBuilding the extension with npm run packageDev..." -ForegroundColor Cyan - Push-Location $repoRoot - try { - & $npmCommand.Source run packageDev - if ($LASTEXITCODE -ne 0) { - throw "npm run packageDev failed with exit code $LASTEXITCODE." - } - } - finally { - Pop-Location - } -} - -$testRoot = Join-Path ([System.IO.Path]::GetTempPath()) "vscode-csharp-copilot-app-$([guid]::NewGuid().ToString('N'))" -$userDataDirectory = Join-Path $testRoot 'vscode-user' -$copilotHome = Join-Path $testRoot 'copilot-home' -New-Item -ItemType Directory -Path $userDataDirectory -Force | Out-Null -if (-not $UseExistingCopilotHome) { - New-Item -ItemType Directory -Path $copilotHome -Force | Out-Null -} - -$hadCopilotHome = Test-Path Env:COPILOT_HOME -$originalCopilotHome = $env:COPILOT_HOME - -try { - $env:PATH = $filteredPath - if (-not $UseExistingCopilotHome) { - $env:COPILOT_HOME = $copilotHome - } - - Write-Host "`nLaunching an isolated Extension Development Host." -ForegroundColor Cyan - Write-Host '1. Trust the workspace if prompted.' - Write-Host '2. Open View > Output and select C#.' - Write-Host '3. Confirm "Copilot .NET plugin result: installed" and the installation notification.' - Write-Host "`nTemporary test root: $testRoot" - - $codeArguments = @( - '--new-window' - "--user-data-dir=`"$userDataDirectory`"" - '--disable-extension=github.copilot' - '--disable-extension=github.copilot-chat' - '--log=ms-dotnettools.csharp:trace' - "--extensionDevelopmentPath=`"$repoRoot`"" - "`"$repoRoot`"" - ) -join ' ' - Start-Process -FilePath $codeExecutable -ArgumentList $codeArguments - Write-Host 'VS Code launched. Complete the checks in that window.' -ForegroundColor Green -} -finally { - $env:PATH = $originalPath - if ($hadCopilotHome) { - $env:COPILOT_HOME = $originalCopilotHome - } - else { - Remove-Item Env:COPILOT_HOME -ErrorAction SilentlyContinue - } - - Write-Host "Test data is retained at $testRoot." -} \ No newline at end of file