Skip to content

Configless debug #557

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jan 17, 2025
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,13 @@
"args": [
"./out/test/**/*.unit.test.js",
"--extensionDevelopmentPath=${workspaceFolder}",
"--extensionTestsPath=${workspaceFolder}/out/test/unittest/index"
"--extensionTestsPath=${workspaceFolder}/out/test/unittest/index",
//"--grep", "<suite name>",
"--timeout=300000"
],
"outFiles": [
"${workspaceFolder}/out/**/*.js",
],
"preLaunchTask": "tasks: watch-tests"
},
"preLaunchTask": "tasks: watch-tests" },
]
}
4 changes: 4 additions & 0 deletions bundled/scripts/noConfigScripts/debugpy
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#! /bin/bash
# Bash script
python $BUNDLED_DEBUGPY_PATH --listen 0 --wait-for-client $@
echo "Executed: python $BUNDLED_DEBUGPY_PATH --listen 0 --wait-for-client $@"
3 changes: 3 additions & 0 deletions bundled/scripts/noConfigScripts/debugpy.bat
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@echo off
:: Bat script
python %BUNDLED_DEBUGPY_PATH% --listen 0 --wait-for-client %*
2 changes: 2 additions & 0 deletions bundled/scripts/noConfigScripts/debugpy.fish
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Fish script
python $BUNDLED_DEBUGPY_PATH --listen 0 --wait-for-client $argv
2 changes: 2 additions & 0 deletions bundled/scripts/noConfigScripts/debugpy.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# PowerShell script
python $env:BUNDLED_DEBUGPY_PATH --listen 0 --wait-for-client $args
5 changes: 5 additions & 0 deletions noConfigDebugAdapterEndpoints/info.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License.

This folder is necessary as it is the location where the debug adapter
will create the debuggerAdapterEndpoint.txt to communicate the endpoint.
3 changes: 3 additions & 0 deletions src/extension/extensionInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IExtensionApi> {
const childProcessAttachService = new ChildProcessAttachService();
Expand Down Expand Up @@ -247,5 +248,7 @@ export async function registerDebugger(context: IExtensionContext): Promise<IExt
window.activeTextEditor?.document.languageId === 'python',
);

registerNoConfigDebug(context);

return buildApi();
}
89 changes: 89 additions & 0 deletions src/extension/noConfigDebugInit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import * as fs from 'fs';
import * as path from 'path';
import { IExtensionContext } from './common/types';
import { DebugSessionOptions, RelativePattern } from 'vscode';
import { createFileSystemWatcher, debugStartDebugging } from './utils';
import { traceError, traceVerbose } from './common/log/logging';

/**
* Registers the configuration-less debugging setup for the extension.
*
* This function sets up environment variables and a file system watcher to
* facilitate debugging without requiring a pre-configured launch.json file.
*
* @param context - The extension context which provides access to the environment variable collection and subscriptions.
*
* Environment Variables:
* - `DEBUGPY_ADAPTER_ENDPOINTS`: Path to the file containing the debugger adapter endpoint.
* - `BUNDLED_DEBUGPY_PATH`: Path to the bundled debugpy library.
* - `PATH`: Appends the path to the noConfigScripts directory.
*/
export async function registerNoConfigDebug(context: IExtensionContext): Promise<void> {
const collection = context.environmentVariableCollection;

// Add env vars for DEBUGPY_ADAPTER_ENDPOINTS, BUNDLED_DEBUGPY_PATH, and PATH
const debugAdapterEndpointDir = path.join(context.extensionPath, 'noConfigDebugAdapterEndpoints');
const debuggerAdapterEndpointPath = path.join(debugAdapterEndpointDir, 'debuggerAdapterEndpoint.txt');
collection.replace('DEBUGPY_ADAPTER_ENDPOINTS', debuggerAdapterEndpointPath);

const noConfigScriptsDir = path.join(context.extensionPath, 'bundled', 'scripts', 'noConfigScripts');
const pathSeparator = process.platform === 'win32' ? ';' : ':';
collection.append('PATH', `${pathSeparator}${noConfigScriptsDir}`);

const bundledDebugPath = path.join(context.extensionPath, 'bundled', 'libs', 'debugpy');
collection.replace('BUNDLED_DEBUGPY_PATH', bundledDebugPath);

// create file system watcher for the debuggerAdapterEndpointFolder for when the communication port is written
context.subscriptions.push(
createFileSystemWatcher(new RelativePattern(debugAdapterEndpointDir, '**/*')).onDidCreate((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',
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;
}),
);
}
21 changes: 21 additions & 0 deletions src/extension/utils.ts
Original file line number Diff line number Diff line change
@@ -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 function debugStartDebugging(
folder: WorkspaceFolder | undefined,
nameOrConfiguration: string | DebugConfiguration,
parentSessionOrOptions?: DebugSession | DebugSessionOptions,
): Thenable<boolean> {
return debug.startDebugging(folder, nameOrConfiguration, parentSessionOrOptions);
}
185 changes: 185 additions & 0 deletions src/test/unittest/noConfigDebugInit.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// 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';

