diff --git a/.vscode/launch.json b/.vscode/launch.json index 155db9a086..3d821aa8b5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,7 +9,7 @@ "args": [ "--extensionDevelopmentPath=${workspaceRoot}" ], "stopOnEntry": false, "sourceMaps": true, - "outFiles": ["${workspaceRoot}/out/src/**/*.js"], + "outFiles": ["${workspaceFolder}/out/src/**/*.js"], "preLaunchTask": "BuildAll" }, { @@ -17,10 +17,10 @@ "type": "extensionHost", "request": "launch", "runtimeExecutable": "${execPath}", - "args": [ "--extensionDevelopmentPath=${workspaceRoot}" ], + "args": [ "--extensionDevelopmentPath=${workspaceFolder}" ], "stopOnEntry": false, "sourceMaps": true, - "outFiles": ["${workspaceRoot}/out/src/**/*.js"], + "outFiles": ["${workspaceFolder}/out/src/**/*.js"], "preLaunchTask": "Build" }, { @@ -28,25 +28,11 @@ "type": "extensionHost", "request": "launch", "runtimeExecutable": "${execPath}", - "args": ["--extensionDevelopmentPath=${workspaceRoot}", "--extensionTestsPath=${workspaceRoot}/out/test" ], - "stopOnEntry": false, - "sourceMaps": true, - "outFiles": ["${workspaceRoot}/out/test/**/*.js"], - "preLaunchTask": "Build", - "skipFiles": [ - "${workspaceFolder}/node_modules/**/*", - "${workspaceFolder}/lib/**/*", - "/private/var/folders/**/*", - "/**/*" - ] - }, - { - "name": "Attach", - "type": "node", - "request": "attach", - "address": "localhost", - "port": 5858, - "sourceMaps": false + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}", + "--extensionTestsPath=${workspaceFolder}/out/test/testRunner.js" ], + "outFiles": ["${workspaceFolder}/out/**/*.js"], + "preLaunchTask": "Build" } ] } diff --git a/.vsts-ci/templates/ci-general.yml b/.vsts-ci/templates/ci-general.yml index c3b24a7397..ebd96d11af 100644 --- a/.vsts-ci/templates/ci-general.yml +++ b/.vsts-ci/templates/ci-general.yml @@ -11,6 +11,7 @@ steps: # Using `prependpath` to update the PATH just for this build. Write-Host "##vso[task.prependpath]$powerShellPath" + continueOnError: true displayName: Install PowerShell Daily - pwsh: '$PSVersionTable' displayName: Display PowerShell version information diff --git a/package-lock.json b/package-lock.json index 4dcae089e6..60a479ec69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -160,6 +160,12 @@ "integrity": "sha512-yYezQwGWty8ziyYLdZjwxyMb0CZR49h8JALHGrxjQHWlqGgc8kLdHEgWrgL0uZ29DMvEVBDnHU2Wg36zKSIUtA==", "dev": true }, + "@types/uuid": { + "version": "8.0.0", + "resolved": "https://botbuilder.myget.org/F/botframework-cli/npm/@types/uuid/-/@types/uuid-8.0.0.tgz", + "integrity": "sha1-FlquSBmtIXShdHbb5m/uvVSVVsA=", + "dev": true + }, "@types/vscode": { "version": "1.43.0", "resolved": "https://botbuilder.myget.org/F/botframework-cli/npm/@types/vscode/-/@types/vscode-1.43.0.tgz", @@ -2081,6 +2087,11 @@ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "dev": true }, + "uuid": { + "version": "8.2.0", + "resolved": "https://botbuilder.myget.org/F/botframework-cli/npm/uuid/-/uuid-8.2.0.tgz", + "integrity": "sha1-yxDdaxGOLa2n0M2XMLp0F8k9kg4=" + }, "v8-compile-cache": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz", diff --git a/package.json b/package.json index 3c4eaee935..9178125be0 100644 --- a/package.json +++ b/package.json @@ -40,11 +40,15 @@ "onCommand:PowerShell.RestartSession", "onCommand:PowerShell.EnableISEMode", "onCommand:PowerShell.DisableISEMode", + "onCommand:PowerShell.RegisterExternalExtension", + "onCommand:PowerShell.UnregisterExternalExtension", + "onCommand:PowerShell.GetPowerShellVersionDetails", "onView:PowerShellCommands" ], "dependencies": { "node-fetch": "^2.6.0", "semver": "^7.3.2", + "uuid": "^8.2.0", "vscode-extension-telemetry": "~0.1.6", "vscode-languageclient": "~6.1.3" }, @@ -57,6 +61,7 @@ "@types/rewire": "~2.5.28", "@types/semver": "~7.2.0", "@types/sinon": "~9.0.4", + "@types/uuid": "^8.0.0", "@types/vscode": "1.43.0", "mocha": "~5.2.0", "mocha-junit-reporter": "~2.0.0", @@ -292,6 +297,21 @@ "light": "resources/light/MovePanelBottom.svg", "dark": "resources/dark/MovePanelBottom.svg" } + }, + { + "command": "PowerShell.RegisterExternalExtension", + "title": "Register an external extension", + "category": "PowerShell" + }, + { + "command": "PowerShell.UnregisterExternalExtension", + "title": "Unregister an external extension", + "category": "PowerShell" + }, + { + "command": "PowerShell.GetPowerShellVersionDetails", + "title": "Get details about the PowerShell version that the PowerShell extension is using", + "category": "PowerShell" } ], "menus": { diff --git a/src/features/ExternalApi.ts b/src/features/ExternalApi.ts new file mode 100644 index 0000000000..d662d39c66 --- /dev/null +++ b/src/features/ExternalApi.ts @@ -0,0 +1,153 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ +import * as vscode from "vscode"; +import { v4 as uuidv4 } from 'uuid'; +import { LanguageClient } from "vscode-languageclient"; +import { IFeature } from "../feature"; +import { Logger } from "../logging"; +import { SessionManager } from "../session"; + +export interface IExternalPowerShellDetails { + exePath: string; + version: string; + displayName: string; + architecture: string; +} + +export class ExternalApiFeature implements IFeature { + private commands: vscode.Disposable[]; + private languageClient: LanguageClient; + private static readonly registeredExternalExtension: Map = new Map(); + + constructor(private sessionManager: SessionManager, private log: Logger) { + this.commands = [ + /* + DESCRIPTION: + Registers your extension to allow usage of the external API. The returns + a session UUID that will need to be passed in to subsequent API calls. + + USAGE: + vscode.commands.executeCommand( + "PowerShell.RegisterExternalExtension", + "ms-vscode.PesterTestExplorer" // the name of the extension using us + "v1"); // API Version. + + RETURNS: + string session uuid + */ + vscode.commands.registerCommand("PowerShell.RegisterExternalExtension", (id: string, apiVersion: string = 'v1'): string => + this.registerExternalExtension(id, apiVersion)), + + /* + DESCRIPTION: + Unregisters a session that an extension has. This returns + true if it succeeds or throws if it fails. + + USAGE: + vscode.commands.executeCommand( + "PowerShell.UnregisterExternalExtension", + "uuid"); // the uuid from above for tracking purposes + + RETURNS: + true if it worked, otherwise throws an error. + */ + vscode.commands.registerCommand("PowerShell.UnregisterExternalExtension", (uuid: string = ""): boolean => + this.unregisterExternalExtension(uuid)), + + /* + DESCRIPTION: + This will fetch the version details of the PowerShell used to start + PowerShell Editor Services in the PowerShell extension. + + USAGE: + vscode.commands.executeCommand( + "PowerShell.GetPowerShellVersionDetails", + "uuid"); // the uuid from above for tracking purposes + + RETURNS: + An IPowerShellVersionDetails which consists of: + { + version: string; + displayVersion: string; + edition: string; + architecture: string; + } + */ + vscode.commands.registerCommand("PowerShell.GetPowerShellVersionDetails", (uuid: string = ""): Promise => + this.getPowerShellVersionDetails(uuid)), + ] + } + + private registerExternalExtension(id: string, apiVersion: string = 'v1'): string { + this.log.writeDiagnostic(`Registering extension '${id}' for use with API version '${apiVersion}'.`); + + for (const [_, externalExtension] of ExternalApiFeature.registeredExternalExtension) { + if (externalExtension.id === id) { + const message = `The extension '${id}' is already registered.`; + this.log.writeWarning(message); + throw new Error(message); + } + } + + if (!vscode.extensions.all.some(ext => ext.id === id)) { + throw new Error(`No extension installed with id '${id}'. You must use a valid extension id.`); + } + + // If we're in development mode, we allow these to be used for testing purposes. + if (!this.sessionManager.InDevelopmentMode && (id === "ms-vscode.PowerShell" || id === "ms-vscode.PowerShell-Preview")) { + throw new Error("You can't use the PowerShell extension's id in this registration."); + } + + const uuid = uuidv4(); + ExternalApiFeature.registeredExternalExtension.set(uuid, { + id, + apiVersion + }); + return uuid; + } + + private unregisterExternalExtension(uuid: string = ""): boolean { + this.log.writeDiagnostic(`Unregistering extension with session UUID: ${uuid}`); + if (!ExternalApiFeature.registeredExternalExtension.delete(uuid)) { + throw new Error(`No extension registered with session UUID: ${uuid}`); + } + return true; + } + + private async getPowerShellVersionDetails(uuid: string = ""): Promise { + if (!ExternalApiFeature.registeredExternalExtension.has(uuid)) { + throw new Error( + "UUID provided was invalid, make sure you execute the 'PowerShell.GetPowerShellVersionDetails' command and pass in the UUID that it returns to subsequent command executions."); + } + + // TODO: When we have more than one API version, make sure to include a check here. + const extension = ExternalApiFeature.registeredExternalExtension.get(uuid); + this.log.writeDiagnostic(`Extension '${extension.id}' used command 'PowerShell.GetPowerShellVersionDetails'.`); + + await this.sessionManager.waitUntilStarted(); + const versionDetails = this.sessionManager.getPowerShellVersionDetails(); + + return { + exePath: this.sessionManager.PowerShellExeDetails.exePath, + version: versionDetails.version, + displayName: this.sessionManager.PowerShellExeDetails.displayName, // comes from the Session Menu + architecture: versionDetails.architecture + }; + } + + public dispose() { + for (const command of this.commands) { + command.dispose(); + } + } + + public setLanguageClient(languageclient: LanguageClient) { + this.languageClient = languageclient; + } +} + +interface IExternalExtension { + readonly id: string; + readonly apiVersion: string; +} diff --git a/src/main.ts b/src/main.ts index a51407300e..b8c681973b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,12 +13,10 @@ import { CodeActionsFeature } from "./features/CodeActions"; import { ConsoleFeature } from "./features/Console"; import { CustomViewsFeature } from "./features/CustomViews"; import { DebugSessionFeature } from "./features/DebugSession"; -import { PickPSHostProcessFeature } from "./features/DebugSession"; -import { PickRunspaceFeature } from "./features/DebugSession"; -import { SpecifyScriptArgsFeature } from "./features/DebugSession"; import { ExamplesFeature } from "./features/Examples"; import { ExpandAliasFeature } from "./features/ExpandAlias"; import { ExtensionCommandsFeature } from "./features/ExtensionCommands"; +import { ExternalApiFeature } from "./features/ExternalApi"; import { FindModuleFeature } from "./features/FindModule"; import { GenerateBugReportFeature } from "./features/GenerateBugReport"; import { GetCommandsFeature } from "./features/GetCommands"; @@ -27,14 +25,15 @@ import { ISECompatibilityFeature } from "./features/ISECompatibility"; import { NewFileOrProjectFeature } from "./features/NewFileOrProject"; import { OpenInISEFeature } from "./features/OpenInISE"; import { PesterTestsFeature } from "./features/PesterTests"; +import { PickPSHostProcessFeature, PickRunspaceFeature } from "./features/DebugSession"; import { RemoteFilesFeature } from "./features/RemoteFiles"; import { RunCodeFeature } from "./features/RunCode"; import { ShowHelpFeature } from "./features/ShowHelp"; +import { SpecifyScriptArgsFeature } from "./features/DebugSession"; import { Logger, LogLevel } from "./logging"; import { SessionManager } from "./session"; import Settings = require("./settings"); import { PowerShellLanguageId } from "./utils"; -import utils = require("./utils"); // The most reliable way to get the name and version of the current extension. // tslint:disable-next-line: no-var-requires @@ -157,6 +156,7 @@ export function activate(context: vscode.ExtensionContext): void { new HelpCompletionFeature(logger), new CustomViewsFeature(), new PickRunspaceFeature(), + new ExternalApiFeature(sessionManager, logger) ]; sessionManager.setExtensionFeatures(extensionFeatures); diff --git a/src/process.ts b/src/process.ts index 7f9f08b185..347dacd06f 100644 --- a/src/process.ts +++ b/src/process.ts @@ -179,10 +179,6 @@ export class PowerShellProcess { return true; } - private sleep(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - private async waitForSessionFile(): Promise { // Determine how many tries by dividing by 2000 thus checking every 2 seconds. const numOfTries = this.sessionSettings.developer.waitForSessionFileTimeoutSeconds / 2; @@ -203,7 +199,7 @@ export class PowerShellProcess { } // Wait a bit and try again - await this.sleep(2000); + await utils.sleep(2000); } const err = "Timed out waiting for session file to appear."; diff --git a/src/session.ts b/src/session.ts index bdd34f703f..9e9ca64260 100644 --- a/src/session.ts +++ b/src/session.ts @@ -54,6 +54,7 @@ export class SessionManager implements Middleware { private sessionSettings: Settings.ISettings = undefined; private sessionDetails: utils.IEditorServicesSessionDetails; private bundledModulesPath: string; + private started: boolean = false; // Initialized by the start() method, since this requires settings private powershellExeFinder: PowerShellExeFinder; @@ -61,7 +62,7 @@ export class SessionManager implements Middleware { // When in development mode, VS Code's session ID is a fake // value of "someValue.machineId". Use that to detect dev // mode for now until Microsoft/vscode#10272 gets implemented. - private readonly inDevelopmentMode = + public readonly InDevelopmentMode = vscode.env.sessionId === "someValue.sessionId"; constructor( @@ -167,7 +168,7 @@ export class SessionManager implements Middleware { this.bundledModulesPath = path.resolve(__dirname, this.sessionSettings.bundledModulesPath); - if (this.inDevelopmentMode) { + if (this.InDevelopmentMode) { const devBundledModulesPath = path.resolve( __dirname, @@ -274,6 +275,12 @@ export class SessionManager implements Middleware { return this.debugSessionProcess; } + public async waitUntilStarted(): Promise { + while(!this.started) { + await utils.sleep(300); + } + } + // ----- LanguageClient middleware methods ----- public resolveCodeLens( @@ -549,8 +556,9 @@ export class SessionManager implements Middleware { .then( async (versionDetails) => { this.versionDetails = versionDetails; + this.started = true; - if (!this.inDevelopmentMode) { + if (!this.InDevelopmentMode) { this.telemetryReporter.sendTelemetryEvent("powershellVersionCheck", { powershellVersion: versionDetails.version }); } diff --git a/src/utils.ts b/src/utils.ts index bf3c107a66..5f4e9d10fb 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -96,6 +96,10 @@ export function getTimestampString() { return `[${time.getHours()}:${time.getMinutes()}:${time.getSeconds()}]`; } +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + export const isMacOS: boolean = process.platform === "darwin"; export const isWindows: boolean = process.platform === "win32"; export const isLinux: boolean = !isMacOS && !isWindows; diff --git a/test/features/ExternalApi.test.ts b/test/features/ExternalApi.test.ts new file mode 100644 index 0000000000..085377174e --- /dev/null +++ b/test/features/ExternalApi.test.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ +import * as assert from "assert"; +import * as vscode from "vscode"; +import { beforeEach, afterEach } from "mocha"; +import { IExternalPowerShellDetails } from "../../src/features/ExternalApi"; + +const testExtensionId = "ms-vscode.powershell-preview"; + +suite("ExternalApi feature - Registration API", () => { + test("It can register and unregister an extension", async () => { + const sessionId: string = await vscode.commands.executeCommand("PowerShell.RegisterExternalExtension", testExtensionId); + assert.notStrictEqual(sessionId , ""); + assert.notStrictEqual(sessionId , null); + assert.strictEqual( + await vscode.commands.executeCommand("PowerShell.UnregisterExternalExtension", sessionId), + true); + }); + + test("It can register and unregister an extension with a version", async () => { + const sessionId: string = await vscode.commands.executeCommand("PowerShell.RegisterExternalExtension", "ms-vscode.powershell-preview", "v2"); + assert.notStrictEqual(sessionId , ""); + assert.notStrictEqual(sessionId , null); + assert.strictEqual( + await vscode.commands.executeCommand("PowerShell.UnregisterExternalExtension", sessionId), + true); + }); + + /* + NEGATIVE TESTS + */ + test("API fails if not registered", async () => { + assert.rejects( + async () => await vscode.commands.executeCommand("PowerShell.GetPowerShellVersionDetails"), + "UUID provided was invalid, make sure you execute the 'PowerShell.RegisterExternalExtension' command and pass in the UUID that it returns to subsequent command executions."); + }); + + test("It can't register the same extension twice", async () => { + const sessionId: string = await vscode.commands.executeCommand("PowerShell.RegisterExternalExtension", testExtensionId); + try { + assert.rejects( + async () => await vscode.commands.executeCommand("PowerShell.RegisterExternalExtension", testExtensionId), + `The extension '${testExtensionId}' is already registered.`); + } finally { + await vscode.commands.executeCommand("PowerShell.UnregisterExternalExtension", sessionId); + } + }); + + test("It can't unregister an extension that isn't registered", async () => { + assert.rejects( + async () => await vscode.commands.executeCommand("PowerShell.RegisterExternalExtension", "not-real"), + `No extension registered with session UUID: not-real`); + }); +}); + +suite("ExternalApi feature - Other APIs", () => { + let sessionId: string; + + beforeEach(async () => { + sessionId = await vscode.commands.executeCommand("PowerShell.RegisterExternalExtension", "ms-vscode.powershell-preview"); + }); + + afterEach(async () => { + await vscode.commands.executeCommand("PowerShell.UnregisterExternalExtension", sessionId); + }); + + test("It can get PowerShell version details", async () => { + const versionDetails: IExternalPowerShellDetails = await vscode.commands.executeCommand("PowerShell.GetPowerShellVersionDetails", sessionId); + + assert.notStrictEqual(versionDetails.architecture, ""); + assert.notStrictEqual(versionDetails.architecture, null); + + assert.notStrictEqual(versionDetails.displayName, ""); + assert.notStrictEqual(versionDetails.displayName, null); + + assert.notStrictEqual(versionDetails.exePath, ""); + assert.notStrictEqual(versionDetails.exePath, null); + + assert.notStrictEqual(versionDetails.version, ""); + assert.notStrictEqual(versionDetails.version, null); + + // Start up can take some time... so set the time out to 30s + }).timeout(30000); +});