Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .github/instructions/testing-workflow.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,17 @@ envConfig.inspect
- Untestable Node.js APIs → Create proxy abstraction functions (use function overloads to preserve intelligent typing while making functions mockable)

## 🧠 Agent Learnings
- VS Code file watchers only monitor workspace folders, not external temp directories (1)
- Use fixture-based testing with real files instead of mocking fs-extra, which has non-configurable property descriptors that prevent stubbing (1)
- Extension tests (.test.ts) should use real filesystem operations; unit tests (.unit.test.ts) should mock dependencies (1)
- Use `as unknown as TargetType` for type casting instead of `as any` to maintain type safety and avoid 'any' violations
- If tests frequently need private access consider that maybe methods should be protected, or public test utilities should exist for testing (1)
- When making systematic changes across many similar locations, fix one instance completely first to validate the approach before applying the pattern everywhere (1)
- Always recompile tests after making changes before running them, especially when changing imports or type definitions (1)
- When using paths as Map keys for tracking, you MUST use Uri.fsPath consistently throughout the test - mixing hardcoded strings with Uri.fsPath causes key mismatches on Windows (1)
- Avoid accessing private members in tests using bracket notation or test interfaces - instead add explicit test helper methods or make methods `protected` rather than `private` for better encapsulation and less brittle tests (1)
- Check for redundant test coverage between unit and integration test files - integration tests should test end-to-end behavior while unit tests focus on internal logic and edge cases (1)
- For async test timing, prefer event-driven or promise-based approaches over delays; when testing fire-and-forget event handlers with no completion signal, use condition-based polling (`waitForCondition`) instead of hardcoded `setTimeout` - faster and more reliable than arbitrary delays (1)
- When accessing fixture files in compiled tests, use `path.join(__dirname, '..', '..', '..', 'src', 'test', 'fixtures')` to read directly from source instead of copying to `out/` - `__dirname` points to the compiled location so navigate up and into `src/` (1)
- Avoid testing exact error messages or log output - assert only that errors are thrown or rejection occurs to prevent brittle tests (1)
- Create shared mock helpers (e.g., `createMockLogOutputChannel()`) instead of duplicating mock setup across multiple test files (1)

