From 15f65cf100bd81e4d8d92ae2fcf264fbb9601764 Mon Sep 17 00:00:00 2001 From: Justin Grote Date: Fri, 1 Apr 2022 14:17:36 -0700 Subject: [PATCH 01/15] Add attachDotnetDebugger debug option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract processId Fetch Cleanup legacy bad rebase Revert breakout config Add dotnetDebuggerConfigName info TODO the workspace folder support Fix typo Cleanup flow and breakout temporary console to dedicated functions Add handlers to cleanup dotnet attach Fix linting issues Refactor one-time event disposal using variable scope demonry Clarify the handlers Remove Disposable (lint fix) Add initial test Lint fix Fix description to use correct and more specific terminology. Co-authored-by: Andy Jordan <2226434+andschwa@users.noreply.github.com> Apply suggestions from @andschwa review Fixed E2E Debug Testing Remove Only Add initial Debug test scaffolding Remove "only" Move validation logic to resolve functions and implement first resolve test Rearrange methods with public first and implementation details last, in order of implemented interfaces. Add more tests Fixed positioning of resolvesubstitutedvariables, now the public methods happen in the order they are called Populate more tests Some more scaffoldng and a broken test Add optional attach port to runTests and remove only *again* Fix wrong argv for port Add check for PSES Binaries Adjust StubInterface to use Partial to preserve intellisense More test fixes Fix test names to be consistent with other tests More specific error checks and implement structuredClone Add structuredclone to dev dependencies Revert accidental commit of new build tasks Update package.json Co-authored-by: Andy Jordan <2226434+andschwa@users.noreply.github.com> Enable ESModuleInterop and fix related issues Updated structuredclone import to ES6 syntax Add ESModuleInterop to tsconfig.json More test adds and add newlines between Arrange Act Assert More test scaffolding ESModuleInterop fixes Bump package-lock More esModuleInterop fixes Tests TESTS TESTS TESTS TESTS Flaky Test Check Move testing default folder to examples Add binary module example and C# extension fetching Add precompiled binarymodule so that it doesnt have to be built for testing Fix pid for ProcessID Add first binary module E2E test Suppress first run messages in examples folder Remove Only Add createDebugAdapterDescriptor test Remove Only Move working dir back to test (was breaking settings tests) and add dummy csproj for C# activation Move dummy to mocks folder Reload window for CI Fix assertion order in binary module test Skip Mac on binary module script test for now Fix bug where stdout is null if CLI install fails Extract CSharp extension install to separate function for DAMP clarity Add test if C# extension was found Remove imports More MacOS troubleshooting (7 minute cycles between tests is hell) More troubleshooting lines Add hack to change cwd to make resolveCliArgs happy Found out the electron extensions run in a whole separate process on MacOS, handle that Re-enable resolveCliArgs detection problem fix Remove error reference Try without window reload Remove Async from InstallCSharpExtension Remove more async Wait for C# extension to load rather than just reloading the window More binary module test scaffolding End to End Tests Done! ๐ŸŽ‰๐ŸŽ‰๐ŸŽ‰ Spoke too soon, find out why CI breakpoint test fails (probably because it might be headless) Add undefined checks Build test binary module locally to avoid absolute PDB path issues MacOS why you gotta be such a PIA Remove dummy csproj, maybe MacOS search was a flake Try reload window on mac Move MacOS C# install to before tests to avoid test duplications Fix reload loop on mac Remove reload from initial Add some debugging to try to find macos issue Move event registration to before startDebugging Try increasing timeout for MacOS Add WaitEvent helper function Try MacOS Hot Install Again Retry: RunCode test seems to be flaky Change exec to spawn to make CodeQL happy Revert "Change exec to spawn to make CodeQL happy" This reverts commit d3aea33f5b8d50c444c6325b3da36b46e6cc5d66. --- .gitignore | 2 + examples/.vscode/settings.json | 6 +- package-lock.json | 30 +- package.json | 12 + src/features/DebugSession.ts | 305 +++++++---- src/process.ts | 8 +- src/session.ts | 8 +- test/core/paths.test.ts | 2 +- test/features/DebugSession.test.ts | 491 ++++++++++++++++++ test/features/RunCode.test.ts | 2 +- test/features/UpdatePowerShell.test.ts | 2 +- test/index.ts | 4 +- test/mocks/BinaryModule/BinaryModule.csproj | 12 + test/mocks/BinaryModule/BinaryModuleTest.ps1 | 2 + .../BinaryModule/TestSampleCmdletCommand.cs | 49 ++ test/runTests.ts | 20 +- test/utils.ts | 106 ++++ tsconfig.json | 1 + 18 files changed, 949 insertions(+), 113 deletions(-) create mode 100644 test/features/DebugSession.test.ts create mode 100644 test/mocks/BinaryModule/BinaryModule.csproj create mode 100644 test/mocks/BinaryModule/BinaryModuleTest.ps1 create mode 100644 test/mocks/BinaryModule/TestSampleCmdletCommand.cs 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 089e0133af..6be0fa4240 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.57.1", + "@ungap/structured-clone": "^1.0.2", "@vscode/test-electron": "2.3.0", "@vscode/vsce": "2.18.0", "esbuild": "0.17.15", @@ -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 5d625b51d7..0eb049f182 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.57.1", + "@ungap/structured-clone": "^1.0.2", "@vscode/test-electron": "2.3.0", "@vscode/vsce": "2.18.0", "esbuild": "0.17.15", @@ -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..6e3da05ae9 100644 --- a/src/features/DebugSession.ts +++ b/src/features/DebugSession.ts @@ -1,21 +1,29 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import vscode = require("vscode"); +import vscode from "vscode"; import { - CancellationToken, DebugConfiguration, DebugConfigurationProvider, - ExtensionContext, WorkspaceFolder + CancellationToken, + DebugAdapterDescriptor, + DebugAdapterDescriptorFactory, + DebugAdapterExecutable, + DebugAdapterNamedPipeServer, + DebugConfiguration, + DebugConfigurationProvider, + DebugSession, + ExtensionContext, + WorkspaceFolder } 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,74 +31,66 @@ 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 configs = defaultDebugConfigurations; constructor(context: ExtensionContext, private sessionManager: SessionManager, private logger: ILogger) { super(); - // Register a debug configuration provider + // This "activates" the debug adapter for use with vscode. You can only do this once. 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); - } - public dispose(): void { for (const handler of this.handlers) { handler.dispose(); @@ -153,32 +153,13 @@ export class DebugSessionFeature extends LanguageClientConsumer return [this.configs[DebugConfig.LaunchCurrentFile]]; } - // DebugConfigurationProvider methods 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. @@ -189,8 +170,8 @@ export class DebugSessionFeature extends LanguageClientConsumer if (config.script === "${file}" || config.script === "${relativeFile}") { if (vscode.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; + await this.logger.writeAndShowError("To debug the 'Current File', you must first open a PowerShell script file in the editor."); + return PREVENT_DEBUG_START; } config.current_document = true; // Special case using the URI for untitled documents. @@ -207,31 +188,55 @@ 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. @@ -239,39 +244,135 @@ export class DebugSessionFeature extends LanguageClientConsumer const currentDocument = vscode.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 (!vscode.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 = vscode.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 = vscode.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 vscode.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 vscode.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 = vscode.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. @@ -279,7 +380,7 @@ export class DebugSessionFeature extends LanguageClientConsumer config.processId = await vscode.commands.executeCommand("PowerShell.PickPSHostProcess"); // No process selected. Cancel attach. if (!config.processId) { - return null; + return PREVENT_DEBUG_START; } } @@ -287,7 +388,7 @@ export class DebugSessionFeature extends LanguageClientConsumer config.runspaceId = await vscode.commands.executeCommand("PowerShell.PickRunspace", config.processId); // No runspace selected. Cancel attach. if (!config.runspaceId) { - return null; + return PREVENT_DEBUG_START; } } 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..994a7b9fa7 --- /dev/null +++ b/test/features/DebugSession.test.ts @@ -0,0 +1,491 @@ +// 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, InstallCSharpExtension, 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 + }) { + 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 () => { + await InstallCSharpExtension(); + BuildBinaryModuleMock(); + await ensureEditorServicesIsConnected(); + }); + afterEach(async () => { + // await debug.stopDebugging(undefined); + // await commands.executeCommand("workbench.action.closeAllEditors"); + }); + + 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..9ea17a7526 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"); 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..ea71606a49 --- /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..411a0882a0 100644 --- a/test/runTests.ts +++ b/test/runTests.ts @@ -7,8 +7,16 @@ import * as path from "path"; import { runTests } from "@vscode/test-electron"; +import { existsSync } from "fs"; async function main(): Promise { + // Test for the presence of modules folder and error if 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,11 +26,21 @@ async function main(): Promise { // Passed to --extensionTestsPath const extensionTestsPath = path.resolve(__dirname, "./index"); + // Open VSCode with the examples folder, so any UI testing can run against the examples. + // Also install the c# extension which is needed for hybrid binary module debug testing + 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" diff --git a/test/utils.ts b/test/utils.ts index 781244dbca..2285f31bf1 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -5,6 +5,9 @@ import * as path from "path"; import * as vscode from "vscode"; import { ILogger } from "../src/logging"; import { IPowerShellExtensionClient } from "../src/features/ExternalApi"; +import { resolveCliArgsFromVSCodeExecutablePath } from "@vscode/test-electron"; +import { execSync, spawnSync } from "child_process"; +import { existsSync } from "fs"; // 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 +69,106 @@ export async function ensureEditorServicesIsConnected(): Promise(object?: Partial): T { + return object ? object as T : {} as T; +} + +/** Installs the C# extension in "this" running vscode version for binary module debug testing */ +export async function InstallCSharpExtension(): Promise { + // Install the csharp extension which is required for the dotnet debugger testing + if (!vscode.extensions.getExtension("ms-dotnettools.csharp")) { + //HACK: There is no way to set the initial cwd using RunTests, and resolveCliArgsFromVSCodeExecutablePath requires the cwd to be the extension root, so we set it here temporarily: https://github.com/microsoft/vscode-test/blob/addc23e100b744de598220adbbf0761da870eda9/lib/download.ts#L31 + const extensionRootPath = path.resolve(`${__dirname}/../..`); + if (!existsSync(path.join(extensionRootPath, "package.json"))) { + throw new Error(`Expected to find package.json at ${extensionRootPath}. Did you move utils.ts out of the test folder?`); + } + const cwd = process.cwd(); + process.chdir(extensionRootPath); + + //HACK: On Mac, resolveCliArgs expects the electron path but we are currently running in a special helper child process, so we need to find electron: https://github.com/microsoft/vscode-test/blob/addc23e100b744de598220adbbf0761da870eda9/lib/util.ts#L158 + + console.log("==INSTALLPREREQUISITE: C# Extension=="); + const codeExePath = process.platform === "darwin" && process.execPath.endsWith("Helper (Plugin)") + ? path.join(path.dirname(process.execPath), "../../../../MacOS", "Electron") + : process.execPath; + + + const [cli, ...args] = resolveCliArgsFromVSCodeExecutablePath(codeExePath, { + reuseMachineInstall: false + }); + + // Restore working directory + process.chdir(cwd); + + !existsSync(cli) && console.error(`Resolved Code.exe path not found: ${cli}`); + + // BUG: resolveCliArgs misdetects the extensions/userdata folder sometimes, we need to fix that. + // regex that matches .vscode-test\\vscode*\\.vscode-test in an OS independent way + const vscodeIncorrectPathRegex = /\.vscode-test[\\/]vscode.+?[\\/]\.vscode-test/; + args[0] = args[0].replace(vscodeIncorrectPathRegex, ".vscode-test"); + args[1] = args[1].replace(vscodeIncorrectPathRegex, ".vscode-test"); + + //TODO: This is the best way I could come up with to wait for the C# extension to activate, I'm sure there's a more terse way to do this. + let cSharpActivatedResolved: (value: string) => void; + const waitForCSharpExtensionActivation = new Promise((resolve) => { + cSharpActivatedResolved = resolve; + }); + const testCSharpActive = async (): Promise => { + const csharpExtension = vscode.extensions.getExtension("ms-dotnettools.csharp"); + if (csharpExtension) { + cSharpMonitorEvent.dispose(); + await csharpExtension.activate(); + console.log("C# Extension is now active"); + cSharpActivatedResolved("ACTIVATED"); + } + }; + const cSharpMonitorEvent = vscode.extensions.onDidChange(testCSharpActive); + + // HACK: There is no API for this so we are forced to use the code CLI method. This is actually what is recommended from the resolveCliArgsFromVSCodeExecutablePath docs. + const installExtensionArgs = [...args, "--install-extension", "ms-dotnettools.csharp"]; + console.log(`Starting Extension Install: ${cli} ${installExtensionArgs.join(" ")}`); + + const { status, stdout, stderr} = spawnSync(cli, installExtensionArgs, { + encoding: "utf-8", + windowsHide: true + }); + if (status !== 0 || !stdout.match(/csharp.+was successfully installed/)) { + throw new Error(`Failed to install C# extension: ${stdout} ${stderr}`); + } + console.log(stdout); + + console.log("Waiting for C# extension activation"); + // Wait for the csharp extnesion to be activated + if (await waitForCSharpExtensionActivation !== "ACTIVATED") { + throw new Error("Failed to activate C# extension"); + } + console.log("==INSTALLED: C# Extension=="); + } +} + +/** 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. +*/ +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" ], } From 43da0082406ccfce807bd4a64c54b8701cb55f2a Mon Sep 17 00:00:00 2001 From: Justin Grote Date: Mon, 10 Apr 2023 13:29:40 -0700 Subject: [PATCH 02/15] Add explicit type to createDebugSessionFeatureStub --- test/features/DebugSession.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/features/DebugSession.test.ts b/test/features/DebugSession.test.ts index 994a7b9fa7..5fd583652f 100644 --- a/test/features/DebugSession.test.ts +++ b/test/features/DebugSession.test.ts @@ -36,7 +36,7 @@ describe("DebugSessionFeature", () => { }), sessionManager = Sinon.createStubInstance(SessionManager), logger = testLogger - }) { + }): DebugSessionFeature { return new DebugSessionFeature(context, sessionManager, logger); } From 22d308f41b4589ffd03b00bf31261e40a30a9411 Mon Sep 17 00:00:00 2001 From: Justin Grote Date: Mon, 10 Apr 2023 14:06:08 -0700 Subject: [PATCH 03/15] Eliminate race condition from RunCode test that is making it flaky on Windows 5.1 --- test/features/RunCode.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/features/RunCode.test.ts b/test/features/RunCode.test.ts index 9ea17a7526..683356b3c2 100644 --- a/test/features/RunCode.test.ts +++ b/test/features/RunCode.test.ts @@ -40,17 +40,17 @@ 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)); - // 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 utils.WaitEvent(vscode.debug.onDidStartDebugSession, + session => session.name === "PowerShell: Launch Pester Tests" + ); await vscode.debug.stopDebugging(); + + assert(debugSession.type === "PowerShell"); }); }); From 76162581771fa38858547a96c11caef2cce23c14 Mon Sep 17 00:00:00 2001 From: Justin Grote Date: Mon, 10 Apr 2023 14:08:19 -0700 Subject: [PATCH 04/15] Clarify how WaitEvent returns --- test/utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/utils.ts b/test/utils.ts index 2285f31bf1..abab3c74e8 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -161,6 +161,7 @@ export function BuildBinaryModuleMock(): void { /** 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) => { From e5f2340e5d18a91f1532a6e5b91f43f8ce9f2890 Mon Sep 17 00:00:00 2001 From: Justin Grote Date: Mon, 10 Apr 2023 14:33:31 -0700 Subject: [PATCH 05/15] Forgot to register the event prior to the debug start --- test/features/RunCode.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/features/RunCode.test.ts b/test/features/RunCode.test.ts index 683356b3c2..0568c303e9 100644 --- a/test/features/RunCode.test.ts +++ b/test/features/RunCode.test.ts @@ -43,12 +43,13 @@ describe("RunCode feature", 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" + ); await vscode.commands.executeCommand("vscode.open", vscode.Uri.file(pesterTests)); assert(await vscode.commands.executeCommand("PowerShell.RunPesterTestsFromFile")); - const debugSession = await utils.WaitEvent(vscode.debug.onDidStartDebugSession, - session => session.name === "PowerShell: Launch Pester Tests" - ); + const debugSession = await pesterTestDebugStarted; await vscode.debug.stopDebugging(); assert(debugSession.type === "PowerShell"); From 1bcd565245da14e6ca1d024b16d7ee0fa09a0411 Mon Sep 17 00:00:00 2001 From: Justin Grote Date: Mon, 10 Apr 2023 14:42:26 -0700 Subject: [PATCH 06/15] Make StructuredClone Dependency Explicit Co-authored-by: Andy Jordan <2226434+andschwa@users.noreply.github.com> --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 0eb049f182..af8396c71c 100644 --- a/package.json +++ b/package.json @@ -90,12 +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/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.57.1", - "@ungap/structured-clone": "^1.0.2", + "@ungap/structured-clone": "1.0.2", "@vscode/test-electron": "2.3.0", "@vscode/vsce": "2.18.0", "esbuild": "0.17.15", From ce23551c70829e36c5f7be1a50f198caaa5aa3a8 Mon Sep 17 00:00:00 2001 From: Justin Grote Date: Mon, 10 Apr 2023 14:54:30 -0700 Subject: [PATCH 07/15] Update all vscode imports in DebugSession to destructured syntax --- src/features/DebugSession.ts | 95 ++++++++++++++++++++---------------- 1 file changed, 52 insertions(+), 43 deletions(-) diff --git a/src/features/DebugSession.ts b/src/features/DebugSession.ts index 6e3da05ae9..42a01c1e8b 100644 --- a/src/features/DebugSession.ts +++ b/src/features/DebugSession.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import vscode from "vscode"; import { + debug, CancellationToken, DebugAdapterDescriptor, DebugAdapterDescriptorFactory, @@ -12,7 +12,16 @@ import { DebugConfigurationProvider, DebugSession, ExtensionContext, - WorkspaceFolder + WorkspaceFolder, + Disposable, + window, + extensions, + workspace, + commands, + CancellationTokenSource, + InputBoxOptions, + QuickPickItem, + QuickPickOptions } from "vscode"; import { NotificationType, RequestType } from "vscode-languageclient"; import { LanguageClient } from "vscode-languageclient/node"; @@ -81,14 +90,14 @@ export class DebugSessionFeature extends LanguageClientConsumer private sessionCount = 1; private tempDebugProcess: PowerShellProcess | undefined; private tempSessionDetails: IEditorServicesSessionDetails | undefined; - private handlers: vscode.Disposable[] = []; + private handlers: Disposable[] = []; private configs = defaultDebugConfigurations; constructor(context: ExtensionContext, private sessionManager: SessionManager, private logger: ILogger) { super(); - // This "activates" the debug adapter for use with vscode. You can only do this once. - context.subscriptions.push(vscode.debug.registerDebugConfigurationProvider("PowerShell", this)); - context.subscriptions.push(vscode.debug.registerDebugAdapterDescriptorFactory("PowerShell", this)); + // 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 +111,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 +119,7 @@ export class DebugSessionFeature extends LanguageClientConsumer languageClient.onNotification( StopDebuggerNotificationType, - () => void vscode.debug.stopDebugging(undefined)) + () => void debug.stopDebugging(undefined)) ]; } @@ -142,7 +151,7 @@ export class DebugSessionFeature extends LanguageClientConsumer ]; const launchSelection = - await vscode.window.showQuickPick( + await window.showQuickPick( debugConfigPickItems, { placeHolder: "Select a PowerShell debug configuration" }); @@ -169,13 +178,13 @@ export class DebugSessionFeature extends LanguageClientConsumer } if (config.script === "${file}" || config.script === "${relativeFile}") { - if (vscode.window.activeTextEditor === undefined) { + if (window.activeTextEditor === undefined) { await this.logger.writeAndShowError("To debug the 'Current File', you must first open a PowerShell script file in the editor."); 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(); @@ -241,7 +250,7 @@ export class DebugSessionFeature extends LanguageClientConsumer // (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 PREVENT_DEBUG_START_AND_OPEN_DEBUGCONFIG; @@ -274,7 +283,7 @@ export class DebugSessionFeature extends LanguageClientConsumer } private resolveAttachDotnetDebugConfiguration(config: DebugConfiguration): ResolveDebugConfigurationResult { - if (!vscode.extensions.getExtension("ms-dotnettools.csharp")) { + 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; } @@ -308,14 +317,14 @@ export class DebugSessionFeature extends LanguageClientConsumer 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 = vscode.debug.onDidStartDebugSession((dotnetAttachSession) => { + 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 = vscode.debug.onDidTerminateDebugSession(async (terminatedDebugSession) => { + const stopDebugEvent = debug.onDidTerminateDebugSession(async (terminatedDebugSession) => { // Makes the event one-time stopDebugEvent.dispose(); @@ -323,7 +332,7 @@ export class DebugSessionFeature extends LanguageClientConsumer if (terminatedDebugSession === session) { this.logger.write("Terminating dotnet debugger session associated with PowerShell debug session"); - await vscode.debug.stopDebugging(dotnetAttachSession); + await debug.stopDebugging(dotnetAttachSession); } }); } @@ -331,7 +340,7 @@ export class DebugSessionFeature extends LanguageClientConsumer // 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 vscode.debug.startDebugging(undefined, dotnetAttachConfig, session); + 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}`); } @@ -340,7 +349,7 @@ export class DebugSessionFeature extends LanguageClientConsumer private getDotnetNamedConfigOrDefault(configName?: string): ResolveDebugConfigurationResult { if (configName) { - const debugConfigs = vscode.workspace.getConfiguration("launch").get("configurations") ?? []; + const debugConfigs = workspace.getConfiguration("launch").get("configurations") ?? []; return debugConfigs.find(({ type, request, name, dotnetDebuggerConfigName }) => type === "coreclr" && request === "attach" && @@ -377,7 +386,7 @@ export class DebugSessionFeature extends LanguageClientConsumer // 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 PREVENT_DEBUG_START; @@ -385,7 +394,7 @@ export class DebugSessionFeature extends LanguageClientConsumer } 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 PREVENT_DEBUG_START; @@ -396,15 +405,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(); }); } @@ -416,7 +425,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", }; @@ -426,7 +435,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) { @@ -436,7 +445,7 @@ export class SpecifyScriptArgsFeature implements vscode.Disposable { } } -interface IProcessItem extends vscode.QuickPickItem { +interface IProcessItem extends QuickPickItem { pid: string; // payload for the QuickPick UI } @@ -456,15 +465,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); }); @@ -489,13 +498,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..." }, @@ -547,12 +556,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; } @@ -563,7 +572,7 @@ export class PickPSHostProcessFeature extends LanguageClientConsumer { } } -interface IRunspaceItem extends vscode.QuickPickItem { +interface IRunspaceItem extends QuickPickItem { id: string; // payload for the QuickPick UI } @@ -582,14 +591,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); @@ -614,13 +623,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..." }, @@ -663,12 +672,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; } From 2c6e818bd81643a935e34879aeec2baec9065640 Mon Sep 17 00:00:00 2001 From: Justin Grote Date: Mon, 10 Apr 2023 14:56:10 -0700 Subject: [PATCH 08/15] Refix writeandshowerror from await to void --- src/features/DebugSession.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/DebugSession.ts b/src/features/DebugSession.ts index 42a01c1e8b..212fe0cd3f 100644 --- a/src/features/DebugSession.ts +++ b/src/features/DebugSession.ts @@ -179,7 +179,7 @@ export class DebugSessionFeature extends LanguageClientConsumer if (config.script === "${file}" || config.script === "${relativeFile}") { if (window.activeTextEditor === undefined) { - await this.logger.writeAndShowError("To debug the 'Current File', you must first open a PowerShell script file in the editor."); + void this.logger.writeAndShowError("To debug the 'Current File', you must first open a PowerShell script file in the editor."); return PREVENT_DEBUG_START; } config.current_document = true; From efc6756711e9cc97e007112a309c615367829360 Mon Sep 17 00:00:00 2001 From: Justin Grote Date: Mon, 10 Apr 2023 15:09:09 -0700 Subject: [PATCH 09/15] Forgive resolveDebugConfiguration not having an await --- src/features/DebugSession.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/features/DebugSession.ts b/src/features/DebugSession.ts index 212fe0cd3f..cf5639b705 100644 --- a/src/features/DebugSession.ts +++ b/src/features/DebugSession.ts @@ -162,6 +162,8 @@ export class DebugSessionFeature extends LanguageClientConsumer return [this.configs[DebugConfig.LaunchCurrentFile]]; } + // 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, From aa8a1ade40ba546671284d54d94b9a65de279303 Mon Sep 17 00:00:00 2001 From: Justin Grote Date: Mon, 10 Apr 2023 15:10:44 -0700 Subject: [PATCH 10/15] Remove private config reference as it is unused and update tests --- src/features/DebugSession.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/features/DebugSession.ts b/src/features/DebugSession.ts index cf5639b705..c822d46853 100644 --- a/src/features/DebugSession.ts +++ b/src/features/DebugSession.ts @@ -91,7 +91,6 @@ export class DebugSessionFeature extends LanguageClientConsumer private tempDebugProcess: PowerShellProcess | undefined; private tempSessionDetails: IEditorServicesSessionDetails | undefined; private handlers: Disposable[] = []; - private configs = defaultDebugConfigurations; constructor(context: ExtensionContext, private sessionManager: SessionManager, private logger: ILogger) { super(); @@ -156,10 +155,10 @@ export class DebugSessionFeature extends LanguageClientConsumer { 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]]; } // We don't use await here but we are returning a promise and the return syntax is easier in an async function @@ -174,7 +173,7 @@ export class DebugSessionFeature extends LanguageClientConsumer 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; } From 5c72a067c38b71ffbdc82e69f133da4536b65ac5 Mon Sep 17 00:00:00 2001 From: Justin Grote Date: Mon, 10 Apr 2023 15:15:50 -0700 Subject: [PATCH 11/15] Bump PSStandard.Library to 5.1.1 --- test/mocks/BinaryModule/BinaryModule.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/mocks/BinaryModule/BinaryModule.csproj b/test/mocks/BinaryModule/BinaryModule.csproj index ea71606a49..52ec116dda 100644 --- a/test/mocks/BinaryModule/BinaryModule.csproj +++ b/test/mocks/BinaryModule/BinaryModule.csproj @@ -5,7 +5,7 @@ - + All From fcaeaabb7b1372319362c82485269f41d4d02d83 Mon Sep 17 00:00:00 2001 From: Justin Grote Date: Mon, 10 Apr 2023 15:21:50 -0700 Subject: [PATCH 12/15] Fix typo --- test/runTests.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/runTests.ts b/test/runTests.ts index 411a0882a0..de455ac802 100644 --- a/test/runTests.ts +++ b/test/runTests.ts @@ -10,7 +10,7 @@ import { runTests } from "@vscode/test-electron"; import { existsSync } from "fs"; async function main(): Promise { - // Test for the presence of modules folder and error if found + // 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."); From 441db78bc6c314895c560048e7366337f708b78b Mon Sep 17 00:00:00 2001 From: Justin Grote Date: Tue, 11 Apr 2023 11:47:10 -0700 Subject: [PATCH 13/15] Remove note about C# extension because it happens later --- test/runTests.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/runTests.ts b/test/runTests.ts index de455ac802..ca850a3d2a 100644 --- a/test/runTests.ts +++ b/test/runTests.ts @@ -27,7 +27,6 @@ async function main(): Promise { const extensionTestsPath = path.resolve(__dirname, "./index"); // Open VSCode with the examples folder, so any UI testing can run against the examples. - // Also install the c# extension which is needed for hybrid binary module debug testing const launchArgs = [ "./test" ]; From 719275b697243d82a59193c30ac4bb5948aaf3bc Mon Sep 17 00:00:00 2001 From: Justin Grote Date: Tue, 11 Apr 2023 11:52:15 -0700 Subject: [PATCH 14/15] Add back cleanup --- test/features/DebugSession.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/features/DebugSession.test.ts b/test/features/DebugSession.test.ts index 5fd583652f..29896da35f 100644 --- a/test/features/DebugSession.test.ts +++ b/test/features/DebugSession.test.ts @@ -425,8 +425,8 @@ describe("DebugSessionFeature E2E", function slowTests() { await ensureEditorServicesIsConnected(); }); afterEach(async () => { - // await debug.stopDebugging(undefined); - // await commands.executeCommand("workbench.action.closeAllEditors"); + // Cleanup E2E testing state + await debug.stopDebugging(undefined); }); it("Debugs a binary module script", async () => { From 24f9b643e8de3c4bb07fe6905fb446bb9b6efcfc Mon Sep 17 00:00:00 2001 From: Justin Grote Date: Tue, 11 Apr 2023 12:43:11 -0700 Subject: [PATCH 15/15] Move csharp extension installation outside of process --- test/features/DebugSession.test.ts | 3 +- test/runTests.ts | 37 ++++++++++++-- test/utils.ts | 77 +----------------------------- 3 files changed, 36 insertions(+), 81 deletions(-) diff --git a/test/features/DebugSession.test.ts b/test/features/DebugSession.test.ts index 29896da35f..cd36993ca6 100644 --- a/test/features/DebugSession.test.ts +++ b/test/features/DebugSession.test.ts @@ -12,7 +12,7 @@ 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, InstallCSharpExtension, WaitEvent, ensureEditorServicesIsConnected, stubInterface, testLogger } from "../utils"; +import { BuildBinaryModuleMock, WaitEvent, ensureEditorServicesIsConnected, stubInterface, testLogger } from "../utils"; const TEST_NUMBER = 7357; //7357 = TEST. Get it? :) @@ -420,7 +420,6 @@ describe("DebugSessionFeature E2E", function slowTests() { describe("Binary Modules", () => { before(async () => { - await InstallCSharpExtension(); BuildBinaryModuleMock(); await ensureEditorServicesIsConnected(); }); diff --git a/test/runTests.ts b/test/runTests.ts index ca850a3d2a..961ac3819d 100644 --- a/test/runTests.ts +++ b/test/runTests.ts @@ -5,9 +5,9 @@ // 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 @@ -26,6 +26,13 @@ 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" @@ -35,6 +42,7 @@ async function main(): Promise { 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, @@ -42,7 +50,7 @@ async function main(): Promise { 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}`); @@ -50,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 abab3c74e8..fe6c685479 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -5,9 +5,7 @@ import * as path from "path"; import * as vscode from "vscode"; import { ILogger } from "../src/logging"; import { IPowerShellExtensionClient } from "../src/features/ExternalApi"; -import { resolveCliArgsFromVSCodeExecutablePath } from "@vscode/test-electron"; -import { execSync, spawnSync } from "child_process"; -import { existsSync } from "fs"; +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`. @@ -77,79 +75,6 @@ export function stubInterface(object?: Partial): T { return object ? object as T : {} as T; } -/** Installs the C# extension in "this" running vscode version for binary module debug testing */ -export async function InstallCSharpExtension(): Promise { - // Install the csharp extension which is required for the dotnet debugger testing - if (!vscode.extensions.getExtension("ms-dotnettools.csharp")) { - //HACK: There is no way to set the initial cwd using RunTests, and resolveCliArgsFromVSCodeExecutablePath requires the cwd to be the extension root, so we set it here temporarily: https://github.com/microsoft/vscode-test/blob/addc23e100b744de598220adbbf0761da870eda9/lib/download.ts#L31 - const extensionRootPath = path.resolve(`${__dirname}/../..`); - if (!existsSync(path.join(extensionRootPath, "package.json"))) { - throw new Error(`Expected to find package.json at ${extensionRootPath}. Did you move utils.ts out of the test folder?`); - } - const cwd = process.cwd(); - process.chdir(extensionRootPath); - - //HACK: On Mac, resolveCliArgs expects the electron path but we are currently running in a special helper child process, so we need to find electron: https://github.com/microsoft/vscode-test/blob/addc23e100b744de598220adbbf0761da870eda9/lib/util.ts#L158 - - console.log("==INSTALLPREREQUISITE: C# Extension=="); - const codeExePath = process.platform === "darwin" && process.execPath.endsWith("Helper (Plugin)") - ? path.join(path.dirname(process.execPath), "../../../../MacOS", "Electron") - : process.execPath; - - - const [cli, ...args] = resolveCliArgsFromVSCodeExecutablePath(codeExePath, { - reuseMachineInstall: false - }); - - // Restore working directory - process.chdir(cwd); - - !existsSync(cli) && console.error(`Resolved Code.exe path not found: ${cli}`); - - // BUG: resolveCliArgs misdetects the extensions/userdata folder sometimes, we need to fix that. - // regex that matches .vscode-test\\vscode*\\.vscode-test in an OS independent way - const vscodeIncorrectPathRegex = /\.vscode-test[\\/]vscode.+?[\\/]\.vscode-test/; - args[0] = args[0].replace(vscodeIncorrectPathRegex, ".vscode-test"); - args[1] = args[1].replace(vscodeIncorrectPathRegex, ".vscode-test"); - - //TODO: This is the best way I could come up with to wait for the C# extension to activate, I'm sure there's a more terse way to do this. - let cSharpActivatedResolved: (value: string) => void; - const waitForCSharpExtensionActivation = new Promise((resolve) => { - cSharpActivatedResolved = resolve; - }); - const testCSharpActive = async (): Promise => { - const csharpExtension = vscode.extensions.getExtension("ms-dotnettools.csharp"); - if (csharpExtension) { - cSharpMonitorEvent.dispose(); - await csharpExtension.activate(); - console.log("C# Extension is now active"); - cSharpActivatedResolved("ACTIVATED"); - } - }; - const cSharpMonitorEvent = vscode.extensions.onDidChange(testCSharpActive); - - // HACK: There is no API for this so we are forced to use the code CLI method. This is actually what is recommended from the resolveCliArgsFromVSCodeExecutablePath docs. - const installExtensionArgs = [...args, "--install-extension", "ms-dotnettools.csharp"]; - console.log(`Starting Extension Install: ${cli} ${installExtensionArgs.join(" ")}`); - - const { status, stdout, stderr} = spawnSync(cli, installExtensionArgs, { - encoding: "utf-8", - windowsHide: true - }); - if (status !== 0 || !stdout.match(/csharp.+was successfully installed/)) { - throw new Error(`Failed to install C# extension: ${stdout} ${stderr}`); - } - console.log(stdout); - - console.log("Waiting for C# extension activation"); - // Wait for the csharp extnesion to be activated - if (await waitForCSharpExtensionActivation !== "ACTIVATED") { - throw new Error("Failed to activate C# extension"); - } - console.log("==INSTALLED: C# Extension=="); - } -} - /** 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==");