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; +}