From 1dd1817c1a3d31d5ab8584567031641f5da76e1b Mon Sep 17 00:00:00 2001 From: bschnurr Date: Fri, 15 May 2026 17:37:24 -0700 Subject: [PATCH] Normalize debugger path aliases and ship shell launcher Normalize duplicate Windows Path aliases during debug env construction, add the missing debugpy.sh launcher, harden launcher quoting and LF handling, add regression tests, and add reviewCritic guidance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitattributes | 3 + .github/agents/reviewCritic.agent.md | 24 + bundled/scripts/noConfigScripts/debugpy | 4 +- bundled/scripts/noConfigScripts/debugpy.bat | 8 +- bundled/scripts/noConfigScripts/debugpy.fish | 4 +- bundled/scripts/noConfigScripts/debugpy.ps1 | 16 +- bundled/scripts/noConfigScripts/debugpy.sh | 4 + src/extension/common/variables/environment.ts | 500 +++++++++-------- .../configuration/resolvers/helper.ts | 164 +++--- .../resolvers/helper.unit.test.ts | 187 ++++--- .../unittest/noConfigDebugInit.unit.test.ts | 524 +++++++++--------- 11 files changed, 792 insertions(+), 646 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/agents/reviewCritic.agent.md create mode 100644 bundled/scripts/noConfigScripts/debugpy.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..7aeafa88 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +bundled/scripts/noConfigScripts/debugpy text eol=lf +bundled/scripts/noConfigScripts/debugpy.sh text eol=lf +bundled/scripts/noConfigScripts/debugpy.fish text eol=lf diff --git a/.github/agents/reviewCritic.agent.md b/.github/agents/reviewCritic.agent.md new file mode 100644 index 00000000..c9cdf864 --- /dev/null +++ b/.github/agents/reviewCritic.agent.md @@ -0,0 +1,24 @@ +--- +description: "Review critic for vscode-python-debugger. Use when: reviewing a fix, checking regressions, verifying test coverage, or pressure-testing a PR before merge." +tools: [read/readFile, edit/editFiles, execute/runInTerminal, execute/getTerminalOutput, execute/sendToTerminal, search/textSearch, vscode/askQuestions, todo] +--- + +You are a high-signal review critic for **vscode-python-debugger**. + +Focus on correctness, regressions, environment mutation, debug configuration behavior, cross-platform shell integration, and missing tests. Ignore style unless it hides a real bug. + +## Review workflow + +1. Start with `git status --short` and `git diff --stat`, then read every changed file in scope. +2. Verify each behavior change has a targeted test, or explain exactly why a test is not practical. +3. Prioritize: + - PATH / Path normalization and environment merging + - no-config debugging bootstrap scripts across shells + - debug configuration resolution and workspace behavior + - Windows/macOS/Linux differences that could break launch or attach +4. Report only actionable findings with: + - severity + - affected file or scope + - why it matters + - the missing fix or missing test +5. If the diff looks sound, say so explicitly and cite the tests that support that conclusion. diff --git a/bundled/scripts/noConfigScripts/debugpy b/bundled/scripts/noConfigScripts/debugpy index def62ec5..94210f89 100755 --- a/bundled/scripts/noConfigScripts/debugpy +++ b/bundled/scripts/noConfigScripts/debugpy @@ -1,4 +1,4 @@ #! /bin/bash # Bash script -export DEBUGPY_ADAPTER_ENDPOINTS=$VSCODE_DEBUGPY_ADAPTER_ENDPOINTS -python3 $BUNDLED_DEBUGPY_PATH --listen 0 --wait-for-client $@ +export DEBUGPY_ADAPTER_ENDPOINTS="$VSCODE_DEBUGPY_ADAPTER_ENDPOINTS" +python3 "$BUNDLED_DEBUGPY_PATH" --listen 0 --wait-for-client "$@" diff --git a/bundled/scripts/noConfigScripts/debugpy.bat b/bundled/scripts/noConfigScripts/debugpy.bat index 2450cf6a..855d03ba 100755 --- a/bundled/scripts/noConfigScripts/debugpy.bat +++ b/bundled/scripts/noConfigScripts/debugpy.bat @@ -1,4 +1,4 @@ -@echo off -:: Bat script -set DEBUGPY_ADAPTER_ENDPOINTS=%VSCODE_DEBUGPY_ADAPTER_ENDPOINTS% -python %BUNDLED_DEBUGPY_PATH% --listen 0 --wait-for-client %* +@echo off +:: Bat script +set "DEBUGPY_ADAPTER_ENDPOINTS=%VSCODE_DEBUGPY_ADAPTER_ENDPOINTS%" +python "%BUNDLED_DEBUGPY_PATH%" --listen 0 --wait-for-client %* diff --git a/bundled/scripts/noConfigScripts/debugpy.fish b/bundled/scripts/noConfigScripts/debugpy.fish index 624f7202..8aaeb56f 100755 --- a/bundled/scripts/noConfigScripts/debugpy.fish +++ b/bundled/scripts/noConfigScripts/debugpy.fish @@ -1,3 +1,3 @@ # Fish script -set -x DEBUGPY_ADAPTER_ENDPOINTS $VSCODE_DEBUGPY_ADAPTER_ENDPOINTS -python3 $BUNDLED_DEBUGPY_PATH --listen 0 --wait-for-client $argv +set -x DEBUGPY_ADAPTER_ENDPOINTS "$VSCODE_DEBUGPY_ADAPTER_ENDPOINTS" +python3 "$BUNDLED_DEBUGPY_PATH" --listen 0 --wait-for-client $argv diff --git a/bundled/scripts/noConfigScripts/debugpy.ps1 b/bundled/scripts/noConfigScripts/debugpy.ps1 index 4b2ff85a..1f2e85da 100755 --- a/bundled/scripts/noConfigScripts/debugpy.ps1 +++ b/bundled/scripts/noConfigScripts/debugpy.ps1 @@ -1,8 +1,8 @@ -# PowerShell script -$env:DEBUGPY_ADAPTER_ENDPOINTS = $env:VSCODE_DEBUGPY_ADAPTER_ENDPOINTS -$os = [System.Environment]::OSVersion.Platform -if ($os -eq [System.PlatformID]::Win32NT) { - python $env:BUNDLED_DEBUGPY_PATH --listen 0 --wait-for-client $args -} else { - python3 $env:BUNDLED_DEBUGPY_PATH --listen 0 --wait-for-client $args -} \ No newline at end of file +# PowerShell script +$env:DEBUGPY_ADAPTER_ENDPOINTS = $env:VSCODE_DEBUGPY_ADAPTER_ENDPOINTS +$os = [System.Environment]::OSVersion.Platform +if ($os -eq [System.PlatformID]::Win32NT) { + python "$env:BUNDLED_DEBUGPY_PATH" --listen 0 --wait-for-client $args +} else { + python3 "$env:BUNDLED_DEBUGPY_PATH" --listen 0 --wait-for-client $args +} diff --git a/bundled/scripts/noConfigScripts/debugpy.sh b/bundled/scripts/noConfigScripts/debugpy.sh new file mode 100644 index 00000000..94210f89 --- /dev/null +++ b/bundled/scripts/noConfigScripts/debugpy.sh @@ -0,0 +1,4 @@ +#! /bin/bash +# Bash script +export DEBUGPY_ADAPTER_ENDPOINTS="$VSCODE_DEBUGPY_ADAPTER_ENDPOINTS" +python3 "$BUNDLED_DEBUGPY_PATH" --listen 0 --wait-for-client "$@" diff --git a/src/extension/common/variables/environment.ts b/src/extension/common/variables/environment.ts index ce67bb8b..70a831cc 100644 --- a/src/extension/common/variables/environment.ts +++ b/src/extension/common/variables/environment.ts @@ -1,231 +1,269 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -import * as fs from 'fs-extra'; -import * as path from 'path'; -import { sendTelemetryEvent } from '../../telemetry'; -import { EventName } from '../../telemetry/constants'; -import { traceError } from '../log/logging'; -import { getSearchPathEnvVarNames } from '../utils/exec'; -import { EnvironmentVariables } from './types'; - -export async function parseFile( - filePath?: string, - baseVars?: EnvironmentVariables, -): Promise { - if (!filePath || !(await fs.pathExists(filePath))) { - return; - } - const contents = await fs.readFile(filePath).catch((ex) => { - traceError('Custom .env is likely not pointing to a valid file', ex); - return undefined; - }); - if (!contents) { - return; - } - return parseEnvFile(contents, baseVars); -} - -export function parseFileSync(filePath?: string, baseVars?: EnvironmentVariables): EnvironmentVariables | undefined { - if (!filePath || !fs.pathExistsSync(filePath)) { - return; - } - let contents: string | undefined; - try { - contents = fs.readFileSync(filePath, { encoding: 'utf8' }); - } catch (ex) { - traceError('Custom .env is likely not pointing to a valid file', ex); - } - if (!contents) { - return; - } - return parseEnvFile(contents, baseVars); -} - -export function mergeVariables( - source: EnvironmentVariables, - target: EnvironmentVariables, - options?: { overwrite?: boolean }, -) { - if (!target) { - return; - } - const settingsNotToMerge = ['PYTHONPATH', getSearchPathEnvVarNames()[0]]; - Object.keys(source).forEach((setting) => { - if (settingsNotToMerge.indexOf(setting) >= 0) { - return; - } - if (target[setting] === undefined || options?.overwrite) { - target[setting] = source[setting]; - } - }); -} - -export function appendPythonPath(vars: EnvironmentVariables, ...pythonPaths: string[]) { - return appendPaths(vars, 'PYTHONPATH', ...pythonPaths); -} - -export function appendPath(vars: EnvironmentVariables, ...paths: string[]) { - return appendPaths(vars, getSearchPathEnvVarNames()[0], ...paths); -} - -export function appendPaths( - vars: EnvironmentVariables, - variableName: 'PATH' | 'Path' | 'PYTHONPATH', - ...pathsToAppend: string[] -) { - const valueToAppend = pathsToAppend - .filter((item) => typeof item === 'string' && item.trim().length > 0) - .map((item) => item.trim()) - .join(path.delimiter); - if (valueToAppend.length === 0) { - return vars; - } - - const variable = vars ? vars[variableName] : undefined; - if (variable && typeof variable === 'string' && variable.length > 0) { - vars[variableName] = variable + path.delimiter + valueToAppend; - } else { - vars[variableName] = valueToAppend; - } - return vars; -} - -export function parseEnvFile(lines: string | Buffer, baseVars?: EnvironmentVariables): EnvironmentVariables { - const globalVars = baseVars ? baseVars : {}; - const vars: EnvironmentVariables = {}; - const content = lines.toString(); - - // State machine to handle multiline quoted values - let currentLine = ''; - let inQuotes = false; - let quoteChar = ''; - let afterEquals = false; - - for (let i = 0; i < content.length; i++) { - const char = content[i]; - - // Track if we've seen an '=' sign (indicating we're in the value part) - if (char === '=' && !inQuotes) { - afterEquals = true; - currentLine += char; - continue; - } - - // Handle quote characters - need to check for proper escaping - if ((char === '"' || char === "'") && afterEquals) { - // Count consecutive backslashes before this quote - let numBackslashes = 0; - let j = i - 1; - while (j >= 0 && content[j] === '\\') { - numBackslashes++; - j--; - } - - // Quote is escaped if there's an odd number of backslashes before it - const isEscaped = numBackslashes % 2 === 1; - - if (!isEscaped) { - if (!inQuotes) { - // Starting a quoted section - inQuotes = true; - quoteChar = char; - } else if (char === quoteChar) { - // Ending a quoted section - inQuotes = false; - quoteChar = ''; - } - } - currentLine += char; - continue; - } - - // Handle newlines - if (char === '\n') { - if (inQuotes) { - // We're inside quotes, preserve the newline - currentLine += char; - } else { - // We're not in quotes, this is the end of a line - const [name, value] = parseEnvLine(currentLine); - if (name !== '') { - vars[name] = substituteEnvVars(value, vars, globalVars); - } - // Reset for next line - currentLine = ''; - afterEquals = false; - } - } else { - currentLine += char; - } - } - - // Handle the last line if there's no trailing newline - if (currentLine.trim() !== '') { - const [name, value] = parseEnvLine(currentLine); - if (name !== '') { - vars[name] = substituteEnvVars(value, vars, globalVars); - } - } - - return vars; -} - -function parseEnvLine(line: string): [string, string] { - // Most of the following is an adaptation of the dotenv code: - // https://github.com/motdotla/dotenv/blob/master/lib/main.js#L32 - // We don't use dotenv here because it loses ordering, which is - // significant for substitution. - // Modified to handle multiline values by using 's' flag so $ matches before newlines in multiline strings - const match = line.match(/^\s*(_*[a-zA-Z]\w*)\s*=\s*(.*?)?\s*$/s); - if (!match) { - return ['', '']; - } - - const name = match[1]; - let value = match[2]; - if (value && value !== '') { - if (value[0] === "'" && value[value.length - 1] === "'") { - value = value.substring(1, value.length - 1); - value = value.replace(/\\n/gm, '\n'); - } else if (value[0] === '"' && value[value.length - 1] === '"') { - value = value.substring(1, value.length - 1); - value = value.replace(/\\n/gm, '\n'); - } - } else { - value = ''; - } - - return [name, value]; -} - -const SUBST_REGEX = /\${([a-zA-Z]\w*)?([^}\w].*)?}/g; - -function substituteEnvVars( - value: string, - localVars: EnvironmentVariables, - globalVars: EnvironmentVariables, - missing = '', -): string { - // Substitution here is inspired a little by dotenv-expand: - // https://github.com/motdotla/dotenv-expand/blob/master/lib/main.js - - let invalid = false; - let replacement = value; - replacement = replacement.replace(SUBST_REGEX, (match, substName, bogus, offset, orig) => { - if (offset > 0 && orig[offset - 1] === '\\') { - return match; - } - if ((bogus && bogus !== '') || !substName || substName === '') { - invalid = true; - return match; - } - return localVars[substName] || globalVars[substName] || missing; - }); - if (!invalid && replacement !== value) { - value = replacement; - sendTelemetryEvent(EventName.ENVFILE_VARIABLE_SUBSTITUTION); - } - - return value.replace(/\\\$/g, '$'); -} +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as fs from 'fs-extra'; +import * as path from 'path'; +import { sendTelemetryEvent } from '../../telemetry'; +import { EventName } from '../../telemetry/constants'; +import { traceError } from '../log/logging'; +import { getSearchPathEnvVarNames } from '../utils/exec'; +import { EnvironmentVariables } from './types'; + +export async function parseFile( + filePath?: string, + baseVars?: EnvironmentVariables, +): Promise { + if (!filePath || !(await fs.pathExists(filePath))) { + return; + } + const contents = await fs.readFile(filePath).catch((ex) => { + traceError('Custom .env is likely not pointing to a valid file', ex); + return undefined; + }); + if (!contents) { + return; + } + return parseEnvFile(contents, baseVars); +} + +export function parseFileSync(filePath?: string, baseVars?: EnvironmentVariables): EnvironmentVariables | undefined { + if (!filePath || !fs.pathExistsSync(filePath)) { + return; + } + let contents: string | undefined; + try { + contents = fs.readFileSync(filePath, { encoding: 'utf8' }); + } catch (ex) { + traceError('Custom .env is likely not pointing to a valid file', ex); + } + if (!contents) { + return; + } + return parseEnvFile(contents, baseVars); +} + +export function mergeVariables( + source: EnvironmentVariables, + target: EnvironmentVariables, + options?: { overwrite?: boolean }, +) { + if (!target) { + return; + } + const settingsNotToMerge = ['PYTHONPATH', ...getSearchPathEnvVarNames()]; + Object.keys(source).forEach((setting) => { + if (settingsNotToMerge.indexOf(setting) >= 0) { + return; + } + if (target[setting] === undefined || options?.overwrite) { + target[setting] = source[setting]; + } + }); +} + +export function normalizeSearchPathEnvironmentVariable( + vars: EnvironmentVariables, + variableNames = getSearchPathEnvVarNames(), +) { + if (!vars || variableNames.length <= 1) { + return vars; + } + + const [preferredName, ...aliasNames] = variableNames; + const mergedPathEntries: string[] = []; + + for (const variableName of variableNames) { + const value = vars[variableName]; + if (typeof value !== 'string' || value.length === 0) { + continue; + } + + for (const entry of value.split(path.delimiter)) { + const trimmedEntry = entry.trim(); + if (!trimmedEntry || mergedPathEntries.includes(trimmedEntry)) { + continue; + } + mergedPathEntries.push(trimmedEntry); + } + } + + if (mergedPathEntries.length === 0) { + return vars; + } + + vars[preferredName] = mergedPathEntries.join(path.delimiter); + for (const aliasName of aliasNames) { + delete vars[aliasName]; + } + + return vars; +} + +export function appendPythonPath(vars: EnvironmentVariables, ...pythonPaths: string[]) { + return appendPaths(vars, 'PYTHONPATH', ...pythonPaths); +} + +export function appendPath(vars: EnvironmentVariables, ...paths: string[]) { + return appendPaths(vars, getSearchPathEnvVarNames()[0], ...paths); +} + +export function appendPaths( + vars: EnvironmentVariables, + variableName: 'PATH' | 'Path' | 'PYTHONPATH', + ...pathsToAppend: string[] +) { + const valueToAppend = pathsToAppend + .filter((item) => typeof item === 'string' && item.trim().length > 0) + .map((item) => item.trim()) + .join(path.delimiter); + if (valueToAppend.length === 0) { + return vars; + } + + const variable = vars ? vars[variableName] : undefined; + if (variable && typeof variable === 'string' && variable.length > 0) { + vars[variableName] = variable + path.delimiter + valueToAppend; + } else { + vars[variableName] = valueToAppend; + } + return vars; +} + +export function parseEnvFile(lines: string | Buffer, baseVars?: EnvironmentVariables): EnvironmentVariables { + const globalVars = baseVars ? baseVars : {}; + const vars: EnvironmentVariables = {}; + const content = lines.toString(); + + // State machine to handle multiline quoted values + let currentLine = ''; + let inQuotes = false; + let quoteChar = ''; + let afterEquals = false; + + for (let i = 0; i < content.length; i++) { + const char = content[i]; + + // Track if we've seen an '=' sign (indicating we're in the value part) + if (char === '=' && !inQuotes) { + afterEquals = true; + currentLine += char; + continue; + } + + // Handle quote characters - need to check for proper escaping + if ((char === '"' || char === "'") && afterEquals) { + // Count consecutive backslashes before this quote + let numBackslashes = 0; + let j = i - 1; + while (j >= 0 && content[j] === '\\') { + numBackslashes++; + j--; + } + + // Quote is escaped if there's an odd number of backslashes before it + const isEscaped = numBackslashes % 2 === 1; + + if (!isEscaped) { + if (!inQuotes) { + // Starting a quoted section + inQuotes = true; + quoteChar = char; + } else if (char === quoteChar) { + // Ending a quoted section + inQuotes = false; + quoteChar = ''; + } + } + currentLine += char; + continue; + } + + // Handle newlines + if (char === '\n') { + if (inQuotes) { + // We're inside quotes, preserve the newline + currentLine += char; + } else { + // We're not in quotes, this is the end of a line + const [name, value] = parseEnvLine(currentLine); + if (name !== '') { + vars[name] = substituteEnvVars(value, vars, globalVars); + } + // Reset for next line + currentLine = ''; + afterEquals = false; + } + } else { + currentLine += char; + } + } + + // Handle the last line if there's no trailing newline + if (currentLine.trim() !== '') { + const [name, value] = parseEnvLine(currentLine); + if (name !== '') { + vars[name] = substituteEnvVars(value, vars, globalVars); + } + } + + return vars; +} + +function parseEnvLine(line: string): [string, string] { + // Most of the following is an adaptation of the dotenv code: + // https://github.com/motdotla/dotenv/blob/master/lib/main.js#L32 + // We don't use dotenv here because it loses ordering, which is + // significant for substitution. + // Modified to handle multiline values by using 's' flag so $ matches before newlines in multiline strings + const match = line.match(/^\s*(_*[a-zA-Z]\w*)\s*=\s*(.*?)?\s*$/s); + if (!match) { + return ['', '']; + } + + const name = match[1]; + let value = match[2]; + if (value && value !== '') { + if (value[0] === "'" && value[value.length - 1] === "'") { + value = value.substring(1, value.length - 1); + value = value.replace(/\\n/gm, '\n'); + } else if (value[0] === '"' && value[value.length - 1] === '"') { + value = value.substring(1, value.length - 1); + value = value.replace(/\\n/gm, '\n'); + } + } else { + value = ''; + } + + return [name, value]; +} + +const SUBST_REGEX = /\${([a-zA-Z]\w*)?([^}\w].*)?}/g; + +function substituteEnvVars( + value: string, + localVars: EnvironmentVariables, + globalVars: EnvironmentVariables, + missing = '', +): string { + // Substitution here is inspired a little by dotenv-expand: + // https://github.com/motdotla/dotenv-expand/blob/master/lib/main.js + + let invalid = false; + let replacement = value; + replacement = replacement.replace(SUBST_REGEX, (match, substName, bogus, offset, orig) => { + if (offset > 0 && orig[offset - 1] === '\\') { + return match; + } + if ((bogus && bogus !== '') || !substName || substName === '') { + invalid = true; + return match; + } + return localVars[substName] || globalVars[substName] || missing; + }); + if (!invalid && replacement !== value) { + value = replacement; + sendTelemetryEvent(EventName.ENVFILE_VARIABLE_SUBSTITUTION); + } + + return value.replace(/\\\$/g, '$'); +} diff --git a/src/extension/debugger/configuration/resolvers/helper.ts b/src/extension/debugger/configuration/resolvers/helper.ts index 6791bded..ab2c4989 100644 --- a/src/extension/debugger/configuration/resolvers/helper.ts +++ b/src/extension/debugger/configuration/resolvers/helper.ts @@ -1,79 +1,85 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -'use strict'; - -import { PYTHON_LANGUAGE } from '../../../common/constants'; -import { getSearchPathEnvVarNames } from '../../../common/utils/exec'; -import { EnvironmentVariables } from '../../../common/variables/types'; -import { getActiveTextEditor } from '../../../common/vscodeapi'; -import { LaunchRequestArguments } from '../../../types'; -import * as envParser from '../../../common/variables/environment'; - -export async function getDebugEnvironmentVariables(args: LaunchRequestArguments): Promise { - const pathVariableName = getSearchPathEnvVarNames()[0]; - - // Merge variables from both .env file and env json variables. - const debugLaunchEnvVars: Record = - args.env && Object.keys(args.env).length > 0 - ? ({ ...args.env } as Record) - : ({} as Record); - const envFileVars = await envParser.parseFile(args.envFile, debugLaunchEnvVars); - const env = envFileVars ? { ...envFileVars } : {}; - - // "overwrite: true" to ensure that debug-configuration env variable values - // take precedence over env file. - envParser.mergeVariables(debugLaunchEnvVars, env, { overwrite: true }); - - // Append the PYTHONPATH and PATH variables. - envParser.appendPath(env, debugLaunchEnvVars[pathVariableName]); - envParser.appendPythonPath(env, debugLaunchEnvVars.PYTHONPATH); - - if (typeof env[pathVariableName] === 'string' && env[pathVariableName]!.length > 0) { - // Now merge this path with the current system path. - // We need to do this to ensure the PATH variable always has the system PATHs as well. - envParser.appendPath(env, process.env[pathVariableName]!); - } - if (typeof env.PYTHONPATH === 'string' && env.PYTHONPATH.length > 0) { - // We didn't have a value for PATH earlier and now we do. - // Now merge this path with the current system path. - // We need to do this to ensure the PATH variable always has the system PATHs as well. - envParser.appendPythonPath(env, process.env.PYTHONPATH!); - } - - if (args.console === 'internalConsole') { - // For debugging, when not using any terminal, then we need to provide all env variables. - // As we're spawning the process, we need to ensure all env variables are passed. - // Including those from the current process (i.e. everything, not just custom vars). - envParser.mergeVariables(process.env, env); - - if (env[pathVariableName] === undefined && typeof process.env[pathVariableName] === 'string') { - env[pathVariableName] = process.env[pathVariableName]; - } - if (env.PYTHONPATH === undefined && typeof process.env.PYTHONPATH === 'string') { - env.PYTHONPATH = process.env.PYTHONPATH; - } - } - - if (!env.hasOwnProperty('PYTHONIOENCODING')) { - env.PYTHONIOENCODING = 'UTF-8'; - } - if (!env.hasOwnProperty('PYTHONUNBUFFERED')) { - env.PYTHONUNBUFFERED = '1'; - } - - if (args.gevent) { - env.GEVENT_SUPPORT = 'True'; // this is read in pydevd_constants.py - } - - return env; -} - -export function getProgram(): string | undefined { - const activeTextEditor = getActiveTextEditor(); - if (activeTextEditor && activeTextEditor.document.languageId === PYTHON_LANGUAGE) { - return activeTextEditor.document.fileName; - } - return undefined; -} +/* eslint-disable @typescript-eslint/naming-convention */ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { PYTHON_LANGUAGE } from '../../../common/constants'; +import { getSearchPathEnvVarNames } from '../../../common/utils/exec'; +import { EnvironmentVariables } from '../../../common/variables/types'; +import { getActiveTextEditor } from '../../../common/vscodeapi'; +import { LaunchRequestArguments } from '../../../types'; +import * as envParser from '../../../common/variables/environment'; + +export async function getDebugEnvironmentVariables(args: LaunchRequestArguments): Promise { + const pathVariableName = getSearchPathEnvVarNames()[0]; + const normalizedProcessEnv = { ...process.env }; + envParser.normalizeSearchPathEnvironmentVariable(normalizedProcessEnv); + + // Merge variables from both .env file and env json variables. + const debugLaunchEnvVars: Record = + args.env && Object.keys(args.env).length > 0 + ? ({ ...args.env } as Record) + : ({} as Record); + envParser.normalizeSearchPathEnvironmentVariable(debugLaunchEnvVars); + const envFileVars = await envParser.parseFile(args.envFile, debugLaunchEnvVars); + const env = envFileVars ? { ...envFileVars } : {}; + envParser.normalizeSearchPathEnvironmentVariable(env); + + // "overwrite: true" to ensure that debug-configuration env variable values + // take precedence over env file. + envParser.mergeVariables(debugLaunchEnvVars, env, { overwrite: true }); + + // Append the PYTHONPATH and PATH variables. + envParser.appendPath(env, debugLaunchEnvVars[pathVariableName]); + envParser.appendPythonPath(env, debugLaunchEnvVars.PYTHONPATH); + + if (typeof env[pathVariableName] === 'string' && env[pathVariableName]!.length > 0) { + // Now merge this path with the current system path. + // We need to do this to ensure the PATH variable always has the system PATHs as well. + envParser.appendPath(env, normalizedProcessEnv[pathVariableName]!); + } + if (typeof env.PYTHONPATH === 'string' && env.PYTHONPATH.length > 0) { + // We didn't have a value for PATH earlier and now we do. + // Now merge this path with the current system path. + // We need to do this to ensure the PATH variable always has the system PATHs as well. + envParser.appendPythonPath(env, normalizedProcessEnv.PYTHONPATH!); + } + + if (args.console === 'internalConsole') { + // For debugging, when not using any terminal, then we need to provide all env variables. + // As we're spawning the process, we need to ensure all env variables are passed. + // Including those from the current process (i.e. everything, not just custom vars). + envParser.mergeVariables(normalizedProcessEnv, env); + + if (env[pathVariableName] === undefined && typeof normalizedProcessEnv[pathVariableName] === 'string') { + env[pathVariableName] = normalizedProcessEnv[pathVariableName]; + } + if (env.PYTHONPATH === undefined && typeof normalizedProcessEnv.PYTHONPATH === 'string') { + env.PYTHONPATH = normalizedProcessEnv.PYTHONPATH; + } + } + + envParser.normalizeSearchPathEnvironmentVariable(env); + + if (!env.hasOwnProperty('PYTHONIOENCODING')) { + env.PYTHONIOENCODING = 'UTF-8'; + } + if (!env.hasOwnProperty('PYTHONUNBUFFERED')) { + env.PYTHONUNBUFFERED = '1'; + } + + if (args.gevent) { + env.GEVENT_SUPPORT = 'True'; // this is read in pydevd_constants.py + } + + return env; +} + +export function getProgram(): string | undefined { + const activeTextEditor = getActiveTextEditor(); + if (activeTextEditor && activeTextEditor.document.languageId === PYTHON_LANGUAGE) { + return activeTextEditor.document.fileName; + } + return undefined; +} diff --git a/src/test/unittest/configuration/resolvers/helper.unit.test.ts b/src/test/unittest/configuration/resolvers/helper.unit.test.ts index c2d6d483..0da4fbc7 100644 --- a/src/test/unittest/configuration/resolvers/helper.unit.test.ts +++ b/src/test/unittest/configuration/resolvers/helper.unit.test.ts @@ -1,70 +1,117 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -'use strict'; - -import { expect } from 'chai'; -import * as sinon from 'sinon'; -import * as typemoq from 'typemoq'; -import { TextDocument, TextEditor } from 'vscode'; -import { PYTHON_LANGUAGE } from '../../../../extension/common/constants'; -import * as vscodeapi from '../../../../extension/common/vscodeapi'; -import { getProgram } from '../../../../extension/debugger/configuration/resolvers/helper'; - -suite('Debugging - Helpers', () => { - let getActiveTextEditorStub: sinon.SinonStub; - - setup(() => { - getActiveTextEditorStub = sinon.stub(vscodeapi, 'getActiveTextEditor'); - }); - teardown(() => { - sinon.restore(); - }); - - test('Program should return filepath of active editor if file is python', () => { - const expectedFileName = 'my.py'; - const editor = typemoq.Mock.ofType(); - const doc = typemoq.Mock.ofType(); - - editor - .setup((e) => e.document) - .returns(() => doc.object) - .verifiable(typemoq.Times.once()); - doc.setup((d) => d.languageId) - .returns(() => PYTHON_LANGUAGE) - .verifiable(typemoq.Times.once()); - doc.setup((d) => d.fileName) - .returns(() => expectedFileName) - .verifiable(typemoq.Times.once()); - - getActiveTextEditorStub.returns(editor.object); - - const program = getProgram(); - - expect(program).to.be.equal(expectedFileName); - }); - test('Program should return undefined if active file is not python', () => { - const editor = typemoq.Mock.ofType(); - const doc = typemoq.Mock.ofType(); - - editor - .setup((e) => e.document) - .returns(() => doc.object) - .verifiable(typemoq.Times.once()); - doc.setup((d) => d.languageId) - .returns(() => 'C#') - .verifiable(typemoq.Times.once()); - getActiveTextEditorStub.returns(editor.object); - - const program = getProgram(); - - expect(program).to.be.equal(undefined, 'Not undefined'); - }); - test('Program should return undefined if there is no active editor', () => { - getActiveTextEditorStub.returns(undefined); - - const program = getProgram(); - - expect(program).to.be.equal(undefined, 'Not undefined'); - }); -}); +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { expect } from 'chai'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as typemoq from 'typemoq'; +import { TextDocument, TextEditor } from 'vscode'; +import { PYTHON_LANGUAGE } from '../../../../extension/common/constants'; +import * as platform from '../../../../extension/common/platform'; +import * as vscodeapi from '../../../../extension/common/vscodeapi'; +import { getDebugEnvironmentVariables, getProgram } from '../../../../extension/debugger/configuration/resolvers/helper'; +import { LaunchRequestArguments } from '../../../../extension/types'; + +suite('Debugging - Helpers', () => { + let getActiveTextEditorStub: sinon.SinonStub; + + setup(() => { + getActiveTextEditorStub = sinon.stub(vscodeapi, 'getActiveTextEditor'); + }); + teardown(() => { + sinon.restore(); + }); + + test('Program should return filepath of active editor if file is python', () => { + const expectedFileName = 'my.py'; + const editor = typemoq.Mock.ofType(); + const doc = typemoq.Mock.ofType(); + + editor + .setup((e) => e.document) + .returns(() => doc.object) + .verifiable(typemoq.Times.once()); + doc.setup((d) => d.languageId) + .returns(() => PYTHON_LANGUAGE) + .verifiable(typemoq.Times.once()); + doc.setup((d) => d.fileName) + .returns(() => expectedFileName) + .verifiable(typemoq.Times.once()); + + getActiveTextEditorStub.returns(editor.object); + + const program = getProgram(); + + expect(program).to.be.equal(expectedFileName); + }); + test('Program should return undefined if active file is not python', () => { + const editor = typemoq.Mock.ofType(); + const doc = typemoq.Mock.ofType(); + + editor + .setup((e) => e.document) + .returns(() => doc.object) + .verifiable(typemoq.Times.once()); + doc.setup((d) => d.languageId) + .returns(() => 'C#') + .verifiable(typemoq.Times.once()); + getActiveTextEditorStub.returns(editor.object); + + const program = getProgram(); + + expect(program).to.be.equal(undefined, 'Not undefined'); + }); + test('Program should return undefined if there is no active editor', () => { + getActiveTextEditorStub.returns(undefined); + + const program = getProgram(); + + expect(program).to.be.equal(undefined, 'Not undefined'); + }); + + test('Debug env vars should normalize duplicate Windows path keys', async () => { + sinon.stub(platform, 'getOSType').returns(platform.OSType.Windows); + + /* eslint-disable @typescript-eslint/naming-convention */ + const env = await getDebugEnvironmentVariables({ + type: 'debugpy', + name: 'Launch', + request: 'launch', + env: { + Path: 'C:\\tool-bin', + PATH: 'C:\\user-bin', + }, + } as LaunchRequestArguments); + /* eslint-enable @typescript-eslint/naming-convention */ + + expect(env.Path).to.include(`C:\\tool-bin${path.delimiter}C:\\user-bin`); + expect(env).to.not.have.property('PATH'); + }); + + test('Debug env vars should normalize Windows path keys after merging process env', async () => { + sinon.stub(platform, 'getOSType').returns(platform.OSType.Windows); + /* eslint-disable @typescript-eslint/naming-convention */ + const processEnvStub = sinon.stub(process, 'env').value({ + Path: 'C:\\system-bin', + PATH: 'C:\\legacy-system-bin', + }); + + const env = await getDebugEnvironmentVariables({ + type: 'debugpy', + name: 'Launch', + request: 'launch', + console: 'internalConsole', + env: { + Path: 'C:\\tool-bin', + }, + } as LaunchRequestArguments); + /* eslint-enable @typescript-eslint/naming-convention */ + + expect(processEnvStub).to.not.equal(undefined); + expect(env.Path).to.include(`C:\\tool-bin${path.delimiter}C:\\system-bin`); + expect(env.Path).to.include('C:\\legacy-system-bin'); + expect(env).to.not.have.property('PATH'); + }); +}); diff --git a/src/test/unittest/noConfigDebugInit.unit.test.ts b/src/test/unittest/noConfigDebugInit.unit.test.ts index 2e9a7ca9..177c4208 100644 --- a/src/test/unittest/noConfigDebugInit.unit.test.ts +++ b/src/test/unittest/noConfigDebugInit.unit.test.ts @@ -1,250 +1,274 @@ -// 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, env, 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'; - const testSessionId = 'test-session-id-1234'; - const hashedSessionId = crypto.createHash('sha256').update(testSessionId).digest('hex').slice(0, 16); - - const testDataDir = path.join(__dirname, 'testData'); - const testFilePath = path.join(testDataDir, 'debuggerAdapterEndpoint.txt'); - setup(() => { - try { - context = TypeMoq.Mock.ofType(); - - context.setup((c) => (c as any).extensionPath).returns(() => os.tmpdir()); - 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')); - - // Stub env.sessionId to return a consistent value for tests - sinon.stub(env, 'sessionId').value(testSessionId); - } 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('endpoint-')); - } else if (key === BUNDLED_DEBUGPY_PATH) { - assert(value === bundledDebugPath); - } else if (key === 'PYDEVD_DISABLE_FILE_VALIDATION') { - assert(value === '1'); - } - }) - .returns(envVarCollectionReplaceStub); - environmentVariableCollectionMock - .setup((x) => x.append(TypeMoq.It.isAny(), TypeMoq.It.isAny())) - .callback((key, value) => { - if (key === 'PATH') { - const pathSeparator = process.platform === 'win32' ? ';' : ':'; - assert(value === `${pathSeparator}${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.calledThrice(envVarCollectionReplaceStub); - sinon.assert.calledOnce(envVarCollectionAppendStub); - }); - - test('should always add separator when appending to PATH', async () => { - const environmentVariableCollectionMock = TypeMoq.Mock.ofType(); - envVarCollectionReplaceStub = sinon.stub(); - envVarCollectionAppendStub = sinon.stub(); - - // The separator should always be prepended regardless of process.env.PATH - const pathSeparator = process.platform === 'win32' ? ';' : ':'; - - environmentVariableCollectionMock - .setup((x) => x.replace(TypeMoq.It.isAny(), TypeMoq.It.isAny())) - .returns(envVarCollectionReplaceStub); - - environmentVariableCollectionMock - .setup((x) => x.append(TypeMoq.It.isAny(), TypeMoq.It.isAny())) - .callback((key, value) => { - if (key === 'PATH') { - // Should always add separator when appending - assert(value === `${pathSeparator}${noConfigScriptsDir}`); - assert(value.startsWith(pathSeparator)); - } - }) - .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 append was called for PATH - 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( - path.join(os.tmpdir(), '.noConfigDebugAdapterEndpoints'), - `endpoint-${hashedSessionId}.txt`, - ); - 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); - }); - - test('should check if tempFilePath exists when debuggerAdapterEndpointFolder exists', async () => { - // Arrange - const environmentVariableCollectionMock = TypeMoq.Mock.ofType(); - context.setup((c) => c.environmentVariableCollection).returns(() => environmentVariableCollectionMock.object); - - const fsExistsSyncStub = sinon.stub(fs, 'existsSync').returns(true); - const fsUnlinkSyncStub = sinon.stub(fs, 'unlinkSync'); - - // Act - await registerNoConfigDebug(context.object.environmentVariableCollection, context.object.extensionPath); - - // Assert - sinon.assert.calledWith( - fsExistsSyncStub, - sinon.match((value: any) => value.includes('endpoint-')), - ); - sinon.assert.calledOnce(fsUnlinkSyncStub); - - // Cleanup - fsExistsSyncStub.restore(); - fsUnlinkSyncStub.restore(); - }); -}); - -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; -} +// 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, env, 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'; +import { EXTENSION_ROOT_DIR_FOR_TESTS } from '../constants'; + +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'; + const testSessionId = 'test-session-id-1234'; + const hashedSessionId = crypto.createHash('sha256').update(testSessionId).digest('hex').slice(0, 16); + + const testDataDir = path.join(__dirname, 'testData'); + const testFilePath = path.join(testDataDir, 'debuggerAdapterEndpoint.txt'); + setup(() => { + try { + context = TypeMoq.Mock.ofType(); + + context.setup((c) => (c as any).extensionPath).returns(() => os.tmpdir()); + 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')); + + // Stub env.sessionId to return a consistent value for tests + sinon.stub(env, 'sessionId').value(testSessionId); + } 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('endpoint-')); + } else if (key === BUNDLED_DEBUGPY_PATH) { + assert(value === bundledDebugPath); + } else if (key === 'PYDEVD_DISABLE_FILE_VALIDATION') { + assert(value === '1'); + } + }) + .returns(envVarCollectionReplaceStub); + environmentVariableCollectionMock + .setup((x) => x.append(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .callback((key, value) => { + if (key === 'PATH') { + const pathSeparator = process.platform === 'win32' ? ';' : ':'; + assert(value === `${pathSeparator}${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.calledThrice(envVarCollectionReplaceStub); + sinon.assert.calledOnce(envVarCollectionAppendStub); + }); + + test('should always add separator when appending to PATH', async () => { + const environmentVariableCollectionMock = TypeMoq.Mock.ofType(); + envVarCollectionReplaceStub = sinon.stub(); + envVarCollectionAppendStub = sinon.stub(); + + // The separator should always be prepended regardless of process.env.PATH + const pathSeparator = process.platform === 'win32' ? ';' : ':'; + + environmentVariableCollectionMock + .setup((x) => x.replace(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .returns(envVarCollectionReplaceStub); + + environmentVariableCollectionMock + .setup((x) => x.append(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .callback((key, value) => { + if (key === 'PATH') { + // Should always add separator when appending + assert(value === `${pathSeparator}${noConfigScriptsDir}`); + assert(value.startsWith(pathSeparator)); + } + }) + .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 append was called for PATH + 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( + path.join(os.tmpdir(), '.noConfigDebugAdapterEndpoints'), + `endpoint-${hashedSessionId}.txt`, + ); + 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); + }); + + test('should check if tempFilePath exists when debuggerAdapterEndpointFolder exists', async () => { + // Arrange + const environmentVariableCollectionMock = TypeMoq.Mock.ofType(); + context.setup((c) => c.environmentVariableCollection).returns(() => environmentVariableCollectionMock.object); + + const fsExistsSyncStub = sinon.stub(fs, 'existsSync').returns(true); + const fsUnlinkSyncStub = sinon.stub(fs, 'unlinkSync'); + + // Act + await registerNoConfigDebug(context.object.environmentVariableCollection, context.object.extensionPath); + + // Assert + sinon.assert.calledWith( + fsExistsSyncStub, + sinon.match((value: any) => value.includes('endpoint-')), + ); + sinon.assert.calledOnce(fsUnlinkSyncStub); + + // Cleanup + fsExistsSyncStub.restore(); + fsUnlinkSyncStub.restore(); + }); + + test('should ship quoted no-config launcher scripts', () => { + const scriptDir = path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'bundled', 'scripts', 'noConfigScripts'); + const bashScript = fs.readFileSync(path.join(scriptDir, 'debugpy'), 'utf8'); + const shScript = fs.readFileSync(path.join(scriptDir, 'debugpy.sh'), 'utf8'); + const fishScript = fs.readFileSync(path.join(scriptDir, 'debugpy.fish'), 'utf8'); + const batScript = fs.readFileSync(path.join(scriptDir, 'debugpy.bat'), 'utf8'); + const ps1Script = fs.readFileSync(path.join(scriptDir, 'debugpy.ps1'), 'utf8'); + + assert(bashScript.includes('export DEBUGPY_ADAPTER_ENDPOINTS="$VSCODE_DEBUGPY_ADAPTER_ENDPOINTS"')); + assert(bashScript.includes('python3 "$BUNDLED_DEBUGPY_PATH" --listen 0 --wait-for-client "$@"')); + assert(!bashScript.includes('\r\n')); + assert(shScript.includes('export DEBUGPY_ADAPTER_ENDPOINTS="$VSCODE_DEBUGPY_ADAPTER_ENDPOINTS"')); + assert(shScript.includes('python3 "$BUNDLED_DEBUGPY_PATH" --listen 0 --wait-for-client "$@"')); + assert(!shScript.includes('\r\n')); + assert(fishScript.includes('set -x DEBUGPY_ADAPTER_ENDPOINTS "$VSCODE_DEBUGPY_ADAPTER_ENDPOINTS"')); + assert(fishScript.includes('python3 "$BUNDLED_DEBUGPY_PATH" --listen 0 --wait-for-client $argv')); + assert(!fishScript.includes('\r\n')); + assert(batScript.includes('set "DEBUGPY_ADAPTER_ENDPOINTS=%VSCODE_DEBUGPY_ADAPTER_ENDPOINTS%"')); + assert(batScript.includes('python "%BUNDLED_DEBUGPY_PATH%" --listen 0 --wait-for-client %*')); + assert(ps1Script.includes('python "$env:BUNDLED_DEBUGPY_PATH" --listen 0 --wait-for-client $args')); + assert(ps1Script.includes('python3 "$env:BUNDLED_DEBUGPY_PATH" --listen 0 --wait-for-client $args')); + }); +}); + +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; +}