From 855554faaf9963e2dea7a02ce0df923b347a2451 Mon Sep 17 00:00:00 2001 From: Wasim Lorgat Date: Wed, 9 Oct 2024 15:07:58 +0200 Subject: [PATCH] Support debugging Python web applications (#4924) This PR adds "Debug \ App in Terminal" commands for all of the currently supported Python app frameworks. There was a fair bit of moving things around to reuse code between the run and debug commands. Addresses #4795. ### QA Notes Test the commands for each app framework, see the examples for each framework in #4662. --- extensions/positron-python/package.json | 80 +++- extensions/positron-python/package.nls.json | 8 +- .../src/client/common/constants.ts | 6 + .../src/client/positron-run-app.d.ts | 42 ++ .../src/client/positron/webAppCommands.ts | 372 +++++++++------ .../test/positron/webAppCommands.unit.test.ts | 143 +++++- extensions/positron-run-app/src/api.ts | 432 ++++++++++++++++++ .../src/debugAdapterTrackerFactory.ts | 85 ++++ extensions/positron-run-app/src/extension.ts | 307 +------------ .../src/positron-run-app.d.ts | 43 ++ .../test/{extension.test.ts => api.test.ts} | 105 +++-- extensions/positron-run-app/src/utils.ts | 5 +- .../positron-run-app/test-workspace/app.js | 6 + .../positron-run-app/test-workspace/app.sh | 2 - scripts/test-integration-pr.sh | 6 - 15 files changed, 1146 insertions(+), 496 deletions(-) create mode 100644 extensions/positron-run-app/src/api.ts create mode 100644 extensions/positron-run-app/src/debugAdapterTrackerFactory.ts rename extensions/positron-run-app/src/test/{extension.test.ts => api.test.ts} (63%) create mode 100644 extensions/positron-run-app/test-workspace/app.js delete mode 100644 extensions/positron-run-app/test-workspace/app.sh diff --git a/extensions/positron-python/package.json b/extensions/positron-python/package.json index 9a5a9e3eae8..ba7da56fa5a 100644 --- a/extensions/positron-python/package.json +++ b/extensions/positron-python/package.json @@ -348,6 +348,48 @@ "icon": "$(play)", "title": "%python.command.python.execInConsole.title%" }, + { + "category": "Python", + "command": "python.debugDashInTerminal", + "icon": "$(debug-alt)", + "title": "%python.command.python.debugDashInTerminal.title%", + "enablement": "pythonAppFramework == dash" + }, + { + "category": "Python", + "command": "python.debugFastAPIInTerminal", + "icon": "$(debug-alt)", + "title": "%python.command.python.debugFastAPIInTerminal.title%", + "enablement": "pythonAppFramework == fastapi" + }, + { + "category": "Python", + "command": "python.debugFlaskInTerminal", + "icon": "$(debug-alt)", + "title": "%python.command.python.debugFlaskInTerminal.title%", + "enablement": "pythonAppFramework == flask" + }, + { + "category": "Python", + "command": "python.debugGradioInTerminal", + "icon": "$(debug-alt)", + "title": "%python.command.python.debugGradioInTerminal.title%", + "enablement": "pythonAppFramework == gradio" + }, + { + "category": "Python", + "command": "python.debugShinyInTerminal", + "icon": "$(debug-alt)", + "title": "%python.command.python.debugShinyInTerminal.title%", + "enablement": "pythonAppFramework == shiny" + }, + { + "category": "Python", + "command": "python.debugStreamlitInTerminal", + "icon": "$(debug-alt)", + "title": "%python.command.python.debugStreamlitInTerminal.title%", + "enablement": "pythonAppFramework == streamlit" + }, { "category": "Python", "command": "python.debugInTerminal", @@ -1600,8 +1642,44 @@ "when": "resourceLangId == python && !isInDiffEditor && !virtualWorkspace && shellExecutionSupported" }, { - "command": "python.debugInTerminal", + "command": "python.debugDashInTerminal", + "group": "navigation@3", + "title": "%python.command.python.debugDashInTerminal.title%", + "when": "pythonAppFramework == dash" + }, + { + "command": "python.debugGradioInTerminal", "group": "navigation@3", + "title": "%python.command.python.debugGradioInTerminal.title%", + "when": "pythonAppFramework == gradio" + }, + { + "command": "python.debugShinyInTerminal", + "group": "navigation@3", + "title": "%python.command.python.debugShinyInTerminal.title%", + "when": "pythonAppFramework == shiny" + }, + { + "command": "python.debugFastAPIInTerminal", + "group": "navigation@3", + "title": "%python.command.python.debugFastAPIInTerminal.title%", + "when": "pythonAppFramework == fastapi" + }, + { + "command": "python.debugFlaskInTerminal", + "group": "navigation@3", + "title": "%python.command.python.debugFlaskInTerminal.title%", + "when": "pythonAppFramework == flask" + }, + { + "command": "python.debugStreamlitInTerminal", + "group": "navigation@3", + "title": "%python.command.python.debugStreamlitInTerminal.title%", + "when": "pythonAppFramework == streamlit" + }, + { + "command": "python.debugInTerminal", + "group": "navigation@4", "title": "%python.command.python.debugInTerminal.title%", "when": "resourceLangId == python && !isInDiffEditor && !virtualWorkspace && shellExecutionSupported" } diff --git a/extensions/positron-python/package.nls.json b/extensions/positron-python/package.nls.json index be71c4ed2b1..cc8c8f92351 100644 --- a/extensions/positron-python/package.nls.json +++ b/extensions/positron-python/package.nls.json @@ -10,8 +10,14 @@ "python.command.python.execFastAPIInTerminal.title": "Run FastAPI App in Terminal", "python.command.python.execFlaskInTerminal.title": "Run Flask App in Terminal", "python.command.python.execGradioInTerminal.title": "Run Gradio App in Terminal", - "python.command.python.execShinyInTerminal.title": "Run Shiny App in Terminal", + "python.command.python.execShinyInTerminal.title": "Run Shiny App in Terminal", "python.command.python.execStreamlitInTerminal.title": "Run Streamlit App in Terminal", + "python.command.python.debugDashInTerminal.title": "Debug Dash App in Terminal", + "python.command.python.debugFastAPIInTerminal.title": "Debug FastAPI App in Terminal", + "python.command.python.debugFlaskInTerminal.title": "Debug Flask App in Terminal", + "python.command.python.debugGradioInTerminal.title": "Debug Gradio App in Terminal", + "python.command.python.debugShinyInTerminal.title": "Debug Shiny App in Terminal", + "python.command.python.debugStreamlitInTerminal.title": "Debug Streamlit App in Terminal", "python.command.python.execInConsole.title": "Run Python File in Console", "python.command.python.debugInTerminal.title": "Debug Python File in Terminal", "python.command.python.execInTerminalIcon.title": "Run Python File in Terminal", diff --git a/extensions/positron-python/src/client/common/constants.ts b/extensions/positron-python/src/client/common/constants.ts index b45e73575a5..bb38793c64f 100644 --- a/extensions/positron-python/src/client/common/constants.ts +++ b/extensions/positron-python/src/client/common/constants.ts @@ -60,6 +60,12 @@ export namespace Commands { export const Exec_Streamlit_In_Terminal = 'python.execStreamlitInTerminal'; export const Exec_In_Console = 'python.execInConsole'; export const Exec_Selection_In_Console = 'python.execSelectionInConsole'; + export const Debug_Dash_In_Terminal = 'python.debugDashInTerminal'; + export const Debug_FastAPI_In_Terminal = 'python.debugFastAPIInTerminal'; + export const Debug_Flask_In_Terminal = 'python.debugFlaskInTerminal'; + export const Debug_Gradio_In_Terminal = 'python.debugGradioInTerminal'; + export const Debug_Shiny_In_Terminal = 'python.debugShinyInTerminal'; + export const Debug_Streamlit_In_Terminal = 'python.debugStreamlitInTerminal'; // --- End Positron --- export const Exec_In_REPL = 'python.execInREPL'; export const Exec_Selection_In_Django_Shell = 'python.execSelectionInDjangoShell'; diff --git a/extensions/positron-python/src/client/positron-run-app.d.ts b/extensions/positron-python/src/client/positron-run-app.d.ts index 456e2f8a2be..ecf76e69601 100644 --- a/extensions/positron-python/src/client/positron-run-app.d.ts +++ b/extensions/positron-python/src/client/positron-run-app.d.ts @@ -53,6 +53,39 @@ export interface RunAppOptions { urlPath?: string; } +/** + * Represents options for the ${@link PositronRunApp.debugApplication} function. + */ +export interface DebugAppOptions { + /** + * The human-readable label for the application e.g. `'Shiny'`. + */ + name: string; + + /** + * A function that will be called to get the ${@link vscode.DebugConfiguration, debug configuration} for debugging the application. + * + * @param runtime The language runtime metadata for the document's language. + * @param document The document to debug. + * @param port The port to run the application on, if known. + * @param urlPrefix The URL prefix to use, if known. + * @returns The debug configuration for debugging the application. Return `undefined` to abort debugging. + */ + getDebugConfiguration( + runtime: positron.LanguageRuntimeMetadata, + document: vscode.TextDocument, + port?: string, + urlPrefix?: string, + ): vscode.DebugConfiguration | undefined | Promise; + + /** + * The optional URL path at which to preview the application. + */ + urlPath?: string; +} + +export interface DebugConfiguration {} + /** * The public API of the Positron Run App extension. */ @@ -65,4 +98,13 @@ export interface PositronRunApp { * started, otherwise resolves when the command has been sent to the terminal. */ runApplication(options: RunAppOptions): Promise; + + /** + * Debug an application. + * + * @param options Options for debugging the application. + * @returns If terminal shell integration is supported, resolves when the application server has + * started, otherwise resolves when the debug session has started. + */ + debugApplication(options: DebugAppOptions): Promise; } diff --git a/extensions/positron-python/src/client/positron/webAppCommands.ts b/extensions/positron-python/src/client/positron/webAppCommands.ts index 476275d47e8..d5876b95f22 100644 --- a/extensions/positron-python/src/client/positron/webAppCommands.ts +++ b/extensions/positron-python/src/client/positron/webAppCommands.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import * as path from 'path'; +// eslint-disable-next-line import/no-unresolved +import * as positron from 'positron'; import * as vscode from 'vscode'; import { PositronRunApp, RunAppTerminalOptions } from '../positron-run-app.d'; import { IServiceContainer } from '../ioc/types'; @@ -14,155 +16,233 @@ import { Commands } from '../common/constants'; export function activateWebAppCommands(serviceContainer: IServiceContainer, disposables: vscode.Disposable[]): void { disposables.push( - vscode.commands.registerCommand(Commands.Exec_Dash_In_Terminal, async () => { - const runAppApi = await getPositronRunAppApi(); - await runAppApi.runApplication({ - name: 'Dash', - getTerminalOptions(runtime, document, port, urlPrefix) { - const terminalOptions: RunAppTerminalOptions = { - commandLine: [runtime.runtimePath, document.uri.fsPath].join(' '), - }; - if (port || urlPrefix) { - terminalOptions.env = {}; - if (port) { - terminalOptions.env.DASH_PORT = port; - } - if (urlPrefix) { - terminalOptions.env.DASH_URL_PREFIX = urlPrefix; - } - } - return terminalOptions; - }, - }); - }), - - vscode.commands.registerCommand(Commands.Exec_FastAPI_In_Terminal, async () => { - const runAppApi = await getPositronRunAppApi(); - await runAppApi.runApplication({ - name: 'FastAPI', - async getTerminalOptions(runtime, document, port, urlPrefix) { - let hasFastapiCli = false; - - const interpreterService = serviceContainer.get(IInterpreterService); - const interpreter = await interpreterService.getInterpreterDetails(runtime.runtimePath); - if (interpreter) { - const installer = serviceContainer.get(IInstaller); - hasFastapiCli = await installer.isInstalled(Product.fastapiCli, interpreter); - } else { - traceError( - `Could not check if fastapi-cli is installed due to an invalid interpreter path: ${runtime.runtimePath}`, - ); - } - - let args: string[]; - if (hasFastapiCli) { - args = [runtime.runtimePath, '-m', 'fastapi', 'dev', document.uri.fsPath]; - } else { - const appName = await getAppName(document, 'FastAPI'); - if (!appName) { - return undefined; - } - args = [ - runtime.runtimePath, - '-m', - 'uvicorn', - '--reload', - `${pathToModule(document.uri.fsPath)}:${appName}`, - ]; - } - - if (port) { - args.push('--port', port); - } - if (urlPrefix) { - args.push('--root-path', urlPrefix); - } - return { commandLine: args.join(' ') }; - }, - urlPath: '/docs', - }); - }), - - vscode.commands.registerCommand(Commands.Exec_Flask_In_Terminal, async () => { - const runAppApi = await getPositronRunAppApi(); - await runAppApi.runApplication({ - name: 'Flask', - async getTerminalOptions(runtime, document, port, urlPrefix) { - const args = [runtime.runtimePath, '-m', 'flask', '--app', document.uri.fsPath, 'run']; - if (port) { - args.push('--port', port); - } - const terminalOptions: RunAppTerminalOptions = { commandLine: args.join(' ') }; - if (urlPrefix) { - terminalOptions.env = { SCRIPT_NAME: urlPrefix }; - } - return terminalOptions; - }, - }); - }), - - vscode.commands.registerCommand(Commands.Exec_Gradio_In_Terminal, async () => { - const runAppApi = await getPositronRunAppApi(); - await runAppApi.runApplication({ - name: 'Gradio', - getTerminalOptions(runtime, document, port, urlPrefix) { - const terminalOptions: RunAppTerminalOptions = { - commandLine: [runtime.runtimePath, document.uri.fsPath].join(' '), - }; - if (port || urlPrefix) { - terminalOptions.env = {}; - if (port) { - terminalOptions.env.GRADIO_SERVER_PORT = port; - } - if (urlPrefix) { - terminalOptions.env.GRADIO_ROOT_PATH = urlPrefix; - } - } - return terminalOptions; - }, - }); - }), - - vscode.commands.registerCommand(Commands.Exec_Shiny_In_Terminal, async () => { - const runAppApi = await getPositronRunAppApi(); - await runAppApi.runApplication({ - name: 'Shiny', - getTerminalOptions(runtime, document, port, _urlPrefix) { - const args = [runtime.runtimePath, '-m', 'shiny', 'run', '--reload', document.uri.fsPath]; - if (port) { - args.push('--port', port); - } - return { commandLine: args.join(' ') }; - }, - }); - }), - - vscode.commands.registerCommand(Commands.Exec_Streamlit_In_Terminal, async () => { - const runAppApi = await getPositronRunAppApi(); - await runAppApi.runApplication({ - name: 'Streamlit', - getTerminalOptions(runtime, document, port, _urlPrefix) { - const args = [ - runtime.runtimePath, - '-m', - 'streamlit', - 'run', - document.uri.fsPath, - // Enable headless mode to avoid opening a browser window since it - // will already be previewed in the viewer pane. - '--server.headless', - 'true', - ]; - if (port) { - args.push('--port', port); - } - return { commandLine: args.join(' ') }; - }, - }); - }), + registerExecCommand(Commands.Exec_Dash_In_Terminal, 'Dash', (_runtime, document, port, urlPrefix) => + getDashDebugConfig(document, port, urlPrefix), + ), + registerExecCommand(Commands.Exec_FastAPI_In_Terminal, 'FastAPI', (runtime, document, port, urlPrefix) => + getFastAPIDebugConfig(serviceContainer, runtime, document, port, urlPrefix), + ), + registerExecCommand(Commands.Exec_Flask_In_Terminal, 'Flask', (_runtime, document, port, urlPrefix) => + getFlaskDebugConfig(document, port, urlPrefix), + ), + registerExecCommand(Commands.Exec_Gradio_In_Terminal, 'Gradio', (_runtime, document, port, urlPrefix) => + getGradioDebugConfig(document, port, urlPrefix), + ), + registerExecCommand(Commands.Exec_Shiny_In_Terminal, 'Shiny', (_runtime, document, port, _urlPrefix) => + getShinyDebugConfig(document, port), + ), + registerExecCommand(Commands.Exec_Streamlit_In_Terminal, 'Streamlit', (_runtime, document, port, _urlPrefix) => + getStreamlitDebugConfig(document, port), + ), + registerDebugCommand(Commands.Debug_Dash_In_Terminal, 'Dash', (_runtime, document, port, urlPrefix) => + getDashDebugConfig(document, port, urlPrefix), + ), + registerDebugCommand(Commands.Debug_FastAPI_In_Terminal, 'FastAPI', (runtime, document, port, urlPrefix) => + getFastAPIDebugConfig(serviceContainer, runtime, document, port, urlPrefix), + ), + registerDebugCommand(Commands.Debug_Flask_In_Terminal, 'Flask', (_runtime, document, port, urlPrefix) => + getFlaskDebugConfig(document, port, urlPrefix), + ), + registerDebugCommand(Commands.Debug_Gradio_In_Terminal, 'Gradio', (_runtime, document, port, urlPrefix) => + getGradioDebugConfig(document, port, urlPrefix), + ), + registerDebugCommand(Commands.Debug_Shiny_In_Terminal, 'Shiny', (_runtime, document, port, _urlPrefix) => + getShinyDebugConfig(document, port), + ), + registerDebugCommand( + Commands.Debug_Streamlit_In_Terminal, + 'Streamlit', + (_runtime, document, port, _urlPrefix) => getStreamlitDebugConfig(document, port), + ), ); } +function registerExecCommand( + command: string, + name: string, + getDebugConfiguration: ( + runtime: positron.LanguageRuntimeMetadata, + document: vscode.TextDocument, + port?: string, + urlPrefix?: string, + ) => DebugConfiguration | undefined | Promise, + urlPath?: string, +): vscode.Disposable { + return vscode.commands.registerCommand(command, async () => { + const runAppApi = await getPositronRunAppApi(); + await runAppApi.runApplication({ + name, + async getTerminalOptions(runtime, document, port, urlPrefix) { + const config = await getDebugConfiguration(runtime, document, port, urlPrefix); + if (!config) { + return undefined; + } + + const args = [runtime.runtimePath]; + if ('module' in config) { + args.push('-m', config.module); + } else { + args.push(config.program); + } + if (config.args) { + args.push(...config.args); + } + + const terminalOptions: RunAppTerminalOptions = { + commandLine: args.join(' '), + }; + // Add environment variables if any. + if (config.env && Object.keys(config.env).length > 0) { + terminalOptions.env = config.env; + } + return terminalOptions; + }, + urlPath, + }); + }); +} + +function registerDebugCommand( + command: string, + name: string, + getPythonDebugConfiguration: ( + runtime: positron.LanguageRuntimeMetadata, + document: vscode.TextDocument, + port?: string, + urlPrefix?: string, + ) => DebugConfiguration | undefined | Promise, +): vscode.Disposable { + return vscode.commands.registerCommand(command, async () => { + const runAppApi = await getPositronRunAppApi(); + await runAppApi.debugApplication({ + name, + async getDebugConfiguration(runtime, document, port, urlPrefix) { + const config = await getPythonDebugConfiguration(runtime, document, port, urlPrefix); + if (!config) { + return undefined; + } + return { + type: 'python', + name, + request: 'launch', + ...config, + jinja: true, + stopOnEntry: false, + }; + }, + }); + }); +} + +interface BaseDebugConfiguration { + env?: { [key: string]: string | null | undefined }; + args?: string[]; +} + +interface ModuleDebugConfiguration extends BaseDebugConfiguration { + module: string; +} + +interface ProgramDebugConfiguration extends BaseDebugConfiguration { + program: string; +} + +type DebugConfiguration = ModuleDebugConfiguration | ProgramDebugConfiguration; + +function getDashDebugConfig(document: vscode.TextDocument, port?: string, urlPrefix?: string): DebugConfiguration { + const env: { [key: string]: string | null | undefined } = { + PYTHONPATH: path.dirname(document.uri.fsPath), + }; + if (port) { + env.DASH_PORT = port; + } + if (urlPrefix) { + env.DASH_URL_PREFIX = urlPrefix; + } + + return { program: document.uri.fsPath, env }; +} + +async function getFastAPIDebugConfig( + serviceContainer: IServiceContainer, + runtime: positron.LanguageRuntimeMetadata, + document: vscode.TextDocument, + port?: string, + urlPrefix?: string, +): Promise { + let mod: string | undefined; + let args: string[]; + if (await isFastAPICLIInstalled(serviceContainer, runtime.runtimePath)) { + mod = 'fastapi'; + args = ['dev', document.uri.fsPath]; + } else { + const appName = await getAppName(document, 'FastAPI'); + if (!appName) { + return undefined; + } + mod = 'uvicorn'; + args = ['--reload', `${pathToModule(document.uri.fsPath)}:${appName}`]; + } + + if (port) { + args.push('--port', port); + } + if (urlPrefix) { + args.push('--root-path', urlPrefix); + } + + return { module: mod, args }; +} + +async function isFastAPICLIInstalled(serviceContainer: IServiceContainer, pythonPath: string): Promise { + const interpreterService = serviceContainer.get(IInterpreterService); + const interpreter = await interpreterService.getInterpreterDetails(pythonPath); + if (!interpreter) { + traceError(`Could not check if fastapi-cli is installed due to an invalid interpreter path: ${pythonPath}`); + } + const installer = serviceContainer.get(IInstaller); + return installer.isInstalled(Product.fastapiCli, interpreter); +} + +function getFlaskDebugConfig(document: vscode.TextDocument, port?: string, urlPrefix?: string): DebugConfiguration { + const args = ['--app', document.uri.fsPath, 'run']; + if (port) { + args.push('--port', port); + } + const env: { [key: string]: string } = {}; + if (urlPrefix) { + env.SCRIPT_NAME = urlPrefix; + } + return { module: 'flask', args, env }; +} + +function getGradioDebugConfig(document: vscode.TextDocument, port?: string, urlPrefix?: string): DebugConfiguration { + const env: { [key: string]: string } = {}; + if (port) { + env.GRADIO_SERVER_PORT = port; + } + if (urlPrefix) { + env.GRADIO_ROOT_PATH = urlPrefix; + } + return { program: document.uri.fsPath, env }; +} + +function getShinyDebugConfig(document: vscode.TextDocument, port?: string): DebugConfiguration { + const args = ['run', '--reload', document.uri.fsPath]; + if (port) { + args.push('--port', port); + } + return { module: 'shiny', args }; +} + +function getStreamlitDebugConfig(document: vscode.TextDocument, port?: string): DebugConfiguration { + const args = ['run', document.uri.fsPath, '--server.headless', 'true']; + if (port) { + args.push('--port', port); + } + return { module: 'streamlit', args }; +} + /** * Convert a file path string to Python module format. * For example `path/to/module.py` becomes `path.to.module`. diff --git a/extensions/positron-python/src/test/positron/webAppCommands.unit.test.ts b/extensions/positron-python/src/test/positron/webAppCommands.unit.test.ts index 6265cfb6db7..b7df5900c8c 100644 --- a/extensions/positron-python/src/test/positron/webAppCommands.unit.test.ts +++ b/extensions/positron-python/src/test/positron/webAppCommands.unit.test.ts @@ -14,32 +14,40 @@ import * as assert from 'assert'; import { IDisposableRegistry, IInstaller } from '../../client/common/types'; import { activateWebAppCommands } from '../../client/positron/webAppCommands'; import { IServiceContainer } from '../../client/ioc/types'; -import { PositronRunApp, RunAppOptions, RunAppTerminalOptions } from '../../client/positron-run-app.d'; +import { DebugAppOptions, PositronRunApp, RunAppOptions, RunAppTerminalOptions } from '../../client/positron-run-app.d'; import { Commands } from '../../client/common/constants'; import { IInterpreterService } from '../../client/interpreter/contracts'; import { PythonEnvironment } from '../../client/pythonEnvironments/info'; suite('Web app commands', () => { - const runtimePath = '/path/to/python'; - const workspacePath = '/path/to'; + const runtimePath = path.join('path', 'to', 'python'); + const workspacePath = path.join('path', 'to', 'workspace'); const documentPath = path.join(workspacePath, 'file.py'); const port = '8080'; const urlPrefix = 'http://new-url-prefix'; const disposables: IDisposableRegistry = []; let runAppOptions: RunAppOptions | undefined; + let debugAppOptions: DebugAppOptions | undefined; const commands = new Map Promise>(); let isFastAPICliInstalled: boolean; setup(() => { // Stub `vscode.extensions.getExtension('vscode.positron-run-app')` to return an extension - // with a `runApplication` that records the last `options` that it was called with. + // with: + // 1. `runApplication` that records the last `options` that it was called with. + // 2. `debugApplication` that records the last `options` that it was called with. runAppOptions = undefined; + debugAppOptions = undefined; const runAppApi: PositronRunApp = { async runApplication(_options) { assert(!runAppOptions, 'runApplication called more than once'); runAppOptions = _options; }, + async debugApplication(_options) { + assert(!debugAppOptions, 'debugApplication called more than once'); + debugAppOptions = _options; + }, }; sinon.stub(vscode.extensions, 'getExtension').callsFake((extensionId) => { if (extensionId === 'vscode.positron-run-app') { @@ -144,6 +152,7 @@ suite('Web app commands', () => { test('Exec Dash in terminal - without port and urlPrefix', async () => { await verifyRunAppCommand(Commands.Exec_Dash_In_Terminal, { commandLine: `${runtimePath} ${documentPath}`, + env: { PYTHONPATH: workspacePath }, }); }); @@ -153,6 +162,7 @@ suite('Web app commands', () => { { commandLine: `${runtimePath} ${documentPath}`, env: { + PYTHONPATH: workspacePath, DASH_PORT: port, DASH_URL_PREFIX: urlPrefix, }, @@ -260,4 +270,129 @@ suite('Web app commands', () => { { port, urlPrefix }, ); }); + + async function verifyDebugAppCommand( + command: string, + expectedDebugConfig: vscode.DebugConfiguration | undefined, + options?: { documentText?: string; port?: string; urlPrefix?: string }, + ) { + // Call the command callback and ensure that it sets runAppOptions. + const callback = commands.get(command); + assert(callback, `Command not registered for: ${command}`); + await callback(); + assert(debugAppOptions, `debugAppOptions not set for command: ${command}`); + + // Test `getDebugConfiguration`. + const runtime = { runtimePath } as positron.LanguageRuntimeMetadata; + const document = { + uri: { fsPath: documentPath }, + getText() { + return options?.documentText ?? ''; + }, + } as vscode.TextDocument; + const terminalOptions = await debugAppOptions.getDebugConfiguration( + runtime, + document, + options?.port, + options?.urlPrefix, + ); + assert.deepStrictEqual(terminalOptions, expectedDebugConfig); + } + + test('Debug Dash in terminal - with port and urlPrefix', async () => { + await verifyDebugAppCommand( + Commands.Debug_Dash_In_Terminal, + { + type: 'python', + name: 'Dash', + request: 'launch', + jinja: true, + stopOnEntry: false, + program: documentPath, + env: { PYTHONPATH: workspacePath, DASH_PORT: port, DASH_URL_PREFIX: urlPrefix }, + }, + { port, urlPrefix }, + ); + }); + + test('Debug FastAPI in terminal - with port and urlPrefix', async () => { + await verifyDebugAppCommand( + Commands.Debug_FastAPI_In_Terminal, + { + type: 'python', + name: 'FastAPI', + request: 'launch', + jinja: true, + stopOnEntry: false, + module: 'fastapi', + args: ['dev', documentPath, '--port', port, '--root-path', urlPrefix], + }, + { port, urlPrefix }, + ); + }); + + test('Debug Flask in terminal - without port and urlPrefix', async () => { + await verifyDebugAppCommand( + Commands.Debug_Flask_In_Terminal, + { + type: 'python', + name: 'Flask', + request: 'launch', + jinja: true, + stopOnEntry: false, + module: 'flask', + args: ['--app', documentPath, 'run', '--port', port], + env: { SCRIPT_NAME: urlPrefix }, + }, + { port, urlPrefix }, + ); + }); + + test('Debug Gradio in terminal - without port and urlPrefix', async () => { + await verifyDebugAppCommand( + Commands.Debug_Gradio_In_Terminal, + { + type: 'python', + name: 'Gradio', + request: 'launch', + jinja: true, + stopOnEntry: false, + program: documentPath, + env: { GRADIO_SERVER_PORT: port, GRADIO_ROOT_PATH: urlPrefix }, + }, + { port, urlPrefix }, + ); + }); + + test('Debug Shiny in terminal - with port and urlPrefix', async () => { + await verifyDebugAppCommand( + Commands.Debug_Shiny_In_Terminal, + { + type: 'python', + name: 'Shiny', + request: 'launch', + jinja: true, + stopOnEntry: false, + module: 'shiny', + args: ['run', '--reload', documentPath, '--port', port], + }, + { port, urlPrefix }, + ); + }); + + test('Debug Streamlit in terminal - with port and urlPrefix', async () => { + await verifyDebugAppCommand( + Commands.Debug_Streamlit_In_Terminal, + { + type: 'python', + name: 'Streamlit', + request: 'launch', + jinja: true, + stopOnEntry: false, + module: 'streamlit', + args: ['run', documentPath, '--server.headless', 'true', '--port', port], + }, + { port, urlPrefix }, + ); + }); }); diff --git a/extensions/positron-run-app/src/api.ts b/extensions/positron-run-app/src/api.ts new file mode 100644 index 00000000000..cb0d22c2f82 --- /dev/null +++ b/extensions/positron-run-app/src/api.ts @@ -0,0 +1,432 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { randomUUID } from 'crypto'; +import * as positron from 'positron'; +import * as vscode from 'vscode'; +import { DebugAdapterTrackerFactory } from './debugAdapterTrackerFactory'; +import { Config, log } from './extension'; +import { DebugAppOptions, PositronRunApp, RunAppOptions } from './positron-run-app'; +import { raceTimeout, SequencerByKey } from './utils'; + +const localUrlRegex = /http:\/\/(localhost|127\.0\.0\.1):(\d{1,5})/; + +export class PositronRunAppApiImpl implements PositronRunApp, vscode.Disposable { + private readonly _debugApplicationSequencerByName = new SequencerByKey(); + private readonly _debugApplicationDisposableByName = new Map(); + private readonly _runApplicationSequencerByName = new SequencerByKey(); + private readonly _runApplicationDisposableByName = new Map(); + + constructor( + private readonly _globalState: vscode.Memento, + private readonly _debugAdapterTrackerFactory: DebugAdapterTrackerFactory, + ) { } + + public dispose() { + this._debugApplicationDisposableByName.forEach(disposable => disposable.dispose()); + this._runApplicationDisposableByName.forEach(disposable => disposable.dispose()); + } + + private isShellIntegrationSupported(): boolean { + return this._globalState.get('shellIntegrationSupported', true); + } + + public setShellIntegrationSupported(supported: boolean): Thenable { + return this._globalState.update('shellIntegrationSupported', supported); + } + + public async runApplication(options: RunAppOptions): Promise { + // If there's no active text editor, do nothing. + const document = vscode.window.activeTextEditor?.document; + if (!document) { + return; + } + + if (this._runApplicationSequencerByName.has(options.name)) { + vscode.window.showErrorMessage(vscode.l10n.t('{0} application is already starting.', options.name)); + return; + } + + return this.queueRunApplication(document, options); + } + + private queueRunApplication(document: vscode.TextDocument, options: RunAppOptions): Promise { + return this._runApplicationSequencerByName.queue( + options.name, + () => Promise.resolve(vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t('Running {0} application', options.name), + }, + (progress) => this.doRunApplication(document, options, progress), + )), + ); + } + + private async doRunApplication(document: vscode.TextDocument, options: RunAppOptions, progress: vscode.Progress<{ message?: string }>): Promise { + // Dispose existing disposables for the application, if any. + this._runApplicationDisposableByName.get(options.name)?.dispose(); + this._runApplicationDisposableByName.delete(options.name); + + // Save the active document if it's dirty. + if (document.isDirty) { + await document.save(); + } + + // Get the preferred runtime for the document's language. + progress.report({ message: vscode.l10n.t('Getting interpreter information...') }); + const runtime = await this.getPreferredRuntime(document.languageId); + if (!runtime) { + return; + } + + // Get the terminal options for the application. + // TODO: If we're in Posit Workbench find a free port and corresponding URL prefix. + // Some application frameworks need to know the URL prefix when running behind a proxy. + progress.report({ message: vscode.l10n.t('Getting terminal options...') }); + const port = undefined; + const urlPrefix = undefined; + const terminalOptions = await options.getTerminalOptions(runtime, document, port, urlPrefix); + if (!terminalOptions) { + return; + } + + // Show shell integration prompts and check if shell integration is: + // - enabled in the workspace, and + // - supported in the terminal. + const isShellIntegrationEnabledAndSupported = this.showShellIntegrationMessages( + () => this.queueRunApplication(document, options) + ); + + // Get existing terminals with the application's name. + const existingTerminals = vscode.window.terminals.filter((t) => t.name === options.name); + + // Create a new terminal for the application. + const terminal = vscode.window.createTerminal({ + name: options.name, + env: terminalOptions.env, + }); + + // Reveal the new terminal. + terminal.show(true); + + // Wait for existing terminals to close, or a timeout. + progress.report({ message: vscode.l10n.t('Closing existing terminals...') }); + await raceTimeout( + Promise.allSettled(existingTerminals.map((terminal) => { + // Create a promise that resolves when the terminal is closed. + // Note that the application process may still be running once this promise resolves. + const terminalDidClose = new Promise((resolve) => { + const disposable = vscode.window.onDidCloseTerminal((closedTerminal) => { + if (closedTerminal === terminal) { + disposable.dispose(); + resolve(); + } + }); + }); + + // Close the terminal. + terminal.dispose(); + + return terminalDidClose; + })), + 5000, + () => log.warn('Timed out waiting for existing terminals to close. Proceeding anyway'), + ); + + // Create a disposables store for this session. + + // Create a promise that resolves when the server URL has been previewed, + // or an error has occurred, or it times out. + const didPreviewUrl = raceTimeout( + new Promise((resolve) => { + const disposable = vscode.window.onDidStartTerminalShellExecution(async e => { + // Remember that shell integration is supported. + await this.setShellIntegrationSupported(true); + + if (e.terminal === terminal) { + const didPreviewUrl = await previewUrlInExecutionOutput(e.execution, options.urlPath); + if (didPreviewUrl) { + resolve(didPreviewUrl); + } + } + }); + this._runApplicationDisposableByName.set(options.name, disposable); + }), + 10_000, + async () => { + await this.setShellIntegrationSupported(false); + }); + + // Execute the command. + progress.report({ message: vscode.l10n.t('Starting application...') }); + terminal.sendText(terminalOptions.commandLine, true); + + if (isShellIntegrationEnabledAndSupported && !await didPreviewUrl) { + log.warn('Failed to preview URL using shell integration'); + // TODO: If a port was provided, we could poll the server until it responds, + // then open the URL in the viewer pane. + } + } + + public async debugApplication(options: DebugAppOptions): Promise { + // If there's no active text editor, do nothing. + const document = vscode.window.activeTextEditor?.document; + if (!document) { + return; + } + + if (this._debugApplicationSequencerByName.has(options.name)) { + vscode.window.showErrorMessage(vscode.l10n.t('{0} application is already starting.', options.name)); + return; + } + + return this.queueDebugApplication(document, options); + } + + private queueDebugApplication(document: vscode.TextDocument, options: DebugAppOptions): Promise { + return this._debugApplicationSequencerByName.queue( + options.name, + () => Promise.resolve(vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t('Debugging {0} application', options.name), + }, + (progress) => this.doDebugApplication(document, options, progress), + )), + ); + } + + private async doDebugApplication(document: vscode.TextDocument, options: DebugAppOptions, progress: vscode.Progress<{ message?: string }>): Promise { + // Dispose existing disposables for the application, if any. + this._debugApplicationDisposableByName.get(options.name)?.dispose(); + this._debugApplicationDisposableByName.delete(options.name); + + // Save the active document if it's dirty. + if (document.isDirty) { + await document.save(); + } + + // Get the preferred runtime for the document's language. + progress.report({ message: vscode.l10n.t('Getting interpreter information...') }); + const runtime = await this.getPreferredRuntime(document.languageId); + if (!runtime) { + return; + } + + // Get the debug config for the application. + // TODO: If we're in Posit Workbench find a free port and corresponding URL prefix. + // Some application frameworks need to know the URL prefix when running behind a proxy. + progress.report({ message: vscode.l10n.t('Getting debug configuration...') }); + const port = undefined; + const urlPrefix = undefined; + const debugConfig = await options.getDebugConfiguration(runtime, document, port, urlPrefix); + if (!debugConfig) { + return; + } + + // Show shell integration prompts and check if shell integration is: + // - enabled in the workspace, and + // - supported in the terminal. + const isShellIntegrationEnabledAndSupported = this.showShellIntegrationMessages( + () => this.queueDebugApplication(document, options) + ); + + // Stop the application's current debug session, if one exists. + const activeDebugSession = vscode.debug.activeDebugSession; + if (activeDebugSession?.name === debugConfig.name) { + progress.report({ message: vscode.l10n.t('Stopping existing debug session...') }); + await vscode.debug.stopDebugging(activeDebugSession); + } + + const debugAppRequestId = debugConfig.debugAppRequestId = randomUUID(); + + // Create a promise that resolves when the server URL has been previewed, + // or an error has occurred, or it times out. + const didPreviewUrl = raceTimeout( + new Promise((resolve) => { + let executionDisposable: vscode.Disposable | undefined; + const disposable = this._debugAdapterTrackerFactory.onDidRequestRunInTerminal(e => { + if (e.debugSession.configuration.debugAppRequestId === debugAppRequestId) { + // Dispose the existing terminal execution listener, if any. + executionDisposable?.dispose(); + + const { processId } = e; + executionDisposable = vscode.window.onDidStartTerminalShellExecution(async e => { + // Remember that shell integration is supported. + await this.setShellIntegrationSupported(true); + + if (await e.terminal.processId === processId) { + const didPreviewUrl = await previewUrlInExecutionOutput(e.execution, options.urlPath); + if (didPreviewUrl) { + resolve(didPreviewUrl); + } + } + }); + } + }); + this._debugApplicationDisposableByName.set(options.name, disposable); + }), + 10_000, + async () => { + await this.setShellIntegrationSupported(false); + }); + + // Start the debug session. + progress.report({ message: vscode.l10n.t('Starting application...') }); + await vscode.debug.startDebugging(undefined, debugConfig); + + // Wait for the server URL to be previewed, or a timeout. + if (isShellIntegrationEnabledAndSupported && !await didPreviewUrl) { + log.warn('Failed to preview URL using shell integration'); + } + } + + /** Get the preferred runtime for a language; forwarding errors to the UI. */ + private async getPreferredRuntime(languageId: string): Promise { + try { + return await positron.runtime.getPreferredRuntime(languageId); + } catch (error) { + vscode.window.showErrorMessage( + vscode.l10n.t( + "Failed to get '{0}' interpreter information. Error: {1}", + languageId, + error.message + ), + ); + } + return undefined; + } + + private showShellIntegrationMessages(rerunApplicationCallback: () => any): boolean { + // Check if shell integration is enabled in the workspace. + const isShellIntegrationEnabled = vscode.workspace.getConfiguration().get(Config.ShellIntegrationEnabled, false); + + // Check if shell integration was detected as supported in a previous application run. + const isShellIntegrationSupported = this.isShellIntegrationSupported(); + + if (isShellIntegrationEnabled) { + if (!isShellIntegrationSupported) { + // Show a message indicating that shell integration is not supported. + showShellIntegrationNotSupportedMessage(); + } + } else { + // Show a message to enable shell integration and rerun the application. + showEnableShellIntegrationMessage(async () => { + await this.setShellIntegrationSupported(true); + rerunApplicationCallback(); + }); + } + + return isShellIntegrationEnabled && isShellIntegrationSupported; + } +} + +async function previewUrlInExecutionOutput(execution: vscode.TerminalShellExecution, urlPath?: string) { + // Wait for the server URL to appear in the terminal output, or a timeout. + const stream = execution.read(); + const url = await raceTimeout( + (async () => { + for await (const data of stream) { + log.warn('Execution:', execution.commandLine.value, data); + const match = data.match(localUrlRegex)?.[0]; + if (match) { + return new URL(match); + } + } + log.warn('URL not found in terminal output'); + return false; + })(), + 5_000, + ); + + if (url === undefined) { + log.warn('Timed out waiting for server URL in terminal output'); + return false; + } + + // Convert the url to an external URI. + const localBaseUri = vscode.Uri.parse(url.toString()); + const localUri = urlPath ? + vscode.Uri.joinPath(localBaseUri, urlPath) : localBaseUri; + const externalUri = await vscode.env.asExternalUri(localUri); + + // Preview the external URI. + positron.window.previewUrl(externalUri); + + return true; +} + +async function showEnableShellIntegrationMessage(rerunApplicationCallback: () => any): Promise { + // Don't show if the user disabled this message. + if (!vscode.workspace.getConfiguration().get(Config.ShowEnableShellIntegrationMessage)) { + return; + } + + // Prompt the user to enable shell integration. + const enableShellIntegration = vscode.l10n.t('Enable Shell Integration'); + const notNow = vscode.l10n.t('Not Now'); + const dontAskAgain = vscode.l10n.t('Don\'t Ask Again'); + const selection = await vscode.window.showInformationMessage( + vscode.l10n.t( + 'Shell integration is disabled. Would you like to enable shell integration for this ' + + 'workspace to automatically preview your application in the Viewer pane?', + ), + enableShellIntegration, + notNow, + dontAskAgain, + ); + + if (selection === enableShellIntegration) { + // Enable shell integration. + const shellIntegrationConfig = vscode.workspace.getConfiguration('terminal.integrated.shellIntegration'); + await shellIntegrationConfig.update('enabled', true, vscode.ConfigurationTarget.Workspace); + + // Prompt the user to rerun the application. + const rerunApplication = vscode.l10n.t('Rerun Application'); + const notNow = vscode.l10n.t('Not Now'); + const selection = await vscode.window.showInformationMessage( + vscode.l10n.t('Shell integration is now enabled. Would you like to rerun the application?'), + rerunApplication, + notNow, + ); + + if (selection === rerunApplication) { + // Rerun the application. + rerunApplicationCallback(); + } + } else if (selection === dontAskAgain) { + // Disable the prompt for future runs. + const runAppConfig = vscode.workspace.getConfiguration('positron.runApplication'); + await runAppConfig.update('showShellIntegrationPrompt', false, vscode.ConfigurationTarget.Global); + } +} + +async function showShellIntegrationNotSupportedMessage(): Promise { + // Don't show if the user disabled this message. + if (!vscode.workspace.getConfiguration().get(Config.ShowShellIntegrationNotSupportedMessage)) { + return; + } + + const learnMore = vscode.l10n.t('Learn More'); + const dismiss = vscode.l10n.t('Dismiss'); + const dontShowAgain = vscode.l10n.t('Don\'t Show Again'); + const selection = await vscode.window.showWarningMessage( + vscode.l10n.t( + 'Shell integration isn\'t supported in this terminal, ' + + 'so automatic preview in the Viewer pane isn\'t available. ' + + 'To use this feature, please switch to a terminal that supports shell integration.' + ), + learnMore, + dismiss, + dontShowAgain, + ); + + if (selection === learnMore) { + await vscode.env.openExternal(vscode.Uri.parse('https://code.visualstudio.com/docs/terminal/shell-integration')); + } else if (selection === dontShowAgain) { + // Disable the prompt for future runs. + const runAppConfig = vscode.workspace.getConfiguration('positron.runApplication'); + await runAppConfig.update('showShellIntegrationNotSupportedMessage', false, vscode.ConfigurationTarget.Global); + } +} diff --git a/extensions/positron-run-app/src/debugAdapterTrackerFactory.ts b/extensions/positron-run-app/src/debugAdapterTrackerFactory.ts new file mode 100644 index 00000000000..7bc54d14ac7 --- /dev/null +++ b/extensions/positron-run-app/src/debugAdapterTrackerFactory.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +export function registerDebugAdapterTrackerFactory(disposables: vscode.Disposable[]): DebugAdapterTrackerFactory { + const factory = new DebugAdapterTrackerFactory(); + disposables.push(vscode.debug.registerDebugAdapterTrackerFactory('*', factory)); + return factory; +} + +/** + * Event fired when a debug session requests to run a command in the terminal. + */ +export interface RequestRunInTerminalEvent { + debugSession: vscode.DebugSession; + processId: number; +} + +/** + * Watches for debug sessions requesting to run commands in the integrated integrated terminal. + */ +export class DebugAdapterTrackerFactory implements vscode.DebugAdapterTrackerFactory, vscode.Disposable { + private readonly _disposables = new Array(); + + private readonly _onDidRequestRunInTerminal = new vscode.EventEmitter(); + + /** Event fired when a debug session requests to run a command in the integrated terminal. */ + public readonly onDidRequestRunInTerminal = this._onDidRequestRunInTerminal.event; + + dispose() { + this._disposables.forEach(disposable => disposable.dispose()); + this._onDidRequestRunInTerminal.dispose(); + } + + public createDebugAdapterTracker(session: vscode.DebugSession): vscode.ProviderResult { + const tracker = new DebugAdapterTracker(); + + this._disposables.push( + tracker, + tracker.onDidRequestRunInTerminal(processId => { + this._onDidRequestRunInTerminal.fire({ debugSession: session, processId }); + }), + ); + + return tracker; + } +} + +class DebugAdapterTracker implements vscode.DebugAdapterTracker, vscode.Disposable { + private _runInTerminalRequestSeq?: number; + + private readonly _onDidRequestRunInTerminal = new vscode.EventEmitter(); + + /** Event fired when a debug session requests to run a command in the integrated terminal. */ + public readonly onDidRequestRunInTerminal = this._onDidRequestRunInTerminal.event; + + dispose() { + this._onDidRequestRunInTerminal.dispose(); + } + + public onDidSendMessage(msg: any): void { + // Listen for the debug adapter requesting to run a command in the integrated terminal. + if (msg.type === 'request' && + msg.command === 'runInTerminal' && + msg.arguments && + msg.arguments.kind === 'integrated') { + this._runInTerminalRequestSeq = msg.seq; + } + } + + public onWillReceiveMessage(msg: any): void { + // Listen for the debug adapter receiving the response to the runInTerminal request. + if (this._runInTerminalRequestSeq && + msg.type === 'response' && + msg.command === 'runInTerminal' && + msg.body && + msg.request_seq === this._runInTerminalRequestSeq) { + this._runInTerminalRequestSeq = undefined; + this._onDidRequestRunInTerminal.fire(msg.body.shellProcessId); + } + } +} diff --git a/extensions/positron-run-app/src/extension.ts b/extensions/positron-run-app/src/extension.ts index a579686946a..b422044865d 100644 --- a/extensions/positron-run-app/src/extension.ts +++ b/extensions/positron-run-app/src/extension.ts @@ -4,307 +4,22 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import * as positron from 'positron'; -import { PositronRunApp, RunAppOptions } from './positron-run-app'; -import { raceTimeout, SequencerByKey } from './utils'; - -const localUrlRegex = /http:\/\/(localhost|127\.0\.0\.1):(\d{1,5})/; +import { PositronRunAppApiImpl } from './api'; +import { registerDebugAdapterTrackerFactory } from './debugAdapterTrackerFactory'; +import { PositronRunApp } from './positron-run-app'; export const log = vscode.window.createOutputChannel('Positron Run App', { log: true }); -export async function activate(context: vscode.ExtensionContext): Promise { - context.subscriptions.push(log); - - return new PositronRunAppApiImpl(context); -} - -export class PositronRunAppApiImpl implements PositronRunApp { - private readonly _runApplicationSequencerByName = new SequencerByKey(); - - constructor(private readonly _context: vscode.ExtensionContext) { } - - private isShellIntegrationSupported(): boolean { - return this._context.globalState.get('shellIntegrationSupported', true); - } - - setShellIntegrationSupported(supported: boolean): Thenable { - return this._context.globalState.update('shellIntegrationSupported', supported); - } - - async runApplication(options: RunAppOptions): Promise { - // If there's no active text editor, do nothing. - const document = vscode.window.activeTextEditor?.document; - if (!document) { - return; - } - - if (this._runApplicationSequencerByName.has(options.name)) { - vscode.window.showErrorMessage(vscode.l10n.t('{0} application is already starting.', options.name)); - return; - } - - return this.queueRunApplication(document, options); - } - - private queueRunApplication(document: vscode.TextDocument, options: RunAppOptions): Promise { - return this._runApplicationSequencerByName.queue( - options.name, - () => Promise.resolve(vscode.window.withProgress({ - location: vscode.ProgressLocation.Notification, - title: vscode.l10n.t(`Running ${options.name} application`), - }, - (progress) => this.doRunApplication(document, options, progress), - )), - ); - } - - private async doRunApplication(document: vscode.TextDocument, options: RunAppOptions, progress: vscode.Progress<{ message?: string }>): Promise { - // Save the active document if it's dirty. - if (document.isDirty) { - await document.save(); - } - - // Get the preferred runtime for the document's language. - progress.report({ message: vscode.l10n.t('Getting interpreter information...') }); - let runtime: positron.LanguageRuntimeMetadata; - try { - runtime = await positron.runtime.getPreferredRuntime(document.languageId); - } catch (error) { - vscode.window.showErrorMessage( - vscode.l10n.t( - "Failed to get '{0}' interpreter information. Error: {1}", - document.languageId, - error.message - ), - ); - return; - } - - // Get the terminal options for the application. - // TODO: If we're in Posit Workbench find a free port and corresponding URL prefix. - // Some application frameworks need to know the URL prefix when running behind a proxy. - progress.report({ message: vscode.l10n.t('Getting terminal options...') }); - const port = undefined; - const urlPrefix = undefined; - const terminalOptions = await options.getTerminalOptions(runtime, document, port, urlPrefix); - if (!terminalOptions) { - return; - } - - // Get existing terminals with the application's name. - const existingTerminals = vscode.window.terminals.filter((t) => t.name === options.name); - - // Create a new terminal for the application. - const terminal = vscode.window.createTerminal({ - name: options.name, - env: terminalOptions.env, - }); - - // Reveal the new terminal. - terminal.show(true); - - // Wait for existing terminals to close, or a timeout. - progress.report({ message: vscode.l10n.t('Closing existing terminals...') }); - await raceTimeout( - Promise.allSettled(existingTerminals.map((terminal) => { - // Create a promise that resolves when the terminal is closed. - // Note that the application process may still be running once this promise resolves. - const terminalDidClose = new Promise((resolve) => { - const disposable = vscode.window.onDidCloseTerminal((closedTerminal) => { - if (closedTerminal === terminal) { - disposable.dispose(); - resolve(); - } - }); - }); - - // Close the terminal. - terminal.dispose(); - - return terminalDidClose; - })), - 5000, - () => { - log.warn('Timed out waiting for existing terminals to close. Proceeding anyway'); - } - ); - - // Replace the contents of the viewer pane with a blank page while the app is loading. - positron.window.previewUrl(vscode.Uri.parse('about:blank')); - - const shellIntegrationConfig = vscode.workspace.getConfiguration('terminal.integrated.shellIntegration'); - const runAppConfig = vscode.workspace.getConfiguration('positron.runApplication'); - - let shellIntegration: vscode.TerminalShellIntegration | undefined; - if (shellIntegrationConfig.get('enabled')) { - // Shell integration may have already been injected into the terminal. - shellIntegration = terminal.shellIntegration; - - // If it hasn't yet been injected... - if (!shellIntegration) { - - // Create a promise that resolves with the terminal's shell integration once it's injected. - const shellIntegrationPromise = new Promise((resolve) => { - const disposable = vscode.window.onDidChangeTerminalShellIntegration(async (e) => { - if (e.terminal === terminal) { - disposable.dispose(); - resolve(e.shellIntegration); - - // Remember that shell integration is supported in this terminal. - await this.setShellIntegrationSupported(true); - } - }); - }); - - if (this.isShellIntegrationSupported()) { - // Shell integration was detected as supported in a previous run. - // Wait for it to be injected, or a timeout. - progress.report({ message: vscode.l10n.t('Activating terminal shell integration...') }); - - shellIntegration = await raceTimeout(shellIntegrationPromise, 5000, () => { - log.warn('Timed out waiting for terminal shell integration. Proceeding without shell integration'); - - // Remember that shell integration is not supported in this terminal, - // so that we don't wait for it to be injected next time. - this.setShellIntegrationSupported(false); - - // Show the shell integration not supported message, if enabled. - if (runAppConfig.get('showShellIntegrationNotSupportedMessage')) { - showShellIntegrationNotSupportedMessage() - .catch(error => log.error(`Error showing shell integration not supported message: ${error}`)); - } - }); - } else { - // Shell integration was detected as not supported in a previous run. - // Don't wait for it to be injected, and show a warning if enabled. - log.warn('Shell integration is not supported in this terminal'); - if (runAppConfig.get('showShellIntegrationNotSupportedMessage')) { - showShellIntegrationNotSupportedMessage() - .catch(error => log.error(`Error showing shell integration not supported message: ${error}`)); - } - } - } - } else if (runAppConfig.get('showEnableShellIntegrationMessage')) { - // Shell integration is disabled. Proceed without it, but give the user the option to - // enable it and to rerun the application. - showEnableShellIntegrationMessage() - .catch(error => { - log.error(`Error during shell integration prompt: ${error}`); - return { rerunApplication: false }; - }) - .then(({ rerunApplication }) => { - if (rerunApplication) { - this.queueRunApplication(document, options); - } - }); - } - - - if (shellIntegration) { - progress.report({ message: vscode.l10n.t('Starting application server...') }); - const execution = shellIntegration.executeCommand(terminalOptions.commandLine); - - // Wait for the server URL to appear in the terminal output, or a timeout. - const stream = execution.read(); - const url = await raceTimeout( - (async () => { - for await (const data of stream) { - const match = data.match(localUrlRegex)?.[0]; - if (match) { - return new URL(match); - } - } - log.warn('URL not found in terminal output'); - return undefined; - })(), - 5000, - () => { - log.warn('Timed out waiting for server URL in terminal output'); - } - ); - - if (url) { - // Convert the url to an external URI. - const localBaseUri = vscode.Uri.parse(url.toString()); - const localUri = options.urlPath ? - vscode.Uri.joinPath(localBaseUri, options.urlPath) : localBaseUri; - const externalUri = await vscode.env.asExternalUri(localUri); - - // Open the server URL in the viewer pane. - positron.window.previewUrl(externalUri); - } - } else { - // No shell integration support, just run the command. - terminal.sendText(terminalOptions.commandLine, true); - - // TODO: If a port was provided, we could poll the server until it responds, - // then open the URL in the viewer pane. - } - } +export enum Config { + ShellIntegrationEnabled = 'terminal.integrated.shellIntegration.enabled', + ShowEnableShellIntegrationMessage = 'positron.runApplication.showEnableShellIntegrationMessage', + ShowShellIntegrationNotSupportedMessage = 'positron.runApplication.showShellIntegrationNotSupportedMessage', } -interface IShellIntegrationPromptResult { - rerunApplication: boolean; -} - -async function showEnableShellIntegrationMessage(): Promise { - // Prompt the user to enable shell integration. - const enableShellIntegration = vscode.l10n.t('Enable Shell Integration'); - const notNow = vscode.l10n.t('Not Now'); - const dontAskAgain = vscode.l10n.t('Don\'t Ask Again'); - const selection = await vscode.window.showInformationMessage( - vscode.l10n.t( - 'Shell integration is disabled. Would you like to enable shell integration for this ' + - 'workspace to automatically preview your application in the Viewer pane?', - ), - enableShellIntegration, - notNow, - dontAskAgain, - ); - - if (selection === enableShellIntegration) { - // Enable shell integration. - const shellIntegrationConfig = vscode.workspace.getConfiguration('terminal.integrated.shellIntegration'); - await shellIntegrationConfig.update('enabled', true, vscode.ConfigurationTarget.Workspace); - - // Prompt the user to rerun the application. - const rerunApplication = vscode.l10n.t('Rerun Application'); - const notNow = vscode.l10n.t('Not Now'); - const selection = await vscode.window.showInformationMessage( - vscode.l10n.t('Shell integration is now enabled. Would you like to rerun the application?'), - rerunApplication, - notNow, - ); - return { rerunApplication: selection === rerunApplication }; - } else if (selection === dontAskAgain) { - // Disable the prompt for future runs. - const runAppConfig = vscode.workspace.getConfiguration('positron.runApplication'); - await runAppConfig.update('showShellIntegrationPrompt', false, vscode.ConfigurationTarget.Global); - } - - return { rerunApplication: false }; -} +export async function activate(context: vscode.ExtensionContext): Promise { + context.subscriptions.push(log); -async function showShellIntegrationNotSupportedMessage(): Promise { - const learnMore = vscode.l10n.t('Learn More'); - const dismiss = vscode.l10n.t('Dismiss'); - const dontShowAgain = vscode.l10n.t('Don\'t Show Again'); - const selection = await vscode.window.showWarningMessage( - vscode.l10n.t( - 'Shell integration isn\'t supported in this terminal, ' + - 'so automatic preview in the Viewer pane isn\'t available. ' + - 'To use this feature, please switch to a terminal that supports shell integration.' - ), - learnMore, - dismiss, - dontShowAgain, - ); + const debugSessionTerminalWatcher = registerDebugAdapterTrackerFactory(context.subscriptions); - if (selection === learnMore) { - await vscode.env.openExternal(vscode.Uri.parse('https://code.visualstudio.com/docs/terminal/shell-integration')); - } else if (selection === dontShowAgain) { - // Disable the prompt for future runs. - const runAppConfig = vscode.workspace.getConfiguration('positron.runApplication'); - await runAppConfig.update('showShellIntegrationNotSupportedMessage', false, vscode.ConfigurationTarget.Global); - } + return new PositronRunAppApiImpl(context.globalState, debugSessionTerminalWatcher); } diff --git a/extensions/positron-run-app/src/positron-run-app.d.ts b/extensions/positron-run-app/src/positron-run-app.d.ts index 822f800bef5..e3bd4eb2acb 100644 --- a/extensions/positron-run-app/src/positron-run-app.d.ts +++ b/extensions/positron-run-app/src/positron-run-app.d.ts @@ -53,6 +53,40 @@ export interface RunAppOptions { urlPath?: string; } +/** + * Represents options for the ${@link PositronRunApp.debugApplication} function. + */ +export interface DebugAppOptions { + /** + * The human-readable label for the application e.g. `'Shiny'`. + */ + name: string; + + /** + * A function that will be called to get the ${@link vscode.DebugConfiguration, debug configuration} for debugging the application. + * + * @param runtime The language runtime metadata for the document's language. + * @param document The document to debug. + * @param port The port to run the application on, if known. + * @param urlPrefix The URL prefix to use, if known. + * @returns The debug configuration for debugging the application. Return `undefined` to abort debugging. + */ + getDebugConfiguration( + runtime: positron.LanguageRuntimeMetadata, + document: vscode.TextDocument, + port?: string, + urlPrefix?: string, + ): vscode.DebugConfiguration | undefined | Promise; + + /** + * The optional URL path at which to preview the application. + */ + urlPath?: string; +} + +export interface DebugConfiguration { +} + /** * The public API of the Positron Run App extension. */ @@ -65,4 +99,13 @@ export interface PositronRunApp { * started, otherwise resolves when the command has been sent to the terminal. */ runApplication(options: RunAppOptions): Promise; + + /** + * Debug an application. + * + * @param options Options for debugging the application. + * @returns If terminal shell integration is supported, resolves when the application server has + * started, otherwise resolves when the debug session has started. + */ + debugApplication(options: DebugAppOptions): Promise; } diff --git a/extensions/positron-run-app/src/test/extension.test.ts b/extensions/positron-run-app/src/test/api.test.ts similarity index 63% rename from extensions/positron-run-app/src/test/extension.test.ts rename to extensions/positron-run-app/src/test/api.test.ts index 6dab8544a32..bee2414007b 100644 --- a/extensions/positron-run-app/src/test/extension.test.ts +++ b/extensions/positron-run-app/src/test/api.test.ts @@ -7,27 +7,42 @@ import * as assert from 'assert'; import * as positron from 'positron'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; -import { RunAppOptions } from '../positron-run-app'; +import { DebugAppOptions, RunAppOptions } from '../positron-run-app'; import { raceTimeout } from '../utils'; -import { PositronRunAppApiImpl } from '../extension'; +import { PositronRunAppApiImpl } from '../api'; suite('PositronRunApp', () => { // Use a test runtime with a runtimePath of `cat` so that executing a file // will simply print its contents to the terminal. const runtime = { - runtimePath: 'cat', + runtimePath: 'node', } as positron.LanguageRuntimeMetadata; // Options for running the test application. - const options: RunAppOptions = { + const runAppOptions: RunAppOptions = { name: 'Test App', - async getTerminalOptions(runtime, document, _port, _urlPrefix) { + getTerminalOptions(runtime, document, _port, _urlPrefix) { return { commandLine: [runtime.runtimePath, document.uri.fsPath].join(' '), }; }, }; + // Options for debugging the test application. + const debugAppOptions: DebugAppOptions = { + name: 'Test App', + getDebugConfiguration(_runtime, document, _port, _urlPrefix) { + return { + name: 'Launch Test App', + type: 'node', + request: 'launch', + program: document.uri.fsPath, + // Use the terminal since we rely on shell integration. + console: 'integratedTerminal', + }; + }, + }; + // Matches a server URL on localhost. const localhostUriMatch = sinon.match((uri: vscode.Uri) => uri.scheme === 'http' && /localhost:\d+/.test(uri.authority)); @@ -37,14 +52,13 @@ suite('PositronRunApp', () => { let uri: vscode.Uri; let previewUrlStub: sinon.SinonStub; let sendTextSpy: sinon.SinonSpy | undefined; - let executedCommandLine: string | undefined; let runAppApi: PositronRunAppApiImpl; setup(async () => { // Open the test app. Assumes that the tests are run in the ../test-workspace workspace. const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; assert(workspaceFolder, 'This test should be run from the ../test-workspace workspace'); - uri = vscode.Uri.joinPath(workspaceFolder.uri, 'app.sh'); + uri = vscode.Uri.joinPath(workspaceFolder.uri, 'app.js'); await vscode.window.showTextDocument(uri); // Stub the runtime API to return the test runtime. @@ -66,15 +80,6 @@ suite('PositronRunApp', () => { // Enable shell integration. await vscode.workspace.getConfiguration('terminal.integrated.shellIntegration').update('enabled', true); - // Capture executions in the app's terminal while shell integration enabled. - executedCommandLine = undefined; - disposables.push(vscode.window.onDidStartTerminalShellExecution(e => { - if (e.terminal.name === options.name) { - assert(!executedCommandLine, 'Multiple terminal shell executions started'); - executedCommandLine = e.execution.commandLine.value; - } - })); - runAppApi = await getRunAppApi(); runAppApi.setShellIntegrationSupported(true); }); @@ -96,28 +101,23 @@ suite('PositronRunApp', () => { } async function verifyRunTestApplication(): Promise { - await runAppApi.runApplication(options); + await runAppApi.runApplication(runAppOptions); // Check that a terminal was created for the application. - const terminal = vscode.window.terminals.find((t) => t.name === options.name); + const terminal = vscode.window.terminals.find((t) => t.name === runAppOptions.name); assert(terminal, 'Terminal not found'); - - // Check that the viewer pane was cleared before any other URL was previewed. - sinon.assert.called(previewUrlStub); - sinon.assert.calledWith(previewUrlStub.getCall(0), vscode.Uri.parse('about:blank')); } test('runApplication: shell integration supported', async () => { // Run the application. await verifyRunTestApplication(); - // Check that the expected command line was executed in the terminal. - assert(executedCommandLine, 'No terminal shell execution started'); - assert.strictEqual(executedCommandLine, `${runtime.runtimePath} ${uri.fsPath}`, 'Unexpected command line executed'); + // Check that the expected text was sent to the terminal. + assert(sendTextSpy, 'Terminal.sendText spy not created'); + sinon.assert.calledOnceWithExactly(sendTextSpy, `${runtime.runtimePath} ${uri.fsPath}`, true); // Check that the expected URL was previewed. - sinon.assert.calledTwice(previewUrlStub); - sinon.assert.calledWith(previewUrlStub.getCall(1), localhostUriMatch); + sinon.assert.calledOnceWithMatch(previewUrlStub, localhostUriMatch); }); test('runApplication: shell integration disabled, user enables and reruns', async () => { @@ -145,9 +145,7 @@ suite('PositronRunApp', () => { // Check that the expected text was sent to the terminal. assert(sendTextSpy, 'Terminal.sendText spy not created'); sinon.assert.calledOnceWithExactly(sendTextSpy, `${runtime.runtimePath} ${uri.fsPath}`, true); - - // Check that the server URL was not previewed yet (only a single call to clear the viewer pane). - sinon.assert.calledOnce(previewUrlStub); + sendTextSpy = undefined; // Wait for the expected URL to be previewed. const didPreviewExpectedUrl = await raceTimeout(didPreviewExpectedUrlPromise, 10_000); @@ -159,13 +157,46 @@ suite('PositronRunApp', () => { 'Shell integration not enabled', ); - // Check that the expected command line was executed in the terminal i.e. the app was rerun with shell integration. - assert(executedCommandLine, 'No terminal shell execution started'); - assert.strictEqual(executedCommandLine, `${runtime.runtimePath} ${uri.fsPath}`, 'Unexpected command line executed'); + // Check that the expected text was sent to the terminal again. + assert(sendTextSpy, 'Terminal.sendText spy not created'); + sinon.assert.calledOnceWithExactly(sendTextSpy, `${runtime.runtimePath} ${uri.fsPath}`, true); + }); + + test('debugApplication: shell integration supported', async () => { + // Debug the test application. + await runAppApi.debugApplication(debugAppOptions); + + // Check that the expected URL was previewed. + sinon.assert.calledOnceWithMatch(previewUrlStub, localhostUriMatch); + }); + + test('debugApplication: shell integration disabled, user enables and reruns', async () => { + // Disable shell integration. + await vscode.workspace.getConfiguration('terminal.integrated.shellIntegration').update('enabled', false); + + // Stub `vscode.window.showInformationMessage` to simulate the user: + // 1. Enabling shell integration. + // 2. Rerunning the app. + const showInformationMessageStub = sinon.stub(vscode.window, 'showInformationMessage'); + showInformationMessageStub.onFirstCall().resolves('Enable Shell Integration' as any); + showInformationMessageStub.onSecondCall().resolves('Rerun Application' as any); + + // Stub positron.window.previewUrl and create a promise that resolves when its called with + // the expected URL. + const didPreviewExpectedUrlPromise = new Promise(resolve => { + previewUrlStub.withArgs(localhostUriMatch).callsFake(() => { + resolve(true); + }); + }); + + // Run the debug application. + await runAppApi.debugApplication(debugAppOptions); - // Check that the viewer pane was cleared again, and the expected URL was previewed. - sinon.assert.calledThrice(previewUrlStub); - sinon.assert.calledWith(previewUrlStub.getCall(1), vscode.Uri.parse('about:blank')); - sinon.assert.calledWith(previewUrlStub.getCall(2), localhostUriMatch); + // Wait for the expected URL to be previewed. + const didPreviewExpectedUrl = await raceTimeout(didPreviewExpectedUrlPromise, 10_000); + assert(didPreviewExpectedUrl, 'Timed out waiting for URL preview'); + + // Check that shell integration was enabled. + vscode.workspace.getConfiguration('terminal.integrated.shellIntegration').get('enabled', false); }); }); diff --git a/extensions/positron-run-app/src/utils.ts b/extensions/positron-run-app/src/utils.ts index 108d993f6c7..a6b75c91513 100644 --- a/extensions/positron-run-app/src/utils.ts +++ b/extensions/positron-run-app/src/utils.ts @@ -3,7 +3,8 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -// Copied from src/vs/base/common/async.ts +/* Utilities copied from ../../../src/vs/base/common/async.ts */ + export function raceTimeout(promise: Promise, timeout: number, onTimeout?: () => void): Promise { let promiseResolve: ((value: T | undefined) => void) | undefined = undefined; @@ -18,12 +19,10 @@ export function raceTimeout(promise: Promise, timeout: number, onTimeout?: ]); } -// Copied from src/vs/base/common/async.ts export interface ITask { (): T; } -// Copied from src/vs/base/common/async.ts export class SequencerByKey { private promiseMap = new Map>(); diff --git a/extensions/positron-run-app/test-workspace/app.js b/extensions/positron-run-app/test-workspace/app.js new file mode 100644 index 00000000000..0f6f9f18c5d --- /dev/null +++ b/extensions/positron-run-app/test-workspace/app.js @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +console.log('Server started: http://localhost:8000'); diff --git a/extensions/positron-run-app/test-workspace/app.sh b/extensions/positron-run-app/test-workspace/app.sh deleted file mode 100644 index a1733990266..00000000000 --- a/extensions/positron-run-app/test-workspace/app.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/usr/bin/env bash -echo 'Server started: http://localhost:8000' diff --git a/scripts/test-integration-pr.sh b/scripts/test-integration-pr.sh index b335b8aed27..2a36740ea9b 100755 --- a/scripts/test-integration-pr.sh +++ b/scripts/test-integration-pr.sh @@ -58,12 +58,6 @@ echo yarn test-extension -l positron-connections kill_app -echo -echo "### Positron Run App tests" -echo -yarn test-extension -l positron-run-app -kill_app - # Cleanup rm -rf $VSCODEUSERDATADIR