suite('setup for no-config debug scenario', function () {
let envVarCollectionReplaceStub: sinon.SinonStub;
let envVarCollectionAppendStub: sinon.SinonStub;
let context: TypeMoq.IMock<IExtensionContext>;
let debugAdapterEndpointDir: string;
let debuggerAdapterEndpointPath: string;
let noConfigScriptsDir: string;
let bundledDebugPath: string;
let DEBUGPY_ADAPTER_ENDPOINTS = 'DEBUGPY_ADAPTER_ENDPOINTS';
let BUNDLED_DEBUGPY_PATH = 'BUNDLED_DEBUGPY_PATH';

const testDataDir = path.join(__dirname, 'testData');
const testFilePath = path.join(testDataDir, 'debuggerAdapterEndpoint.txt');
suiteSetup(() => {
// create file called testData/debuggerAdapterEndpoint.txt
if (!fs.existsSync(testDataDir)) {
fs.mkdirSync(testDataDir);
}
fs.writeFileSync(testFilePath, JSON.stringify({ client: { port: 5678 } }));
});
setup(() => {
context = TypeMoq.Mock.ofType<IExtensionContext>();

context.setup((c) => (c as any).extensionPath).returns(() => 'fake/extension/path');
context.setup((c) => c.subscriptions).returns(() => []);
debugAdapterEndpointDir = path.join(context.object.extensionPath, 'noConfigDebugAdapterEndpoints');
debuggerAdapterEndpointPath = path.join(debugAdapterEndpointDir, 'debuggerAdapterEndpoint.txt');
noConfigScriptsDir = path.join(context.object.extensionPath, 'bundled/scripts/noConfigScripts');
bundledDebugPath = path.join(context.object.extensionPath, 'bundled/libs/debugpy');
});
teardown(() => {
sinon.restore();
});
suiteTeardown(() => {
if (fs.existsSync(testDataDir)) {
fs.rmSync(testDataDir, { recursive: true, force: true });
}
});

test('should add environment variables for DEBUGPY_ADAPTER_ENDPOINTS, BUNDLED_DEBUGPY_PATH, and PATH', async () => {
const environmentVariableCollectionMock = TypeMoq.Mock.ofType<any>();
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 === debuggerAdapterEndpointPath);
} 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);

// 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<any>();
context.setup((c) => c.environmentVariableCollection).returns(() => environmentVariableCollectionMock.object);
let createFileSystemWatcherFunct = setupFileSystemWatchers();

// Act
await registerNoConfigDebug(context.object);

// Assert
sinon.assert.calledOnce(createFileSystemWatcherFunct);
const expectedPattern = new RelativePattern(debugAdapterEndpointDir, '**/*');
sinon.assert.calledWith(createFileSystemWatcherFunct, expectedPattern);
});

test('should start debug session with client port', async () => {
// Arrange
const environmentVariableCollectionMock = TypeMoq.Mock.ofType<any>();
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);

// Assert
sinon.assert.calledOnce(debugStub);
const expectedConfig: DebugConfiguration = {
type: 'python',
request: 'attach',
name: 'Attach to Python',
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;
}
Loading