7 changes: 7 additions & 0 deletions src/features/execution/envVariableManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ export class PythonEnvVariableManager implements EnvVarManager {

onDidChangeEnvironmentVariables: Event<DidChangeEnvironmentVariablesEventArgs>;

/**
* @internal For testing only - manually trigger environment variable change event
*/
triggerEnvironmentVariableChange(event: DidChangeEnvironmentVariablesEventArgs): void {
this._onDidChangeEnvironmentVariables.fire(event);
}

dispose(): void {
this.disposables.forEach((disposable) => disposable.dispose());
}
Expand Down
169 changes: 129 additions & 40 deletions src/features/terminal/terminalEnvVarInjector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import * as fse from 'fs-extra';
import * as path from 'path';
import {
Disposable,
EnvironmentVariableCollection,
EnvironmentVariableScope,
GlobalEnvironmentVariableCollection,
Uri,
window,
workspace,
WorkspaceFolder,
Expand All @@ -23,6 +25,13 @@ import { EnvVarManager } from '../execution/envVariableManager';
export class TerminalEnvVarInjector implements Disposable {
private disposables: Disposable[] = [];

/**
* Track which environment variables we've set per workspace to properly handle
* variables that are removed/commented out in .env files.
* Key: workspace fsPath, Value: Set of env var keys we've set for that workspace
*/
protected readonly trackedEnvVars: Map<string, Set<string>> = new Map();

constructor(
private readonly envVarCollection: GlobalEnvironmentVariableCollection,
private readonly envVarManager: EnvVarManager,
Expand Down Expand Up @@ -134,48 +143,22 @@ export class TerminalEnvVarInjector implements Disposable {
*/
private async injectEnvironmentVariablesForWorkspace(workspaceFolder: WorkspaceFolder): Promise<void> {
const workspaceUri = workspaceFolder.uri;
try {
const envVars = await this.envVarManager.getEnvironmentVariables(workspaceUri);
const workspaceKey = workspaceUri.fsPath;

// use scoped environment variable collection
try {
const envVarScope = this.getEnvironmentVariableCollectionScoped({ workspaceFolder });

// Check if env file injection is enabled
const config = getConfiguration('python', workspaceUri);
const useEnvFile = config.get<boolean>('terminal.useEnvFile', false);
const envFilePath = config.get<string>('envFile');

if (!useEnvFile) {
traceVerbose(
`TerminalEnvVarInjector: Env file injection disabled for workspace: ${workspaceUri.fsPath}`,
);
// Check if we should inject and get the env file path
const shouldInject = await this.shouldInjectEnvVars(workspaceUri, envVarScope, workspaceKey);
if (!shouldInject) {
return;
}

// Track which .env file is being used for logging
const resolvedEnvFilePath: string | undefined = envFilePath
? path.resolve(resolveVariables(envFilePath, workspaceUri))
: undefined;
const defaultEnvFilePath: string = path.join(workspaceUri.fsPath, '.env');

let activeEnvFilePath: string = resolvedEnvFilePath || defaultEnvFilePath;
if (activeEnvFilePath && (await fse.pathExists(activeEnvFilePath))) {
traceVerbose(`TerminalEnvVarInjector: Using env file: ${activeEnvFilePath}`);
} else {
traceVerbose(
`TerminalEnvVarInjector: No .env file found for workspace: ${workspaceUri.fsPath}, not injecting environment variables.`,
);
return; // No .env file to inject
}
// Get environment variables from the .env file
const envVars = await this.envVarManager.getEnvironmentVariables(workspaceUri);

for (const [key, value] of Object.entries(envVars)) {
if (value === undefined) {
// Remove the environment variable if the value is undefined
envVarScope.delete(key);
} else {
envVarScope.replace(key, value);
}
}
// Apply the environment variable changes
this.applyEnvVarChanges(envVarScope, envVars, workspaceKey);
} catch (error) {
traceError(
`TerminalEnvVarInjector: Error injecting environment variables for workspace ${workspaceUri.fsPath}:`,
Expand All @@ -184,16 +167,115 @@ export class TerminalEnvVarInjector implements Disposable {
}
}

/**
* Check if environment variables should be injected for the workspace.
* Returns true if injection should proceed, false otherwise.
*/
private async shouldInjectEnvVars(
workspaceUri: Uri,
envVarScope: EnvironmentVariableCollection,
workspaceKey: string,
): Promise<boolean> {
const config = getConfiguration('python', workspaceUri);
const useEnvFile = config.get<boolean>('terminal.useEnvFile', false);
const envFilePath = config.get<string>('envFile');

if (!useEnvFile) {
traceVerbose(`TerminalEnvVarInjector: Env file injection disabled for workspace: ${workspaceUri.fsPath}`);
this.cleanupTrackedVars(envVarScope, workspaceKey);
return false;
}

// Determine which .env file to use
const resolvedEnvFilePath: string | undefined = envFilePath
? path.resolve(resolveVariables(envFilePath, workspaceUri))
: undefined;
const defaultEnvFilePath: string = path.join(workspaceUri.fsPath, '.env');
const activeEnvFilePath: string = resolvedEnvFilePath || defaultEnvFilePath;

if (activeEnvFilePath && (await fse.pathExists(activeEnvFilePath))) {
traceVerbose(`TerminalEnvVarInjector: Using env file: ${activeEnvFilePath}`);
return true;
} else {
traceVerbose(
`TerminalEnvVarInjector: No .env file found for workspace: ${workspaceUri.fsPath}, not injecting environment variables.`,
);
this.cleanupTrackedVars(envVarScope, workspaceKey);
return false;
}
}

/**
* Apply environment variable changes by comparing current and previous state.
*/
protected applyEnvVarChanges(
envVarScope: EnvironmentVariableCollection,
envVars: { [key: string]: string | undefined },
workspaceKey: string,
): void {
const previousKeys = this.trackedEnvVars.get(workspaceKey) || new Set<string>();
const currentKeys = new Set<string>();

// Update/add current env vars from .env file
for (const [key, value] of Object.entries(envVars)) {
if (value === undefined || value === '') {
// Variable explicitly unset in .env (e.g., VAR=)
envVarScope.delete(key);
} else {
envVarScope.replace(key, value);
currentKeys.add(key);
}
}

// Delete keys that were previously set but are now gone from .env
for (const oldKey of previousKeys) {
if (!currentKeys.has(oldKey)) {
traceVerbose(
`TerminalEnvVarInjector: Removing previously set env var '${oldKey}' from workspace ${workspaceKey}`,
);
envVarScope.delete(oldKey);
}
}

// Update our tracking for this workspace
this.trackedEnvVars.set(workspaceKey, currentKeys);
}

/**
* Clean up previously tracked environment variables for a workspace.
*/
protected cleanupTrackedVars(envVarScope: EnvironmentVariableCollection, workspaceKey: string): void {
const previousKeys = this.trackedEnvVars.get(workspaceKey);
if (previousKeys) {
previousKeys.forEach((key) => envVarScope.delete(key));
this.trackedEnvVars.delete(workspaceKey);
}
}

/**
* Dispose of the injector and clean up resources.
*/
dispose(): void {
traceVerbose('TerminalEnvVarInjector: Disposing');
this.disposables.forEach((disposable) => disposable.dispose());
this.disposables.forEach((disposable) => {
disposable.dispose();
});
this.disposables = [];

// Clear all environment variables from the collection
this.envVarCollection.clear();
// Clear only the environment variables that we've set, preserving others (e.g., BASH_ENV)
for (const [workspaceKey, trackedKeys] of this.trackedEnvVars.entries()) {
try {
// Try to find the workspace folder for proper scoping
const workspaceFolder = workspace.workspaceFolders?.find((wf) => wf.uri.fsPath === workspaceKey);
if (workspaceFolder) {
const scope = this.getEnvironmentVariableCollectionScoped({ workspaceFolder });
trackedKeys.forEach((key) => scope.delete(key));
}
} catch (error) {
traceError(`Failed to clean up environment variables for workspace ${workspaceKey}:`, error);
}
}
this.trackedEnvVars.clear();
}

private getEnvironmentVariableCollectionScoped(scope: EnvironmentVariableScope = {}) {
Expand All @@ -204,10 +286,17 @@ export class TerminalEnvVarInjector implements Disposable {
/**
* Clear all environment variables for a workspace.
*/
private clearWorkspaceVariables(workspaceFolder: WorkspaceFolder): void {
protected clearWorkspaceVariables(workspaceFolder: WorkspaceFolder): void {
const workspaceKey = workspaceFolder.uri.fsPath;
try {
const scope = this.getEnvironmentVariableCollectionScoped({ workspaceFolder });
scope.clear();

// Only delete env vars that we've set, not ones set by other managers (e.g., BASH_ENV)
const trackedKeys = this.trackedEnvVars.get(workspaceKey);
if (trackedKeys) {
trackedKeys.forEach((key) => scope.delete(key));
this.trackedEnvVars.delete(workspaceKey);
}
} catch (error) {
traceError(`Failed to clear environment variables for workspace ${workspaceFolder.uri.fsPath}:`, error);
}
Expand Down
Loading
Loading