diff --git a/.gitignore b/.gitignore index 1edc71c6ff..100457c404 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ logs/ modules modules/ node_modules/ +obj/ +bin/ out/ sessions/ test/.vscode/ diff --git a/examples/.vscode/settings.json b/examples/.vscode/settings.json index b0dfef11b6..fad6823648 100644 --- a/examples/.vscode/settings.json +++ b/examples/.vscode/settings.json @@ -2,5 +2,9 @@ // Use a custom PowerShell Script Analyzer settings file for this workspace. // Relative paths for this setting are always relative to the workspace root dir. "powershell.scriptAnalysis.settingsPath": "./PSScriptAnalyzerSettings.psd1", - "files.defaultLanguage": "powershell" + "files.defaultLanguage": "powershell", + // Suppresses some first-run messages + "git.openRepositoryInParentFolders": "never", + "csharp.suppressDotnetRestoreNotification": true, + "extensions.ignoreRecommendations": true } diff --git a/package-lock.json b/package-lock.json index 63bbafe17f..a9b468dfd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "powershell-preview", + "name": "powershell", "version": "2023.3.3", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "powershell-preview", + "name": "powershell", "version": "2023.3.3", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { @@ -25,10 +25,12 @@ "@types/rewire": "2.5.28", "@types/semver": "7.3.13", "@types/sinon": "10.0.13", + "@types/ungap__structured-clone": "^0.3.0", "@types/uuid": "9.0.1", "@types/vscode": "1.67.0", "@typescript-eslint/eslint-plugin": "5.57.0", "@typescript-eslint/parser": "5.58.0", + "@ungap/structured-clone": "^1.0.2", "@vscode/test-electron": "2.3.0", "@vscode/vsce": "2.18.0", "esbuild": "0.17.16", @@ -720,6 +722,12 @@ "integrity": "sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA==", "dev": true }, + "node_modules/@types/ungap__structured-clone": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@types/ungap__structured-clone/-/ungap__structured-clone-0.3.0.tgz", + "integrity": "sha512-eBWREUhVUGPze+bUW22AgUr05k8u+vETzuYdLYSvWqGTUe0KOf+zVnOB1qER5wMcw8V6D9Ar4DfJmVvD1yu0kQ==", + "dev": true + }, "node_modules/@types/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.1.tgz", @@ -994,6 +1002,12 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.0.2.tgz", + "integrity": "sha512-06PHwE0K24Wi8FBmC8MuMi/+nQ3DTpcXYL3y/IaZz2ScY2GOJXOe8fyMykVXyLOKxpL2Y0frAnJZmm65OxzMLQ==", + "dev": true + }, "node_modules/@vscode/extension-telemetry": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/@vscode/extension-telemetry/-/extension-telemetry-0.6.2.tgz", @@ -5401,6 +5415,12 @@ "integrity": "sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA==", "dev": true }, + "@types/ungap__structured-clone": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@types/ungap__structured-clone/-/ungap__structured-clone-0.3.0.tgz", + "integrity": "sha512-eBWREUhVUGPze+bUW22AgUr05k8u+vETzuYdLYSvWqGTUe0KOf+zVnOB1qER5wMcw8V6D9Ar4DfJmVvD1yu0kQ==", + "dev": true + }, "@types/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.1.tgz", @@ -5555,6 +5575,12 @@ "eslint-visitor-keys": "^3.3.0" } }, + "@ungap/structured-clone": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.0.2.tgz", + "integrity": "sha512-06PHwE0K24Wi8FBmC8MuMi/+nQ3DTpcXYL3y/IaZz2ScY2GOJXOe8fyMykVXyLOKxpL2Y0frAnJZmm65OxzMLQ==", + "dev": true + }, "@vscode/extension-telemetry": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/@vscode/extension-telemetry/-/extension-telemetry-0.6.2.tgz", diff --git a/package.json b/package.json index 552db470a9..f2f3c8c273 100644 --- a/package.json +++ b/package.json @@ -90,10 +90,12 @@ "@types/rewire": "2.5.28", "@types/semver": "7.3.13", "@types/sinon": "10.0.13", + "@types/ungap__structured-clone": "0.3.0", "@types/uuid": "9.0.1", "@types/vscode": "1.67.0", "@typescript-eslint/eslint-plugin": "5.57.0", "@typescript-eslint/parser": "5.58.0", + "@ungap/structured-clone": "1.0.2", "@vscode/test-electron": "2.3.0", "@vscode/vsce": "2.18.0", "esbuild": "0.17.16", @@ -522,6 +524,16 @@ "type": "boolean", "description": "Determines whether a temporary PowerShell Extension Terminal is created for each debugging session, useful for debugging PowerShell classes and binary modules. Overrides the user setting 'powershell.debugging.createTemporaryIntegratedConsole'.", "default": false + }, + "attachDotnetDebugger": { + "type": "boolean", + "description": "If specified, a C# debug session will be started and attached to the new temporary extension terminal. This does nothing unless 'powershell.debugging.createTemporaryIntegratedConsole' is also specified.", + "default": false + }, + "dotnetDebuggerConfigName": { + "type": "string", + "description": "If you would like to use a custom coreclr attach debug launch configuration for the debug session, specify the name here. Otherwise a default basic config will be used. The config must be a coreclr attach config. Launch configs are not supported.", + "default": false } } }, diff --git a/src/features/DebugSession.ts b/src/features/DebugSession.ts index c7fd0847d2..c822d46853 100644 --- a/src/features/DebugSession.ts +++ b/src/features/DebugSession.ts @@ -1,21 +1,38 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import vscode = require("vscode"); import { - CancellationToken, DebugConfiguration, DebugConfigurationProvider, - ExtensionContext, WorkspaceFolder + debug, + CancellationToken, + DebugAdapterDescriptor, + DebugAdapterDescriptorFactory, + DebugAdapterExecutable, + DebugAdapterNamedPipeServer, + DebugConfiguration, + DebugConfigurationProvider, + DebugSession, + ExtensionContext, + WorkspaceFolder, + Disposable, + window, + extensions, + workspace, + commands, + CancellationTokenSource, + InputBoxOptions, + QuickPickItem, + QuickPickOptions } from "vscode"; import { NotificationType, RequestType } from "vscode-languageclient"; import { LanguageClient } from "vscode-languageclient/node"; -import { getPlatformDetails, OperatingSystem } from "../platform"; +import { LanguageClientConsumer } from "../languageClientConsumer"; +import { ILogger } from "../logging"; +import { OperatingSystem, getPlatformDetails } from "../platform"; import { PowerShellProcess } from "../process"; import { IEditorServicesSessionDetails, SessionManager, SessionStatus } from "../session"; import { getSettings } from "../settings"; -import { ILogger } from "../logging"; -import { LanguageClientConsumer } from "../languageClientConsumer"; -import path = require("path"); -import utils = require("../utils"); +import path from "path"; +import { checkIfFileExists } from "../utils"; export const StartDebuggerNotificationType = new NotificationType("powerShell/startDebugger"); @@ -23,72 +40,63 @@ export const StartDebuggerNotificationType = export const StopDebuggerNotificationType = new NotificationType("powerShell/stopDebugger"); -enum DebugConfig { +export enum DebugConfig { LaunchCurrentFile, LaunchScript, InteractiveSession, AttachHostProcess, } +/** Make the implicit behavior of undefined and null in the debug api more explicit */ +type PREVENT_DEBUG_START = undefined; +type PREVENT_DEBUG_START_AND_OPEN_DEBUGCONFIG = null; +type ResolveDebugConfigurationResult = DebugConfiguration | PREVENT_DEBUG_START | PREVENT_DEBUG_START_AND_OPEN_DEBUGCONFIG; + +const PREVENT_DEBUG_START = undefined; +const PREVENT_DEBUG_START_AND_OPEN_DEBUGCONFIG = null; + + +export const defaultDebugConfigurations: Record = { + [DebugConfig.LaunchCurrentFile]: { + name: "PowerShell: Launch Current File", + type: "PowerShell", + request: "launch", + script: "${file}", + args: [], + }, + [DebugConfig.LaunchScript]: { + name: "PowerShell: Launch Script", + type: "PowerShell", + request: "launch", + script: "Enter path or command to execute, for example: \"${workspaceFolder}/src/foo.ps1\" or \"Invoke-Pester\"", + args: [], + }, + [DebugConfig.InteractiveSession]: { + name: "PowerShell: Interactive Session", + type: "PowerShell", + request: "launch", + }, + [DebugConfig.AttachHostProcess]: { + name: "PowerShell: Attach to PowerShell Host Process", + type: "PowerShell", + request: "attach", + runspaceId: 1, + } +}; + export class DebugSessionFeature extends LanguageClientConsumer - implements DebugConfigurationProvider, vscode.DebugAdapterDescriptorFactory { + implements DebugConfigurationProvider, DebugAdapterDescriptorFactory { private sessionCount = 1; private tempDebugProcess: PowerShellProcess | undefined; private tempSessionDetails: IEditorServicesSessionDetails | undefined; - private handlers: vscode.Disposable[] = []; - private configs: Record = { - [DebugConfig.LaunchCurrentFile]: { - name: "PowerShell: Launch Current File", - type: "PowerShell", - request: "launch", - script: "${file}", - args: [], - }, - [DebugConfig.LaunchScript]: { - name: "PowerShell: Launch Script", - type: "PowerShell", - request: "launch", - script: "Enter path or command to execute, for example: \"${workspaceFolder}/src/foo.ps1\" or \"Invoke-Pester\"", - args: [], - }, - [DebugConfig.InteractiveSession]: { - name: "PowerShell: Interactive Session", - type: "PowerShell", - request: "launch", - }, - [DebugConfig.AttachHostProcess]: { - name: "PowerShell: Attach to PowerShell Host Process", - type: "PowerShell", - request: "attach", - runspaceId: 1, - }, - }; + private handlers: Disposable[] = []; constructor(context: ExtensionContext, private sessionManager: SessionManager, private logger: ILogger) { super(); - // Register a debug configuration provider - context.subscriptions.push(vscode.debug.registerDebugConfigurationProvider("PowerShell", this)); - context.subscriptions.push(vscode.debug.registerDebugAdapterDescriptorFactory("PowerShell", this)); - } - - createDebugAdapterDescriptor( - session: vscode.DebugSession, - _executable: vscode.DebugAdapterExecutable | undefined): vscode.ProviderResult { - - const sessionDetails = session.configuration.createTemporaryIntegratedConsole - ? this.tempSessionDetails - : this.sessionManager.getSessionDetails(); - - if (sessionDetails === undefined) { - void this.logger.writeAndShowError(`PowerShell session details not available for ${session.name}`); - return; - } - - this.logger.writeVerbose(`Connecting to pipe: ${sessionDetails.debugServicePipeName}`); - this.logger.writeVerbose(`Debug configuration: ${JSON.stringify(session.configuration)}`); - - return new vscode.DebugAdapterNamedPipeServer(sessionDetails.debugServicePipeName); + // This "activates" the debug adapter for use with You can only do this once. + context.subscriptions.push(debug.registerDebugConfigurationProvider("PowerShell", this)); + context.subscriptions.push(debug.registerDebugAdapterDescriptorFactory("PowerShell", this)); } public dispose(): void { @@ -102,7 +110,7 @@ export class DebugSessionFeature extends LanguageClientConsumer languageClient.onNotification( StartDebuggerNotificationType, // TODO: Use a named debug configuration. - () => void vscode.debug.startDebugging(undefined, { + () => void debug.startDebugging(undefined, { request: "launch", type: "PowerShell", name: "PowerShell: Interactive Session" @@ -110,7 +118,7 @@ export class DebugSessionFeature extends LanguageClientConsumer languageClient.onNotification( StopDebuggerNotificationType, - () => void vscode.debug.stopDebugging(undefined)) + () => void debug.stopDebugging(undefined)) ]; } @@ -142,59 +150,42 @@ export class DebugSessionFeature extends LanguageClientConsumer ]; const launchSelection = - await vscode.window.showQuickPick( + await window.showQuickPick( debugConfigPickItems, { placeHolder: "Select a PowerShell debug configuration" }); if (launchSelection) { - return [this.configs[launchSelection.id]]; + return [defaultDebugConfigurations[launchSelection.id]]; } - return [this.configs[DebugConfig.LaunchCurrentFile]]; + return [defaultDebugConfigurations[DebugConfig.LaunchCurrentFile]]; } - // DebugConfigurationProvider methods + // We don't use await here but we are returning a promise and the return syntax is easier in an async function + // eslint-disable-next-line @typescript-eslint/require-await public async resolveDebugConfiguration( _folder: WorkspaceFolder | undefined, config: DebugConfiguration, - _token?: CancellationToken): Promise { - - // Prevent the Debug Console from opening - config.internalConsoleOptions = "neverOpen"; + _token?: CancellationToken): Promise { // NOTE: We intentionally do not touch the `cwd` setting of the config. - // If the createTemporaryIntegratedConsole field is not specified in the - // launch config, set the field using the value from the corresponding - // setting. Otherwise, the launch config value overrides the setting. - // - // Also start the temporary process and console for this configuration. - const settings = getSettings(); - config.createTemporaryIntegratedConsole = - config.createTemporaryIntegratedConsole ?? - settings.debugging.createTemporaryIntegratedConsole; - - if (config.createTemporaryIntegratedConsole) { - this.tempDebugProcess = await this.sessionManager.createDebugSessionProcess(settings); - this.tempSessionDetails = await this.tempDebugProcess.start(`DebugSession-${this.sessionCount++}`); - } - if (!config.request) { // No launch.json, create the default configuration for both unsaved // (Untitled) and saved documents. - const LaunchCurrentFileConfig = this.configs[DebugConfig.LaunchCurrentFile]; + const LaunchCurrentFileConfig = defaultDebugConfigurations[DebugConfig.LaunchCurrentFile]; config = { ...config, ...LaunchCurrentFileConfig }; config.current_document = true; } if (config.script === "${file}" || config.script === "${relativeFile}") { - if (vscode.window.activeTextEditor === undefined) { + if (window.activeTextEditor === undefined) { void this.logger.writeAndShowError("To debug the 'Current File', you must first open a PowerShell script file in the editor."); - return undefined; + return PREVENT_DEBUG_START; } config.current_document = true; // Special case using the URI for untitled documents. - const currentDocument = vscode.window.activeTextEditor.document; + const currentDocument = window.activeTextEditor.document; if (currentDocument.isUntitled) { config.untitled_document = true; config.script = currentDocument.uri.toString(); @@ -207,87 +198,207 @@ export class DebugSessionFeature extends LanguageClientConsumer public async resolveDebugConfigurationWithSubstitutedVariables( _folder: WorkspaceFolder | undefined, config: DebugConfiguration, - _token?: CancellationToken): Promise { + _token?: CancellationToken): Promise { + + let resolvedConfig: ResolveDebugConfigurationResult; + + // Prevent the Debug Console from opening + config.internalConsoleOptions = "neverOpen"; - let resolvedConfig: DebugConfiguration | undefined | null; + const settings = getSettings(); + config.createTemporaryIntegratedConsole ??= settings.debugging.createTemporaryIntegratedConsole; if (config.request === "attach") { resolvedConfig = await this.resolveAttachDebugConfiguration(config); } else if (config.request === "launch") { resolvedConfig = await this.resolveLaunchDebugConfiguration(config); } else { void this.logger.writeAndShowError(`PowerShell debug configuration's request type was invalid: '${config.request}'.`); - return null; + return PREVENT_DEBUG_START_AND_OPEN_DEBUGCONFIG; } - if (resolvedConfig) { - // Start the PowerShell session if needed. - if (this.sessionManager.getSessionStatus() !== SessionStatus.Running) { - await this.sessionManager.start(); - } - // Create or show the debug terminal (either temporary or session). - this.sessionManager.showDebugTerminal(true); + return resolvedConfig; + } + + // This is our factory entrypoint hook to when a debug session starts, and where we will lazy initialize everything needed to do the debugging such as a temporary console if required + public async createDebugAdapterDescriptor( + session: DebugSession, + _executable: DebugAdapterExecutable | undefined): Promise { + // NOTE: A Promise meets the shape of a ProviderResult, which allows us to make this method async. + + if (this.sessionManager.getSessionStatus() !== SessionStatus.Running) { + await this.sessionManager.start(); } - return resolvedConfig; + const sessionDetails = session.configuration.createTemporaryIntegratedConsole + ? await this.createTemporaryIntegratedConsole(session) + : this.sessionManager.getSessionDetails(); + + if (sessionDetails === undefined) { + return undefined; + } + + // Create or show the debug terminal (either temporary or session). + this.sessionManager.showDebugTerminal(true); + + this.logger.writeVerbose(`Connecting to pipe: ${sessionDetails.debugServicePipeName}`); + this.logger.writeVerbose(`Debug configuration: ${JSON.stringify(session.configuration)}`); + + return new DebugAdapterNamedPipeServer(sessionDetails.debugServicePipeName); } - private async resolveLaunchDebugConfiguration(config: DebugConfiguration): Promise { + private async resolveLaunchDebugConfiguration(config: DebugConfiguration): Promise { // Check the languageId and file extension only for current documents // (which includes untitled documents). This prevents accidentally // running the debugger for an open non-PowerShell file. if (config.current_document) { - const currentDocument = vscode.window.activeTextEditor?.document; + const currentDocument = window.activeTextEditor?.document; if (currentDocument?.languageId !== "powershell") { void this.logger.writeAndShowError(`PowerShell does not support debugging this language mode: '${currentDocument?.languageId}'.`); - return undefined; + return PREVENT_DEBUG_START_AND_OPEN_DEBUGCONFIG; } - if (await utils.checkIfFileExists(config.script)) { + if (await checkIfFileExists(config.script)) { const ext = path.extname(config.script).toLowerCase(); if (!(ext === ".ps1" || ext === ".psm1")) { void this.logger.writeAndShowError(`PowerShell does not support debugging this file type: '${path.basename(config.script)}'.`); - return undefined; + return PREVENT_DEBUG_START_AND_OPEN_DEBUGCONFIG; } } } - // Check the temporary console setting for untitled documents only. if (config.untitled_document && config.createTemporaryIntegratedConsole) { void this.logger.writeAndShowError("PowerShell does not support debugging untitled files in a temporary console."); - return undefined; + return PREVENT_DEBUG_START; + } + + if (!config.createTemporaryIntegratedConsole && config.attachDotnetDebugger) { + void this.logger.writeAndShowError("dotnet debugging without using a temporary console is currently not supported. Please updated your launch config to include createTemporaryIntegratedConsole: true."); + return PREVENT_DEBUG_START_AND_OPEN_DEBUGCONFIG; + } + + if (config.attachDotnetDebugger) { + return this.resolveAttachDotnetDebugConfiguration(config); } return config; } - private async resolveAttachDebugConfiguration(config: DebugConfiguration): Promise { + private resolveAttachDotnetDebugConfiguration(config: DebugConfiguration): ResolveDebugConfigurationResult { + if (!extensions.getExtension("ms-dotnettools.csharp")) { + void this.logger.writeAndShowError("You specified attachDotnetDebugger in your PowerShell Launch configuration but the C# extension is not installed. Please install the C# extension and try again."); + return PREVENT_DEBUG_START; + } + + const dotnetDebuggerConfig = this.getDotnetNamedConfigOrDefault(config.dotnetDebuggerConfigName); + + if (dotnetDebuggerConfig === undefined) { + void this.logger.writeAndShowError(`You specified dotnetDebuggerConfigName in your PowerShell Launch configuration but a matching launch config was not found. Please ensure you have a coreclr attach config with the name ${config.dotnetDebuggerConfigName} in your launch.json file or remove dotnetDebuggerConfigName from your PowerShell Launch configuration to use the defaults`); + return PREVENT_DEBUG_START_AND_OPEN_DEBUGCONFIG; + } + + config.dotnetAttachConfig = dotnetDebuggerConfig; + return config; + } + + private async createTemporaryIntegratedConsole(session: DebugSession): Promise { + const settings = getSettings(); + this.tempDebugProcess = await this.sessionManager.createDebugSessionProcess(settings); + this.tempSessionDetails = await this.tempDebugProcess.start(`DebugSession-${this.sessionCount++}`); + + // NOTE: Dotnet attach debugging is only currently supported if a temporary debug terminal is used, otherwise we get lots of lock conflicts from loading the assemblies. + if (session.configuration.attachDotnetDebugger) { + const dotnetAttachConfig = session.configuration.dotnetAttachConfig; + + // Will wait until the process is started and available before attaching + const pid = await this.tempDebugProcess.getPid(); + if (pid === undefined) { + void this.logger.writeAndShowError("Attach Dotnet Debugger was specified but the PowerShell temporary debug session failed to start. This is probably a bug."); + return PREVENT_DEBUG_START; + } + dotnetAttachConfig.processId = pid; + + // Ensure the .NET session stops before the PowerShell session so that the .NET debug session doesn't emit an error about the process unexpectedly terminating. + const startDebugEvent = debug.onDidStartDebugSession((dotnetAttachSession) => { + // Makes the event one-time + // HACK: This seems like you would be calling a method on a variable not assigned yet, but it does work in the flow. + // The dispose shorthand demonry for making an event one-time courtesy of: https://github.com/OmniSharp/omnisharp-vscode/blob/b8b07bb12557b4400198895f82a94895cb90c461/test/integrationTests/launchConfiguration.integration.test.ts#L41-L45 + startDebugEvent.dispose(); + this.logger.write(`Debugger session detected: ${dotnetAttachSession.name} (${dotnetAttachSession.id})`); + if (dotnetAttachSession.configuration.name == dotnetAttachConfig.name) { + const stopDebugEvent = debug.onDidTerminateDebugSession(async (terminatedDebugSession) => { + // Makes the event one-time + stopDebugEvent.dispose(); + + this.logger.write(`Debugger session stopped: ${terminatedDebugSession.name} (${terminatedDebugSession.id})`); + + if (terminatedDebugSession === session) { + this.logger.write("Terminating dotnet debugger session associated with PowerShell debug session"); + await debug.stopDebugging(dotnetAttachSession); + } + }); + } + }); + + // Start a child debug session to attach the dotnet debugger + // TODO: Accomodate multi-folder workspaces if the C# code is in a different workspace folder + await debug.startDebugging(undefined, dotnetAttachConfig, session); + this.logger.writeVerbose(`Dotnet Attach Debug configuration: ${JSON.stringify(dotnetAttachConfig)}`); + this.logger.write(`Attached dotnet debugger to process ${pid}`); + } + return this.tempSessionDetails; + } + + private getDotnetNamedConfigOrDefault(configName?: string): ResolveDebugConfigurationResult { + if (configName) { + const debugConfigs = workspace.getConfiguration("launch").get("configurations") ?? []; + return debugConfigs.find(({ type, request, name, dotnetDebuggerConfigName }) => + type === "coreclr" && + request === "attach" && + name === dotnetDebuggerConfigName + ); + } + + // Default debugger config if none provided + // TODO: Type this appropriately from the C# extension? + return { + name: "Dotnet Debugger: Temporary Extension Terminal", + type: "coreclr", + request: "attach", + processId: undefined, + logging: { + moduleLoad: false + } + }; + } + + private async resolveAttachDebugConfiguration(config: DebugConfiguration): Promise { const platformDetails = getPlatformDetails(); const versionDetails = this.sessionManager.getPowerShellVersionDetails(); if (versionDetails === undefined) { void this.logger.writeAndShowError(`PowerShell session version details were not found for '${config.name}'.`); - return null; + return PREVENT_DEBUG_START; } // Cross-platform attach to process was added in 6.2.0-preview.4. if (versionDetails.version < "7.0.0" && platformDetails.operatingSystem !== OperatingSystem.Windows) { - void this.logger.writeAndShowError(`Attaching to a PowerShell Host Process on ${OperatingSystem[platformDetails.operatingSystem]} requires PowerShell 7.0 or higher.`); - return undefined; + void this.logger.writeAndShowError(`Attaching to a PowerShell Host Process on ${OperatingSystem[platformDetails.operatingSystem]} requires PowerShell 7.0 or higher (Current Version: ${versionDetails.version}).`); + return PREVENT_DEBUG_START; } // If nothing is set, prompt for the processId. if (!config.customPipeName && !config.processId) { - config.processId = await vscode.commands.executeCommand("PowerShell.PickPSHostProcess"); + config.processId = await commands.executeCommand("PowerShell.PickPSHostProcess"); // No process selected. Cancel attach. if (!config.processId) { - return null; + return PREVENT_DEBUG_START; } } if (!config.runspaceId && !config.runspaceName) { - config.runspaceId = await vscode.commands.executeCommand("PowerShell.PickRunspace", config.processId); + config.runspaceId = await commands.executeCommand("PowerShell.PickRunspace", config.processId); // No runspace selected. Cancel attach. if (!config.runspaceId) { - return null; + return PREVENT_DEBUG_START; } } @@ -295,15 +406,15 @@ export class DebugSessionFeature extends LanguageClientConsumer } } -export class SpecifyScriptArgsFeature implements vscode.Disposable { +export class SpecifyScriptArgsFeature implements Disposable { - private command: vscode.Disposable; - private context: vscode.ExtensionContext; + private command: Disposable; + private context: ExtensionContext; - constructor(context: vscode.ExtensionContext) { + constructor(context: ExtensionContext) { this.context = context; - this.command = vscode.commands.registerCommand("PowerShell.SpecifyScriptArgs", () => { + this.command = commands.registerCommand("PowerShell.SpecifyScriptArgs", () => { return this.specifyScriptArguments(); }); } @@ -315,7 +426,7 @@ export class SpecifyScriptArgsFeature implements vscode.Disposable { private async specifyScriptArguments(): Promise { const powerShellDbgScriptArgsKey = "powerShellDebugScriptArgs"; - const options: vscode.InputBoxOptions = { + const options: InputBoxOptions = { ignoreFocusOut: true, placeHolder: "Enter script arguments or leave empty to pass no args", }; @@ -325,7 +436,7 @@ export class SpecifyScriptArgsFeature implements vscode.Disposable { options.value = prevArgs; } - const text = await vscode.window.showInputBox(options); + const text = await window.showInputBox(options); // When user cancel's the input box (by pressing Esc), the text value is undefined. // Let's not blow away the previous setting. if (text !== undefined) { @@ -335,7 +446,7 @@ export class SpecifyScriptArgsFeature implements vscode.Disposable { } } -interface IProcessItem extends vscode.QuickPickItem { +interface IProcessItem extends QuickPickItem { pid: string; // payload for the QuickPick UI } @@ -355,15 +466,15 @@ export const GetPSHostProcessesRequestType = export class PickPSHostProcessFeature extends LanguageClientConsumer { - private command: vscode.Disposable; - private waitingForClientToken?: vscode.CancellationTokenSource; + private command: Disposable; + private waitingForClientToken?: CancellationTokenSource; private getLanguageClientResolve?: (value: LanguageClient) => void; constructor(private logger: ILogger) { super(); this.command = - vscode.commands.registerCommand("PowerShell.PickPSHostProcess", () => { + commands.registerCommand("PowerShell.PickPSHostProcess", () => { return this.getLanguageClient() .then((_) => this.pickPSHostProcess(), (_) => undefined); }); @@ -388,13 +499,13 @@ export class PickPSHostProcessFeature extends LanguageClientConsumer { } else { // If PowerShell isn't finished loading yet, show a loading message // until the LanguageClient is passed on to us - this.waitingForClientToken = new vscode.CancellationTokenSource(); + this.waitingForClientToken = new CancellationTokenSource(); return new Promise( (resolve, reject) => { this.getLanguageClientResolve = resolve; - vscode.window + window .showQuickPick( ["Cancel"], { placeHolder: "Attach to PowerShell host process: Please wait, starting PowerShell..." }, @@ -446,12 +557,12 @@ export class PickPSHostProcessFeature extends LanguageClientConsumer { return Promise.reject("There are no PowerShell host processes to attach to."); } - const options: vscode.QuickPickOptions = { + const options: QuickPickOptions = { placeHolder: "Select a PowerShell host process to attach to", matchOnDescription: true, matchOnDetail: true, }; - const item = await vscode.window.showQuickPick(items, options); + const item = await window.showQuickPick(items, options); return item ? `${item.pid}` : undefined; } @@ -462,7 +573,7 @@ export class PickPSHostProcessFeature extends LanguageClientConsumer { } } -interface IRunspaceItem extends vscode.QuickPickItem { +interface IRunspaceItem extends QuickPickItem { id: string; // payload for the QuickPick UI } @@ -481,14 +592,14 @@ export const GetRunspaceRequestType = export class PickRunspaceFeature extends LanguageClientConsumer { - private command: vscode.Disposable; - private waitingForClientToken?: vscode.CancellationTokenSource; + private command: Disposable; + private waitingForClientToken?: CancellationTokenSource; private getLanguageClientResolve?: (value: LanguageClient) => void; constructor(private logger: ILogger) { super(); this.command = - vscode.commands.registerCommand("PowerShell.PickRunspace", (processId) => { + commands.registerCommand("PowerShell.PickRunspace", (processId) => { return this.getLanguageClient() .then((_) => this.pickRunspace(processId), (_) => undefined); }, this); @@ -513,13 +624,13 @@ export class PickRunspaceFeature extends LanguageClientConsumer { } else { // If PowerShell isn't finished loading yet, show a loading message // until the LanguageClient is passed on to us - this.waitingForClientToken = new vscode.CancellationTokenSource(); + this.waitingForClientToken = new CancellationTokenSource(); return new Promise( (resolve, reject) => { this.getLanguageClientResolve = resolve; - vscode.window + window .showQuickPick( ["Cancel"], { placeHolder: "Attach to PowerShell host process: Please wait, starting PowerShell..." }, @@ -562,12 +673,12 @@ export class PickRunspaceFeature extends LanguageClientConsumer { }); } - const options: vscode.QuickPickOptions = { + const options: QuickPickOptions = { placeHolder: "Select PowerShell runspace to debug", matchOnDescription: true, matchOnDetail: true, }; - const item = await vscode.window.showQuickPick(items, options); + const item = await window.showQuickPick(items, options); return item ? `${item.id}` : undefined; } diff --git a/src/process.ts b/src/process.ts index 26647b441a..24c1f51314 100644 --- a/src/process.ts +++ b/src/process.ts @@ -128,12 +128,18 @@ export class PowerShellProcess { this.consoleCloseSubscription = vscode.window.onDidCloseTerminal((terminal) => this.onTerminalClose(terminal)); // Log that the PowerShell terminal process has been started - const pid = await this.consoleTerminal.processId; + const pid = await this.getPid(); this.logTerminalPid(pid ?? 0, pwshName); return sessionDetails; } + // Returns the process Id of the consoleTerminal + public async getPid(): Promise { + if (!this.consoleTerminal) { return undefined; } + return await this.consoleTerminal.processId; + } + public showTerminal(preserveFocus?: boolean): void { this.consoleTerminal?.show(preserveFocus); } diff --git a/src/session.ts b/src/session.ts index 7178095bbb..4d14cd0ce3 100644 --- a/src/session.ts +++ b/src/session.ts @@ -220,7 +220,13 @@ export class SessionManager implements Middleware { } public getSessionDetails(): IEditorServicesSessionDetails | undefined { - return this.sessionDetails; + const sessionDetails = this.sessionDetails; + if (sessionDetails != undefined) { + return sessionDetails; + } else { + void this.logger.writeAndShowError("Editor Services session details are not available yet."); + return undefined; + } } public getSessionStatus(): SessionStatus { diff --git a/test/core/paths.test.ts b/test/core/paths.test.ts index b0372b2f87..628034a581 100644 --- a/test/core/paths.test.ts +++ b/test/core/paths.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import * as assert from "assert"; +import assert from "assert"; import * as vscode from "vscode"; import { IPowerShellExtensionClient } from "../../src/features/ExternalApi"; import utils = require("../utils"); diff --git a/test/features/DebugSession.test.ts b/test/features/DebugSession.test.ts new file mode 100644 index 0000000000..cd36993ca6 --- /dev/null +++ b/test/features/DebugSession.test.ts @@ -0,0 +1,490 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import structuredClone from "@ungap/structured-clone"; //Polyfill for structuredClone which will be present in Node 17. +import * as assert from "assert"; +import Sinon from "sinon"; +import { DebugAdapterNamedPipeServer, DebugConfiguration, DebugSession, Extension, ExtensionContext, Range, SourceBreakpoint, TextDocument, TextEditor, Uri, commands, debug, extensions, window, workspace } from "vscode"; +import { Disposable } from "vscode-languageserver-protocol"; +import { DebugConfig, DebugSessionFeature, defaultDebugConfigurations } from "../../src/features/DebugSession"; +import { IPowerShellExtensionClient } from "../../src/features/ExternalApi"; +import * as platform from "../../src/platform"; +import { IPlatformDetails } from "../../src/platform"; +import { IEditorServicesSessionDetails, IPowerShellVersionDetails, SessionManager, SessionStatus } from "../../src/session"; +import * as utils from "../../src/utils"; +import { BuildBinaryModuleMock, WaitEvent, ensureEditorServicesIsConnected, stubInterface, testLogger } from "../utils"; + +const TEST_NUMBER = 7357; //7357 = TEST. Get it? :) + +let defaultDebugConfig: DebugConfiguration; +beforeEach(() => { + // This prevents state from creeping into the template between test runs + defaultDebugConfig = structuredClone(defaultDebugConfigurations[DebugConfig.LaunchCurrentFile]); +}); + +describe("DebugSessionFeature", () => { + // These constructor stubs are required for all tests so we don't interfere with the E2E vscode instance + let registerProviderStub: Sinon.SinonStub; + let registerFactoryStub: Sinon.SinonStub; + + /** + * Convenience function for creating a DebugSessionFeature with stubbed dependencies. We want the actual methods and Sinon.stubInstance is awkward because it stubs all methods and the constructor, and we just want to stub the constructor basically. + */ + function createDebugSessionFeatureStub({ + context = stubInterface({ + subscriptions: Array() //Needed for constructor + }), + sessionManager = Sinon.createStubInstance(SessionManager), + logger = testLogger + }): DebugSessionFeature { + return new DebugSessionFeature(context, sessionManager, logger); + } + + /** Representation of an untitled powershell document window in the Editor */ + const untitledEditor = stubInterface({ + document: stubInterface({ + uri: Uri.parse("file:///fakeUntitled.ps1"), + languageId: "powershell", + isUntitled: true + }) + }); + + beforeEach(() => { + registerProviderStub = Sinon.stub(debug, "registerDebugConfigurationProvider").returns(Disposable.create(() => {"Stubbed";})); + registerFactoryStub = Sinon.stub(debug, "registerDebugAdapterDescriptorFactory").returns(Disposable.create(() => {"Stubbed";})); + }); + + afterEach(() => { + Sinon.restore(); + }); + describe("Constructor", () => { + it("Registers debug configuration provider and factory", () => { + const context = stubInterface({ + subscriptions: Array() + }); + + createDebugSessionFeatureStub({context: context}); + + assert.ok(registerFactoryStub.calledOnce, "Debug adapter factory method called"); + assert.ok(registerProviderStub.calledOnce, "Debug config provider method called"); + assert.equal(context.subscriptions.length, 2, "DebugSessionFeature disposables populated"); + // TODO: Validate the registration content, such as the language name + }); + }); + + describe("resolveDebugConfiguration", () => { + it("Defaults to LaunchCurrentFile if no request type was specified", async () => { + const noRequestConfig: DebugConfiguration = defaultDebugConfig; + noRequestConfig.request = ""; + // Need to have an editor window "open" for this not to error out + Sinon.stub(window, "activeTextEditor").value(untitledEditor); + + const actual = await createDebugSessionFeatureStub({}).resolveDebugConfiguration(undefined, noRequestConfig); + + assert.equal(actual!.current_document, true); + assert.equal(actual!.request, defaultDebugConfigurations[DebugConfig.LaunchCurrentFile].request); + }); + + it("Errors if current file config was specified but no file is open in the editor", async () => { + Sinon.stub(window, "activeTextEditor").value(undefined); + const logger = Sinon.stub(testLogger); + + const actual = await createDebugSessionFeatureStub({}).resolveDebugConfiguration(undefined, defaultDebugConfig); + + assert.equal(actual!, undefined); + assert.match(logger.writeAndShowError.firstCall.args[0], /you must first open a PowerShell script file/); + }); + + it("Detects an untitled document", async () => { + Sinon.stub(window, "activeTextEditor").value(untitledEditor); + + const actual = await createDebugSessionFeatureStub({}).resolveDebugConfiguration(undefined, defaultDebugConfig); + + assert.equal(actual!.untitled_document, true); + assert.equal(actual!.script, "file:///fakeUntitled.ps1"); + }); + }); + + describe("resolveDebugConfigurationWithSubstitutedVariables", () => { + it("Sets internalConsoleOptions to neverOpen", async () => { + Sinon.stub(window, "activeTextEditor").value(untitledEditor); + + const actual = await createDebugSessionFeatureStub({}).resolveDebugConfigurationWithSubstitutedVariables(undefined, defaultDebugConfig); + + assert.equal(actual!.internalConsoleOptions, "neverOpen"); + }); + it("Rejects invalid request type", async () => { + const invalidRequestConfig: DebugConfiguration = defaultDebugConfig; + invalidRequestConfig.request = "notAttachOrLaunch"; + const logger = Sinon.stub(testLogger); + + const actual = await createDebugSessionFeatureStub({}).resolveDebugConfigurationWithSubstitutedVariables(undefined, invalidRequestConfig); + + assert.equal(actual, null); + assert.match(logger.writeAndShowError.firstCall.args[0], /request type was invalid/); + }); + + it("Uses createTemporaryIntegratedConsole config setting if not explicitly specified", async () => { + Sinon.stub(window, "activeTextEditor").value(untitledEditor); + assert.equal(defaultDebugConfig.createTemporaryIntegratedConsole, undefined, "Default config should have no temp integrated console setting"); + + const actual = await createDebugSessionFeatureStub({}).resolveDebugConfigurationWithSubstitutedVariables(undefined, defaultDebugConfig); + + assert.notEqual(actual!.createTemporaryIntegratedConsole, undefined, "createTemporaryIntegratedConsole should have received a value from the settings and no longer be undefined"); + }); + + it("LaunchCurrentFile: Rejects non-Powershell language active editor", async () => { + const nonPSEditor = stubInterface({ + document: stubInterface({ + uri: Uri.parse("file:///fakeUntitled.ps1"), + languageId: "NotPowerShell", + isUntitled: true + }) + }); + const currentDocConfig: DebugConfiguration = defaultDebugConfig; + currentDocConfig.current_document = true; + Sinon.stub(window, "activeTextEditor").value(nonPSEditor); + const logger = Sinon.stub(testLogger); + + const actual = await createDebugSessionFeatureStub({}).resolveDebugConfigurationWithSubstitutedVariables(undefined, currentDocConfig); + + assert.equal(actual, undefined, "Debug session should end"); + assert.match(logger.writeAndShowError.firstCall.args[0], /debugging this language mode/); + }); + + it("LaunchScript: Rejects scripts without a powershell script extension", async () => { + const currentDocConfig: DebugConfiguration = defaultDebugConfig; + currentDocConfig.current_document = true; + currentDocConfig.script = "file:///notPowerShell.txt"; + // This check is currently dependent on the languageID check which is why this is needed still + Sinon.stub(window, "activeTextEditor").value(untitledEditor); + Sinon.stub(utils, "checkIfFileExists").resolves(true); + const logger = Sinon.stub(testLogger); + + const actual = await createDebugSessionFeatureStub({}).resolveDebugConfigurationWithSubstitutedVariables(undefined, currentDocConfig); + + assert.equal(actual, undefined); + assert.match(logger.writeAndShowError.firstCall.args[0], /debugging this file type/); + }); + + it("Prevents debugging untitled files in a temp console", async () => { + const currentDocConfig: DebugConfiguration = defaultDebugConfig; + currentDocConfig.untitled_document = true; + currentDocConfig.createTemporaryIntegratedConsole = true; + Sinon.stub(window, "activeTextEditor").value(untitledEditor); + const logger = Sinon.stub(testLogger); + + const actual = await createDebugSessionFeatureStub({}).resolveDebugConfigurationWithSubstitutedVariables(undefined, currentDocConfig); + + assert.equal(actual, undefined); + assert.match(logger.writeAndShowError.firstCall.args[0], /debugging untitled/); + }); + + it("Attach: Exits if session version details cannot be retrieved", async () => { + const attachConfig: DebugConfiguration = defaultDebugConfig; + attachConfig.request = "attach"; + const logger = Sinon.stub(testLogger); + const sessionManager = Sinon.createStubInstance(SessionManager, {}); + sessionManager.getPowerShellVersionDetails.returns(undefined); + + const actual = await createDebugSessionFeatureStub({ + sessionManager: sessionManager + }).resolveDebugConfigurationWithSubstitutedVariables(undefined, attachConfig); + + assert.equal(actual, undefined); + assert.match(logger.writeAndShowError.firstCall.args[0], /session version details were not found/); + assert.ok(sessionManager.getPowerShellVersionDetails.calledOnce); + }); + + it("Attach: Prevents attach on non-windows if not PS7.0 or higher", async() => { + const attachConfig: DebugConfiguration = defaultDebugConfig; + attachConfig.request = "attach"; + const logger = Sinon.stub(testLogger); + const sessionManager = Sinon.createStubInstance(SessionManager, {}); + Sinon.stub(platform, "getPlatformDetails").returns( + stubInterface({ + operatingSystem: platform.OperatingSystem.MacOS + }) + ); + sessionManager.getPowerShellVersionDetails.returns( + stubInterface({ + version: "6.2.3" + }) + ); + + const actual = await createDebugSessionFeatureStub({ + sessionManager: sessionManager + }).resolveDebugConfigurationWithSubstitutedVariables(undefined, attachConfig); + + assert.equal(actual, undefined); + assert.match(logger.writeAndShowError.firstCall.args[0], /requires PowerShell 7/); + assert.ok(sessionManager.getPowerShellVersionDetails.calledOnce); + }); + + it("Attach: Prompts for PS Process if not specified", async () => { + const attachConfig: DebugConfiguration = defaultDebugConfig; + attachConfig.request = "attach"; + // This effectively skips this check + attachConfig.runspaceId = TEST_NUMBER; + attachConfig.runspaceName = "TEST"; + const sessionManager = Sinon.createStubInstance(SessionManager, {}); + sessionManager.getPowerShellVersionDetails.returns( + stubInterface({ + version: "7.2.3" + }) + ); + const executeCommandStub = Sinon.stub(commands, "executeCommand").resolves(7357); + + const actual = await createDebugSessionFeatureStub({ + sessionManager: sessionManager + }).resolveDebugConfigurationWithSubstitutedVariables(undefined, attachConfig); + + assert.equal(actual!.processId, TEST_NUMBER); + assert.ok(executeCommandStub.calledOnceWith("PowerShell.PickPSHostProcess")); + }); + + it("Attach: Exits if process was not selected from the picker", async () => { + const attachConfig: DebugConfiguration = defaultDebugConfig; + attachConfig.request = "attach"; + // This effectively skips this check + attachConfig.runspaceId = TEST_NUMBER; + attachConfig.runspaceName = "TEST"; + const sessionManager = Sinon.createStubInstance(SessionManager, {}); + sessionManager.getPowerShellVersionDetails.returns( + stubInterface({ + version: "7.2.3" + }) + ); + const executeCommandStub = Sinon.stub(commands, "executeCommand").resolves(undefined); + + const actual = await createDebugSessionFeatureStub({ + sessionManager: sessionManager + }).resolveDebugConfigurationWithSubstitutedVariables(undefined, attachConfig); + + assert.equal(actual, undefined); + assert.ok(executeCommandStub.calledOnceWith("PowerShell.PickPSHostProcess")); + }); + + it("Attach: Prompts for Runspace if not specified", async () => { + const attachConfig: DebugConfiguration = defaultDebugConfig; + attachConfig.request = "attach"; + // This effectively skips this check + attachConfig.processId = TEST_NUMBER; + const sessionManager = Sinon.createStubInstance(SessionManager, {}); + sessionManager.getPowerShellVersionDetails.returns( + stubInterface({ + version: "7.2.3" + }) + ); + const executeCommandStub = Sinon.stub(commands, "executeCommand").resolves(TEST_NUMBER); + + const actual = await createDebugSessionFeatureStub({ + sessionManager: sessionManager + }).resolveDebugConfigurationWithSubstitutedVariables(undefined, attachConfig); + + assert.equal(actual!.runspaceId, TEST_NUMBER); + assert.ok(executeCommandStub.calledOnceWith("PowerShell.PickRunspace", TEST_NUMBER)); + }); + + it("Attach: Exits if runspace was not selected from the picker", async () => { + const attachConfig: DebugConfiguration = defaultDebugConfig; + attachConfig.request = "attach"; + // This effectively skips this check + attachConfig.processId = TEST_NUMBER; + const sessionManager = Sinon.createStubInstance(SessionManager, {}); + sessionManager.getPowerShellVersionDetails.returns( + stubInterface({ + version: "7.2.3" + }) + ); + const executeCommandStub = Sinon.stub(commands, "executeCommand").resolves(undefined); + + const actual = await createDebugSessionFeatureStub({ + sessionManager: sessionManager + }).resolveDebugConfigurationWithSubstitutedVariables(undefined, attachConfig); + assert.equal(actual, undefined); + assert.ok(executeCommandStub.calledOnceWith("PowerShell.PickRunspace", TEST_NUMBER)); + }); + + it("Starts dotnet attach debug session with default config", async () => { + const attachConfig: DebugConfiguration = defaultDebugConfig; + attachConfig.script = "test.ps1"; // This bypasses the ${file} logic + attachConfig.createTemporaryIntegratedConsole = true; + attachConfig.attachDotnetDebugger = true; + Sinon.stub(extensions, "getExtension").returns( + stubInterface>() + ); + + const actual = await createDebugSessionFeatureStub({}).resolveDebugConfigurationWithSubstitutedVariables(undefined, attachConfig); + + const dotnetAttachConfig = actual!.dotnetAttachConfig; + assert.equal(dotnetAttachConfig.name, "Dotnet Debugger: Temporary Extension Terminal"); + assert.equal(dotnetAttachConfig.request, "attach"); + assert.equal(dotnetAttachConfig.type, "coreclr"); + assert.equal(dotnetAttachConfig.processId, undefined); + assert.equal(dotnetAttachConfig.logging.moduleLoad, false); + }); + + it("Prevents dotnet attach session if terminal is not temporary", async () => { + const attachConfig: DebugConfiguration = defaultDebugConfig; + attachConfig.script = "test.ps1"; // This bypasses the ${file} logic + attachConfig.attachDotnetDebugger = true; + const logger = Sinon.stub(testLogger); + + const actual = await createDebugSessionFeatureStub({}).resolveDebugConfigurationWithSubstitutedVariables(undefined, attachConfig); + + assert.equal(actual!, null); + assert.match(logger.writeAndShowError.firstCall.args[0], /dotnet debugging without using a temporary console/); + }); + + it("Errors if dotnetDebuggerConfigName was provided but the config was not found", async () => { + const attachConfig: DebugConfiguration = defaultDebugConfig; + attachConfig.script = "test.ps1"; // This bypasses the ${file} logic + attachConfig.createTemporaryIntegratedConsole = true; + attachConfig.attachDotnetDebugger = true; + attachConfig.dotnetDebuggerConfigName = "not a real config"; + const logger = Sinon.stub(testLogger); + Sinon.stub(extensions, "getExtension").returns( + stubInterface>() + ); + + const actual = await createDebugSessionFeatureStub({}).resolveDebugConfigurationWithSubstitutedVariables(undefined, attachConfig); + + assert.equal(actual!, null); + assert.match(logger.writeAndShowError.firstCall.args[0], /matching launch config was not found/); + }); + }); + + describe("createDebugAdapterDescriptor", () => { + it("Creates a named pipe server for the debug adapter", async () => { + const debugSessionFeature = createDebugSessionFeatureStub({ + sessionManager: Sinon.createStubInstance(SessionManager, { + getSessionStatus: SessionStatus.Running, + getSessionDetails: stubInterface({ + debugServicePipeName: "testPipeName" + }) + }), + }); + const debugSession = stubInterface({ + configuration: stubInterface({ + createTemporaryIntegratedConsole: false + }) + }); + + const debugAdapterDescriptor = await debugSessionFeature.createDebugAdapterDescriptor(debugSession, undefined); + + // Confirm debugAdapterDescriptor is of type debugadapternamedpipeserver + assert.ok(debugAdapterDescriptor instanceof DebugAdapterNamedPipeServer); + assert.equal(debugAdapterDescriptor.path, "testPipeName"); + }); + }); +}); + +describe("DebugSessionFeature E2E", function slowTests() { + this.slow(20000); // Will warn if test takes longer than 10s and show red if longer than 20s + + if (process.platform == "darwin") { + this.timeout(60000); // The MacOS test runner is sloooow in Azure Devops + } + before(async () => { + // Registers and warms up the debug adapter and the PowerShell Extension Terminal + await ensureEditorServicesIsConnected(); + }); + + it("Starts and stops a debugging session", async () => { + // Inspect the debug session via the started events to ensure it is correct + let startDebugSession: DebugSession; + let stopDebugSession: DebugSession; + const interactiveSessionConfig = defaultDebugConfigurations[DebugConfig.InteractiveSession]; + // Asserts dont seem to fire in this event or the event doesnt resolve in the test code flow, so we need to "extract" the values for later use by the asserts + + const startDebugEvent = debug.onDidStartDebugSession((newDebugSession) => { + startDebugEvent.dispose(); + startDebugSession = newDebugSession; + const stopDebugEvent = debug.onDidTerminateDebugSession((terminatedDebugSession) => { + stopDebugEvent.dispose(); + stopDebugSession = terminatedDebugSession; + }); + }); + + const debugSessionStarted = await debug.startDebugging(undefined, interactiveSessionConfig); + assert.ok(debugSessionStarted, "Debug session should start"); + assert.equal(startDebugSession!.name, interactiveSessionConfig.name, "Debug session name should match when started"); + // debugSession var should be populated from the event before startDebugging completes + await debug.stopDebugging(startDebugSession!); + + assert.equal(stopDebugSession!.name, interactiveSessionConfig.name, "Debug session name should match when stopped"); + assert.equal(stopDebugSession!.configuration.internalConsoleOptions, "neverOpen", "Debug session should always have neverOpen internalConsoleOptions"); + assert.ok(stopDebugSession!, "Debug session should stop"); + }); + + describe("Binary Modules", () => { + before(async () => { + BuildBinaryModuleMock(); + await ensureEditorServicesIsConnected(); + }); + afterEach(async () => { + // Cleanup E2E testing state + await debug.stopDebugging(undefined); + }); + + it("Debugs a binary module script", async () => { + const launchScriptConfig = structuredClone(defaultDebugConfigurations[DebugConfig.LaunchScript]); + launchScriptConfig.script = "../examples/BinaryModule/BinaryModuleTest.ps1"; + launchScriptConfig.attachDotnetDebugger = true; + launchScriptConfig.createTemporaryIntegratedConsole = true; + const startDebugging = Sinon.spy(debug, "startDebugging"); + + const debugStarted = await debug.startDebugging(undefined, launchScriptConfig); + assert.ok(debugStarted); + const debugStopped = await debug.stopDebugging(undefined); + assert.ok(debugStopped); + + assert.ok(startDebugging.calledTwice); + assert.ok(startDebugging.calledWith(undefined, launchScriptConfig)); + // The C# child process + assert.ok(startDebugging.calledWithMatch( + undefined, + Sinon.match.has("type", "coreclr"), // The new child debugger + Sinon.match.has("type", "PowerShell") // The parent session + ), "The C# debugger child process is created with the PowerShell debugger as the parent"); + }); + + it("Stops at a binary module breakpoint", async () => { + const launchScriptConfig = structuredClone(defaultDebugConfigurations[DebugConfig.LaunchCurrentFile]); + launchScriptConfig.attachDotnetDebugger = true; + launchScriptConfig.createTemporaryIntegratedConsole = true; + const testScriptPath = Uri.joinPath(workspace.workspaceFolders![0].uri, "mocks/BinaryModule/BinaryModuleTest.ps1"); + const cmdletSourcePath = Uri.joinPath(workspace.workspaceFolders![0].uri, "mocks/BinaryModule/TestSampleCmdletCommand.cs"); + const testScriptDocument = await workspace.openTextDocument(testScriptPath); + await window.showTextDocument(testScriptDocument); + + // We cant see when a breakpoint is hit because the code we would spy on is in the C# extension or is vscode private, but we can see if the debug session changes which should only happen when the debug session context switches to C#, so that's good enough. + + //We wire this up before starting the debug session so the event is registered + const dotnetDebugSessionActive = WaitEvent(debug.onDidChangeActiveDebugSession, (session) => { + console.log(`Debug Session Changed: ${session?.name}`); + return !!session?.name.match(/Dotnet Debugger/); + }); + + // Break at beginProcessing of the cmdlet + debug.addBreakpoints([ + new SourceBreakpoint({ + uri: cmdletSourcePath, + range: new Range(26, 0, 26, 0) //BeginProcessing + }, true, undefined, undefined, "TEST-BinaryModuleBreakpoint") + ]); + + const debugStarted = await debug.startDebugging(undefined, launchScriptConfig); + console.log(debug.breakpoints); + const dotnetDebugSession = await dotnetDebugSessionActive; + console.log(debug.activeDebugSession); + console.log(debug.breakpoints); + const debugStopped = await debug.stopDebugging(undefined); + + assert.ok(debugStarted); + assert.ok(dotnetDebugSession); + assert.ok(debugStopped); + }); + }); +}); diff --git a/test/features/RunCode.test.ts b/test/features/RunCode.test.ts index e47e223285..0568c303e9 100644 --- a/test/features/RunCode.test.ts +++ b/test/features/RunCode.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import * as assert from "assert"; +import assert from "assert"; import * as path from "path"; import rewire = require("rewire"); import vscode = require("vscode"); @@ -40,17 +40,18 @@ describe("RunCode feature", function () { }); it("Runs Pester tests from a file", async function () { + this.slow(5000); const pesterTests = path.resolve(__dirname, "../../../examples/Tests/SampleModule.Tests.ps1"); assert(checkIfFileExists(pesterTests)); + const pesterTestDebugStarted = utils.WaitEvent(vscode.debug.onDidStartDebugSession, + session => session.name === "PowerShell: Launch Pester Tests" + ); - // Open the PowerShell file with Pester tests and then wait a while for - // the extension to finish connecting to the server. await vscode.commands.executeCommand("vscode.open", vscode.Uri.file(pesterTests)); - - // Now run the Pester tests, check the debugger started, wait a bit for - // it to run, and then kill it for safety's sake. assert(await vscode.commands.executeCommand("PowerShell.RunPesterTestsFromFile")); - assert(vscode.debug.activeDebugSession !== undefined); + const debugSession = await pesterTestDebugStarted; await vscode.debug.stopDebugging(); + + assert(debugSession.type === "PowerShell"); }); }); diff --git a/test/features/UpdatePowerShell.test.ts b/test/features/UpdatePowerShell.test.ts index f78494cac0..2961699530 100644 --- a/test/features/UpdatePowerShell.test.ts +++ b/test/features/UpdatePowerShell.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import * as assert from "assert"; +import assert from "assert"; import { UpdatePowerShell } from "../../src/features/UpdatePowerShell"; import { Settings } from "../../src/settings"; import { IPowerShellVersionDetails } from "../../src/session"; diff --git a/test/index.ts b/test/index.ts index 4a65285b1a..c179b9d65b 100644 --- a/test/index.ts +++ b/test/index.ts @@ -5,8 +5,8 @@ // https://github.com/microsoft/vscode-extension-samples/tree/main/helloworld-test-sample/src/test import * as path from "path"; -import * as Mocha from "mocha"; -import * as glob from "glob"; +import Mocha from "mocha"; +import glob from "glob"; export function run(): Promise { // Create the mocha test diff --git a/test/mocks/BinaryModule/BinaryModule.csproj b/test/mocks/BinaryModule/BinaryModule.csproj new file mode 100644 index 0000000000..52ec116dda --- /dev/null +++ b/test/mocks/BinaryModule/BinaryModule.csproj @@ -0,0 +1,12 @@ + + + netstandard2.0 + BinaryModule + + + + + All + + + diff --git a/test/mocks/BinaryModule/BinaryModuleTest.ps1 b/test/mocks/BinaryModule/BinaryModuleTest.ps1 new file mode 100644 index 0000000000..8954566517 --- /dev/null +++ b/test/mocks/BinaryModule/BinaryModuleTest.ps1 @@ -0,0 +1,2 @@ +Import-Module $PSScriptRoot\bin\Debug\netstandard2.0\BinaryModule.dll +Test-SampleCmdlet diff --git a/test/mocks/BinaryModule/TestSampleCmdletCommand.cs b/test/mocks/BinaryModule/TestSampleCmdletCommand.cs new file mode 100644 index 0000000000..039f6c1e6c --- /dev/null +++ b/test/mocks/BinaryModule/TestSampleCmdletCommand.cs @@ -0,0 +1,49 @@ +using System.Management.Automation; + +namespace BinaryModule +{ + [Cmdlet(VerbsDiagnostic.Test, "SampleCmdlet")] + [OutputType(typeof(FavoriteStuff))] + public class TestSampleCmdletCommand : PSCmdlet + { + [Parameter( + Position = 0, + ValueFromPipeline = true, + ValueFromPipelineByPropertyName = true)] + public int FavoriteNumber { get; set; } = 42; + + [Parameter( + Position = 1, + ValueFromPipelineByPropertyName = true)] + [ValidateSet("Cat", "Dog", "Horse")] + public string FavoritePet { get; set; } = "Dog"; + + // This method gets called once for each cmdlet in the pipeline when the pipeline starts executing + protected override void BeginProcessing() + { + WriteVerbose("Begin!"); + } + + // This method will be called for each input received from the pipeline to this cmdlet; if no input is received, this method is not called + protected override void ProcessRecord() + { + WriteObject(new FavoriteStuff + { + FavoriteNumber = FavoriteNumber, + FavoritePet = FavoritePet + }); + } + + // This method will be called once at the end of pipeline execution; if no input is received, this method is not called + protected override void EndProcessing() + { + WriteVerbose("End!"); + } + } + + public class FavoriteStuff + { + public int FavoriteNumber { get; set; } + public string FavoritePet { get; set; } + } +} diff --git a/test/runTests.ts b/test/runTests.ts index a54f424d74..961ac3819d 100644 --- a/test/runTests.ts +++ b/test/runTests.ts @@ -5,10 +5,18 @@ // https://github.com/microsoft/vscode-extension-samples/tree/main/helloworld-test-sample/src/test import * as path from "path"; - -import { runTests } from "@vscode/test-electron"; +import { ConsoleReporter, downloadAndUnzipVSCode, resolveCliArgsFromVSCodeExecutablePath, runTests } from "@vscode/test-electron"; +import { existsSync } from "fs"; +import { spawnSync } from "child_process"; async function main(): Promise { + // Test for the presence of modules folder and error if not found + const PSESPath = path.resolve(__dirname, "../../modules/PowerShellEditorServices.VSCode/bin/Microsoft.PowerShell.EditorServices.VSCode.dll"); + if (!existsSync(PSESPath)) { + console.error("ERROR: A PowerShell Editor Services build was not found in the modules directory. Please run a build first, using either the 'Run Build Task' in VSCode or ./build.ps1 in PowerShell."); + process.exit(1); + } + try { // The folder containing the Extension Manifest package.json // Passed to `--extensionDevelopmentPath` @@ -18,14 +26,31 @@ async function main(): Promise { // Passed to --extensionTestsPath const extensionTestsPath = path.resolve(__dirname, "./index"); + // The version to test. By default we test on insiders. + const vsCodeVersion = "insiders"; + + // Install Temp VSCode. We need to do this first so we can then install extensions as the runTests function doesn't give us a way to hook in to do this. + const testVSCodePath = await downloadAndUnzipVSCode(vsCodeVersion, undefined, new ConsoleReporter(true)); + InstallExtension(testVSCodePath, "ms-dotnettools.csharp"); + + // Open VSCode with the examples folder, so any UI testing can run against the examples. + const launchArgs = [ + "./test" + ]; + + // Allow to wait for extension test debugging + const port = process.argv[2]; + if (port) {launchArgs.push(`--inspect-brk-extensions=${port}`);} + + // Download VS Code, unzip it and run the integration test await runTests({ extensionDevelopmentPath, extensionTestsPath, - launchArgs: ["--disable-extensions", "./test"], + launchArgs: launchArgs, // This is necessary because the tests fail if more than once // instance of Code is running. - version: "insiders" + version: vsCodeVersion }); } catch (err) { console.error(`Failed to run tests: ${err}`); @@ -33,4 +58,27 @@ async function main(): Promise { } } + +/** Installs an extension into an existing vscode instance. Returns the output result */ +function InstallExtension(vscodeExePath: string, extensionIdOrVSIXPath: string): string { + // Install the csharp extension which is required for the dotnet debugger testing + const [cli, ...args] = resolveCliArgsFromVSCodeExecutablePath(vscodeExePath); + + args.push("--install-extension", extensionIdOrVSIXPath); + + // Install the extension. There is no API for this, we must use the executable. This is the recommended sample in the vscode-test repo. + console.log(`Installing extension: ${cli} ${args.join(" ")}`); + const installResult = spawnSync(cli, args, { + encoding: "utf8", + stdio: "inherit" + }); + + if (installResult.status !== 0) { + console.error(installResult.stderr); + throw new Error(`Failed to install extension: ${installResult.stderr}`); + } + return installResult.stdout; +} + + void main(); diff --git a/test/utils.ts b/test/utils.ts index 781244dbca..fe6c685479 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -5,6 +5,7 @@ import * as path from "path"; import * as vscode from "vscode"; import { ILogger } from "../src/logging"; import { IPowerShellExtensionClient } from "../src/features/ExternalApi"; +import { execSync } from "child_process"; // This lets us test the rest of our path assumptions against the baseline of // this test file existing at `/out/test/utils.js`. @@ -66,3 +67,34 @@ export async function ensureEditorServicesIsConnected(): Promise(object?: Partial): T { + return object ? object as T : {} as T; +} + +/** Builds the sample binary module code. We need to do this because the source maps have absolute paths so they are not portable between machines, and while we could do deterministic with source maps, that's way more complicated and everywhere we build has dotnet already anyways */ +export function BuildBinaryModuleMock(): void { + console.log("==BUILDING: Binary Module Mock=="); + const projectPath = path.resolve(`${__dirname}/../../test/mocks/BinaryModule/BinaryModule.csproj`); //Relative to "out/test" when testing. + const buildResult = execSync(`dotnet publish ${projectPath}`); + console.log(buildResult.toString()); +} + +/** Waits until the registered vscode event is fired and returns the trigger result of the event. + * @param event The event to wait for + * @param filter An optional filter to apply to the event TResult. The filter will continue to monitor the event firings until the filter returns true. + * @returns A promise that resolves when the specified event is fired with the TResult subject of the event. If a filter is specified, the promise will not resolve until the filter returns true. +*/ +export function WaitEvent(event: vscode.Event, filter?: (event: TResult) => boolean | undefined): Promise { + return new Promise((resolve) => { + const listener = event((result: TResult) => { + if (!filter || filter(result)) { + listener.dispose(); + resolve(result); + } + }); + }); +} diff --git a/tsconfig.json b/tsconfig.json index f0ad892c55..4da3d70d92 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,7 @@ "noImplicitReturns": true, "noUnusedLocals": true, "noUnusedParameters": true, + "esModuleInterop": true }, "include": [ "src", "test" ], }