22// Licensed under the MIT License.
33
44import * as assert from 'assert' ;
5+ import fse from 'fs-extra' ;
56import * as path from 'path' ;
67import * as sinon from 'sinon' ;
78import * as typeMoq from 'typemoq' ;
@@ -18,6 +19,7 @@ import {
1819import { ActivationStrings , Common } from '../../common/localize' ;
1920import * as logging from '../../common/logging' ;
2021import * as persistentState from '../../common/persistentState' ;
22+ import { createDeferred } from '../../common/utils/deferred' ;
2123import * as windowApis from '../../common/window.apis' ;
2224import * as workspaceApis from '../../common/workspace.apis' ;
2325import { EnvVarManager } from '../../features/execution/envVariableManager' ;
@@ -151,6 +153,131 @@ suite('TerminalEnvVarInjector', () => {
151153 assert . strictEqual ( mockScopedCollection . replace . called , false ) ;
152154 } ) ;
153155
156+ suite ( 'env file changes' , ( ) => {
157+ let envChangeCallback : ( ( args : { uri ?: Uri ; changeType : number } ) => Promise < void > ) | undefined ;
158+ let variables : Record < string , string > ;
159+ let variableReads : Promise < Record < string , string > > [ ] ;
160+ let existingFiles : Set < string > ;
161+ const defaultEnvFile = path . join ( testWorkspaceFolder . uri . fsPath , '.env' ) ;
162+ const configuredEnvFile = path . join ( testWorkspaceFolder . uri . fsPath , 'configured.env' ) ;
163+
164+ setup ( ( ) => {
165+ variables = { } ;
166+ variableReads = [ ] ;
167+ existingFiles = new Set ( ) ;
168+ workspaceFoldersValue = undefined ;
169+ sinon
170+ . stub ( fse , 'pathExists' )
171+ . callsFake ( async ( filePath ) => existingFiles . has ( path . resolve ( filePath . toString ( ) ) ) ) ;
172+ sinon . stub ( workspaceApis , 'getWorkspaceFolder' ) . returns ( testWorkspaceFolder ) ;
173+ envVarManager . reset ( ) ;
174+ envVarManager . setup ( ( m ) => m . onDidChangeEnvironmentVariables ) . returns (
175+ ( ) => ( listener ) => {
176+ envChangeCallback = listener ;
177+ return new Disposable ( ( ) => { } ) ;
178+ } ,
179+ ) ;
180+ envVarManager
181+ . setup ( ( m ) => m . getEnvironmentVariables ( typeMoq . It . isAny ( ) ) )
182+ . returns ( ( ) => variableReads . shift ( ) ?? Promise . resolve ( { ...variables } ) ) ;
183+ } ) ;
184+
185+ async function fireChange ( changeType : number , filePath = defaultEnvFile ) : Promise < void > {
186+ assert . ok ( envChangeCallback ) ;
187+ await envChangeCallback ( { uri : Uri . file ( filePath ) , changeType } ) ;
188+ }
189+
190+ test ( 'creating an env file with injection disabled preserves shell activation variables' , async ( ) => {
191+ getConfigurationStub . returns ( createMockConfig ( { useEnvFile : false } ) as WorkspaceConfiguration ) ;
192+ injector = new TerminalEnvVarInjector ( envVarCollection . object , envVarManager . object ) ;
193+ await fireChange ( 2 ) ;
194+
195+ sinon . assert . notCalled ( mockScopedCollection . clear ) ;
196+ sinon . assert . notCalled ( mockScopedCollection . delete ) ;
197+ sinon . assert . notCalled ( mockScopedCollection . replace ) ;
198+ } ) ;
199+
200+ test ( 'creating, editing, and deleting an env file updates only its injected variables' , async ( ) => {
201+ getConfigurationStub . returns ( createMockConfig ( { useEnvFile : true } ) as WorkspaceConfiguration ) ;
202+ injector = new TerminalEnvVarInjector ( envVarCollection . object , envVarManager . object ) ;
203+
204+ existingFiles . add ( defaultEnvFile ) ;
205+ variables = { TERMINAL_PROBE_VALUE : 'created' } ;
206+ await fireChange ( 2 ) ;
207+ sinon . assert . calledWith ( mockScopedCollection . replace , 'TERMINAL_PROBE_VALUE' , 'created' ) ;
208+
209+ variables = { OTHER_VALUE : 'edited' } ;
210+ await fireChange ( 1 ) ;
211+ sinon . assert . calledWith ( mockScopedCollection . delete , 'TERMINAL_PROBE_VALUE' ) ;
212+ sinon . assert . calledWith ( mockScopedCollection . replace , 'OTHER_VALUE' , 'edited' ) ;
213+
214+ existingFiles . delete ( defaultEnvFile ) ;
215+ variables = { } ;
216+ await fireChange ( 3 ) ;
217+ sinon . assert . calledWith ( mockScopedCollection . delete , 'OTHER_VALUE' ) ;
218+ sinon . assert . notCalled ( mockScopedCollection . clear ) ;
219+ } ) ;
220+
221+ test ( 'deleting one env file retains variables from the other configured file' , async ( ) => {
222+ getConfigurationStub . returns (
223+ createMockConfig ( { useEnvFile : true , envFilePath : configuredEnvFile } ) as WorkspaceConfiguration ,
224+ ) ;
225+ existingFiles . add ( defaultEnvFile ) ;
226+ existingFiles . add ( configuredEnvFile ) ;
227+ variables = { CONFIGURED_VALUE : 'configured' , PROJECT_VALUE : 'project' } ;
228+ injector = new TerminalEnvVarInjector ( envVarCollection . object , envVarManager . object ) ;
229+ await fireChange ( 2 , configuredEnvFile ) ;
230+
231+ existingFiles . delete ( configuredEnvFile ) ;
232+ variables = { PROJECT_VALUE : 'project' } ;
233+ await fireChange ( 3 , configuredEnvFile ) ;
234+
235+ sinon . assert . calledWith ( mockScopedCollection . delete , 'CONFIGURED_VALUE' ) ;
236+ sinon . assert . neverCalledWith ( mockScopedCollection . delete , 'PROJECT_VALUE' ) ;
237+ sinon . assert . notCalled ( mockScopedCollection . clear ) ;
238+ } ) ;
239+
240+ test ( 'deleting the project env file retains variables from the configured file' , async ( ) => {
241+ getConfigurationStub . returns (
242+ createMockConfig ( { useEnvFile : true , envFilePath : configuredEnvFile } ) as WorkspaceConfiguration ,
243+ ) ;
244+ existingFiles . add ( defaultEnvFile ) ;
245+ existingFiles . add ( configuredEnvFile ) ;
246+ variables = { CONFIGURED_VALUE : 'configured' , PROJECT_VALUE : 'project' } ;
247+ injector = new TerminalEnvVarInjector ( envVarCollection . object , envVarManager . object ) ;
248+ await fireChange ( 2 ) ;
249+
250+ existingFiles . delete ( defaultEnvFile ) ;
251+ variables = { CONFIGURED_VALUE : 'configured' } ;
252+ await fireChange ( 3 ) ;
253+
254+ sinon . assert . calledWith ( mockScopedCollection . delete , 'PROJECT_VALUE' ) ;
255+ sinon . assert . neverCalledWith ( mockScopedCollection . delete , 'CONFIGURED_VALUE' ) ;
256+ sinon . assert . notCalled ( mockScopedCollection . clear ) ;
257+ } ) ;
258+
259+ test ( 'does not let an older env file refresh overwrite newer variables' , async ( ) => {
260+ getConfigurationStub . returns ( createMockConfig ( { useEnvFile : true } ) as WorkspaceConfiguration ) ;
261+ existingFiles . add ( defaultEnvFile ) ;
262+ const olderRefresh = createDeferred < Record < string , string > > ( ) ;
263+ const newerRefresh = createDeferred < Record < string , string > > ( ) ;
264+ variableReads . push ( olderRefresh . promise , newerRefresh . promise ) ;
265+ injector = new TerminalEnvVarInjector ( envVarCollection . object , envVarManager . object ) ;
266+
267+ assert . ok ( envChangeCallback ) ;
268+ const firstChange = envChangeCallback ( { uri : Uri . file ( defaultEnvFile ) , changeType : 3 } ) ;
269+ const secondChange = envChangeCallback ( { uri : Uri . file ( defaultEnvFile ) , changeType : 2 } ) ;
270+
271+ newerRefresh . resolve ( { NEW_VALUE : 'new' } ) ;
272+ await secondChange ;
273+ olderRefresh . resolve ( { OLD_VALUE : 'old' } ) ;
274+ await firstChange ;
275+
276+ sinon . assert . calledOnceWithExactly ( mockScopedCollection . replace , 'NEW_VALUE' , 'new' ) ;
277+ sinon . assert . neverCalledWith ( mockScopedCollection . replace , 'OLD_VALUE' , 'old' ) ;
278+ } ) ;
279+ } ) ;
280+
154281 test ( 'should NOT inject when useEnvFile is false even with python.envFile configured' , async ( ) => {
155282 getConfigurationStub . returns (
156283 createMockConfig ( {
0 commit comments