diff --git a/.vscode/launch.json b/.vscode/launch.json index 0b3fee7b..08fec254 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -3,40 +3,35 @@ // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 { - "version": "0.2.0", - "configurations": [ - { - "name": "Run Extension", - "type": "extensionHost", - "request": "launch", - "args": [ - "--extensionDevelopmentPath=${workspaceFolder}" - ], - "outFiles": [ - "${workspaceFolder}/dist/**/*.js", - "!${workspaceFolder}/**/node_modules**/*" - ], - "preLaunchTask": "npm: watch", - "presentation": { - "hidden": false, - "group": "", - "order": 2 - } - }, - { - "name": "Unit Tests", - "type": "extensionHost", - "request": "launch", - "runtimeExecutable": "${execPath}", - "args": [ - "./out/test/**/*.unit.test.js", - "--extensionDevelopmentPath=${workspaceFolder}", - "--extensionTestsPath=${workspaceFolder}/out/test/unittest/index" - ], - "outFiles": [ - "${workspaceFolder}/out/**/*.js", - ], - "preLaunchTask": "tasks: watch-tests" - }, - ] -} \ No newline at end of file + "version": "0.2.0", + "configurations": [ + { + "name": "Run Extension", + "type": "extensionHost", + "request": "launch", + "args": ["--extensionDevelopmentPath=${workspaceFolder}"], + "outFiles": ["${workspaceFolder}/dist/**/*.js", "!${workspaceFolder}/**/node_modules**/*"], + "preLaunchTask": "npm: watch", + "presentation": { + "hidden": false, + "group": "", + "order": 2 + } + }, + { + "name": "Unit Tests", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "./out/test/**/*.unit.test.js", + "--extensionDevelopmentPath=${workspaceFolder}", + "--extensionTestsPath=${workspaceFolder}/out/test/unittest/index", + //"--grep", "", + "--timeout=300000" + ], + "outFiles": ["${workspaceFolder}/out/**/*.js"], + "preLaunchTask": "tasks: watch-tests" + } + ] +} diff --git a/bundled/scripts/noConfigScripts/debugpy b/bundled/scripts/noConfigScripts/debugpy new file mode 100755 index 00000000..7b440d2c --- /dev/null +++ b/bundled/scripts/noConfigScripts/debugpy @@ -0,0 +1,3 @@ +#! /bin/bash +# Bash script +python $BUNDLED_DEBUGPY_PATH --listen 0 --wait-for-client $@ diff --git a/bundled/scripts/noConfigScripts/debugpy.bat b/bundled/scripts/noConfigScripts/debugpy.bat new file mode 100755 index 00000000..25e3bf5f --- /dev/null +++ b/bundled/scripts/noConfigScripts/debugpy.bat @@ -0,0 +1,3 @@ +@echo off +:: Bat script +python %BUNDLED_DEBUGPY_PATH% --listen 0 --wait-for-client %* diff --git a/bundled/scripts/noConfigScripts/debugpy.fish b/bundled/scripts/noConfigScripts/debugpy.fish new file mode 100755 index 00000000..7319d863 --- /dev/null +++ b/bundled/scripts/noConfigScripts/debugpy.fish @@ -0,0 +1,2 @@ +# Fish script +python $BUNDLED_DEBUGPY_PATH --listen 0 --wait-for-client $argv diff --git a/bundled/scripts/noConfigScripts/debugpy.ps1 b/bundled/scripts/noConfigScripts/debugpy.ps1 new file mode 100755 index 00000000..ebffa418 --- /dev/null +++ b/bundled/scripts/noConfigScripts/debugpy.ps1 @@ -0,0 +1,2 @@ +# PowerShell script +python $env:BUNDLED_DEBUGPY_PATH --listen 0 --wait-for-client $args diff --git a/src/extension/extensionInit.ts b/src/extension/extensionInit.ts index 976e3405..c4f52b6c 100644 --- a/src/extension/extensionInit.ts +++ b/src/extension/extensionInit.ts @@ -53,6 +53,7 @@ import { IExtensionApi } from './apiTypes'; import { registerHexDebugVisualizationTreeProvider } from './debugger/visualizers/inlineHexDecoder'; import { PythonInlineValueProvider } from './debugger/inlineValue/pythonInlineValueProvider'; import { traceLog } from './common/log/logging'; +import { registerNoConfigDebug } from './noConfigDebugInit'; export async function registerDebugger(context: IExtensionContext): Promise { const childProcessAttachService = new ChildProcessAttachService(); @@ -247,5 +248,9 @@ export async function registerDebugger(context: IExtensionContext): Promise { + const collection = envVarCollection; + + // create a temp directory for the noConfigDebugAdapterEndpoints + // file path format: tempDir/noConfigDebugAdapterEndpoints-/debuggerAdapterEndpoint.txt + const randomSuffix = crypto.randomBytes(10).toString('hex'); + const tempDirName = `noConfigDebugAdapterEndpoints-${randomSuffix}`; + let tempDirPath = path.join(os.tmpdir(), tempDirName); + try { + traceLog('Attempting to use temp directory for noConfigDebugAdapterEndpoints, dir name:', tempDirName); + await fs.promises.mkdir(tempDirPath, { recursive: true }); + } catch (error) { + // Handle the error when accessing the temp directory + traceError('Error accessing temp directory:', error, ' Attempt to use extension root dir instead'); + // Make new temp directory in extension root dird + tempDirPath = path.join(extPath, '.temp'); + await fs.promises.mkdir(tempDirPath, { recursive: true }); + } + const tempFilePath = path.join(tempDirPath, 'debuggerAdapterEndpoint.txt'); + + // Add env vars for DEBUGPY_ADAPTER_ENDPOINTS, BUNDLED_DEBUGPY_PATH, and PATH + collection.replace('DEBUGPY_ADAPTER_ENDPOINTS', tempFilePath); + + const noConfigScriptsDir = path.join(extPath, 'bundled', 'scripts', 'noConfigScripts'); + const pathSeparator = process.platform === 'win32' ? ';' : ':'; + collection.append('PATH', `${pathSeparator}${noConfigScriptsDir}`); + + const bundledDebugPath = path.join(extPath, 'bundled', 'libs', 'debugpy'); + collection.replace('BUNDLED_DEBUGPY_PATH', bundledDebugPath); + + // create file system watcher for the debuggerAdapterEndpointFolder for when the communication port is written + const fileSystemWatcher = createFileSystemWatcher(new RelativePattern(tempDirPath, '**/*')); + const fileCreationEvent = fileSystemWatcher.onDidCreate(async (uri) => { + const filePath = uri.fsPath; + fs.readFile(filePath, (err, data) => { + const dataParse = data.toString(); + if (err) { + traceError(`Error reading debuggerAdapterEndpoint.txt file: ${err}`); + return; + } + try { + // parse the client port + const jsonData = JSON.parse(dataParse); + const clientPort = jsonData.client?.port; + traceVerbose(`Parsed client port: ${clientPort}`); + + const options: DebugSessionOptions = { + noDebug: false, + }; + + // start debug session with the client port + debugStartDebugging( + undefined, + { + type: 'python', + request: 'attach', + name: 'Attach to Python', + connect: { + port: clientPort, + host: 'localhost', + }, + }, + options, + ).then( + (started) => { + if (started) { + traceVerbose('Successfully started debug session'); + } else { + traceError('Error starting debug session, session not started.'); + } + }, + (error) => { + traceError(`Error starting debug session: ${error}`); + }, + ); + } catch (parseErr) { + traceError(`Error parsing JSON: ${parseErr}`); + } + }); + JSON.parse; + }); + return Promise.resolve( + new Disposable(() => { + fileSystemWatcher.dispose(); + fileCreationEvent.dispose(); + }), + ); +} diff --git a/src/extension/utils.ts b/src/extension/utils.ts new file mode 100644 index 00000000..4e64b602 --- /dev/null +++ b/src/extension/utils.ts @@ -0,0 +1,21 @@ +import { + workspace, + debug, + WorkspaceFolder, + DebugConfiguration, + DebugSession, + DebugSessionOptions, + FileSystemWatcher, +} from 'vscode'; + +export function createFileSystemWatcher(args: any): FileSystemWatcher { + return workspace.createFileSystemWatcher(args); +} + +export async function debugStartDebugging( + folder: WorkspaceFolder | undefined, + nameOrConfiguration: string | DebugConfiguration, + parentSessionOrOptions?: DebugSession | DebugSessionOptions, +): Promise { + return debug.startDebugging(folder, nameOrConfiguration, parentSessionOrOptions); +} diff --git a/src/test/unittest/noConfigDebugInit.unit.test.ts b/src/test/unittest/noConfigDebugInit.unit.test.ts new file mode 100644 index 00000000..7ade02a7 --- /dev/null +++ b/src/test/unittest/noConfigDebugInit.unit.test.ts @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as path from 'path'; +import { IExtensionContext } from '../../extension/common/types'; +import { registerNoConfigDebug as registerNoConfigDebug } from '../../extension/noConfigDebugInit'; +import * as TypeMoq from 'typemoq'; +import * as sinon from 'sinon'; +import { DebugConfiguration, DebugSessionOptions, RelativePattern, Uri } from 'vscode'; +import * as utils from '../../extension/utils'; +import { assert } from 'console'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as crypto from 'crypto'; + +suite('setup for no-config debug scenario', function () { + let envVarCollectionReplaceStub: sinon.SinonStub; + let envVarCollectionAppendStub: sinon.SinonStub; + let context: TypeMoq.IMock; + let noConfigScriptsDir: string; + let bundledDebugPath: string; + let DEBUGPY_ADAPTER_ENDPOINTS = 'DEBUGPY_ADAPTER_ENDPOINTS'; + let BUNDLED_DEBUGPY_PATH = 'BUNDLED_DEBUGPY_PATH'; + let tempDirPath: string; + + const testDataDir = path.join(__dirname, 'testData'); + const testFilePath = path.join(testDataDir, 'debuggerAdapterEndpoint.txt'); + setup(() => { + try { + context = TypeMoq.Mock.ofType(); + + const randomSuffix = '1234567899'; + const tempDirName = `noConfigDebugAdapterEndpoints-${randomSuffix}`; + tempDirPath = path.join(os.tmpdir(), tempDirName); + context.setup((c) => (c as any).extensionPath).returns(() => 'fake/extension/path'); + context.setup((c) => c.subscriptions).returns(() => []); + noConfigScriptsDir = path.join(context.object.extensionPath, 'bundled/scripts/noConfigScripts'); + bundledDebugPath = path.join(context.object.extensionPath, 'bundled/libs/debugpy'); + + // Stub crypto.randomBytes with proper typing + let randomBytesStub = sinon.stub(crypto, 'randomBytes'); + // Provide a valid Buffer object + randomBytesStub.callsFake((_size: number) => Buffer.from('1234567899', 'hex')); + } catch (error) { + console.error('Error in setup:', error); + } + }); + teardown(() => { + sinon.restore(); + }); + + test('should add environment variables for DEBUGPY_ADAPTER_ENDPOINTS, BUNDLED_DEBUGPY_PATH, and PATH', async () => { + const environmentVariableCollectionMock = TypeMoq.Mock.ofType(); + envVarCollectionReplaceStub = sinon.stub(); + envVarCollectionAppendStub = sinon.stub(); + + // set up the environment variable collection mock including asserts for the key, value pairs + environmentVariableCollectionMock + .setup((x) => x.replace(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .callback((key, value) => { + if (key === DEBUGPY_ADAPTER_ENDPOINTS) { + assert(value.includes('noConfigDebugAdapterEndpoints-1234567899')); + } else if (key === BUNDLED_DEBUGPY_PATH) { + assert(value === bundledDebugPath); + } + }) + .returns(envVarCollectionReplaceStub); + environmentVariableCollectionMock + .setup((x) => x.append(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .callback((key, value) => { + if (key === 'PATH') { + assert(value === `:${noConfigScriptsDir}`); + } + }) + .returns(envVarCollectionAppendStub); + + context.setup((c) => c.environmentVariableCollection).returns(() => environmentVariableCollectionMock.object); + + setupFileSystemWatchers(); + + // run init for no config debug + await registerNoConfigDebug(context.object.environmentVariableCollection, context.object.extensionPath); + + // assert that functions called right number of times + sinon.assert.calledTwice(envVarCollectionReplaceStub); + sinon.assert.calledOnce(envVarCollectionAppendStub); + }); + + test('should create file system watcher for debuggerAdapterEndpointFolder', async () => { + // Arrange + const environmentVariableCollectionMock = TypeMoq.Mock.ofType(); + context.setup((c) => c.environmentVariableCollection).returns(() => environmentVariableCollectionMock.object); + let createFileSystemWatcherFunct = setupFileSystemWatchers(); + + // Act + await registerNoConfigDebug(context.object.environmentVariableCollection, context.object.extensionPath); + + // Assert + sinon.assert.calledOnce(createFileSystemWatcherFunct); + const expectedPattern = new RelativePattern(tempDirPath, '**/*'); + sinon.assert.calledWith(createFileSystemWatcherFunct, expectedPattern); + }); + + test('should start debug session with client port', async () => { + // Arrange + const environmentVariableCollectionMock = TypeMoq.Mock.ofType(); + context.setup((c) => c.environmentVariableCollection).returns(() => environmentVariableCollectionMock.object); + + // mock file sys watcher to give back test file + let createFileSystemWatcherFunct: sinon.SinonStub; + createFileSystemWatcherFunct = sinon.stub(utils, 'createFileSystemWatcher'); + createFileSystemWatcherFunct.callsFake(() => { + return { + onDidCreate: (callback: (arg0: Uri) => void) => { + callback(Uri.parse(testFilePath)); + }, + }; + }); + + // create stub of fs.readFile function + sinon.stub(fs, 'readFile').callsFake((_path: any, callback: (arg0: null, arg1: Buffer) => void) => { + console.log('reading file'); + callback(null, Buffer.from(JSON.stringify({ client: { port: 5678 } }))); + }); + + const debugStub = sinon.stub(utils, 'debugStartDebugging').resolves(true); + + // Act + await registerNoConfigDebug(context.object.environmentVariableCollection, context.object.extensionPath); + + // Assert + sinon.assert.calledOnce(debugStub); + const expectedConfig: DebugConfiguration = { + type: 'python', + request: 'attach', + name: 'Attach to Python', + connect: { + port: 5678, + host: 'localhost', + }, + }; + const optionsExpected: DebugSessionOptions = { + noDebug: false, + }; + const actualConfig = debugStub.getCall(0).args[1]; + const actualOptions = debugStub.getCall(0).args[2]; + + if (JSON.stringify(actualConfig) !== JSON.stringify(expectedConfig)) { + console.log('Config diff:', { + expected: expectedConfig, + actual: actualConfig, + }); + } + + if (JSON.stringify(actualOptions) !== JSON.stringify(optionsExpected)) { + console.log('Options diff:', { + expected: optionsExpected, + actual: actualOptions, + }); + } + + sinon.assert.calledWith(debugStub, undefined, expectedConfig, optionsExpected); + }); +}); + +function setupFileSystemWatchers(): sinon.SinonStub { + // create stub of createFileSystemWatcher function that will return a fake watcher with a callback + let createFileSystemWatcherFunct: sinon.SinonStub; + createFileSystemWatcherFunct = sinon.stub(utils, 'createFileSystemWatcher'); + createFileSystemWatcherFunct.callsFake(() => { + return { + onDidCreate: (callback: (arg0: Uri) => void) => { + callback(Uri.parse('fake/debuggerAdapterEndpoint.txt')); + }, + }; + }); + // create stub of fs.readFile function + sinon.stub(fs, 'readFile').callsFake( + (TypeMoq.It.isAny(), + TypeMoq.It.isAny(), + (err, data) => { + console.log(err, data); + }), + ); + return createFileSystemWatcherFunct; +}