From 521daed1f0d45c9e5420cfb3f8b73f797e13b926 Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Thu, 12 Nov 2026 16:59:42 -0800 Subject: [PATCH 1/2] Isolate virtual environment creation integration tests Create real environments in an owned temporary directory outside the open workspace, avoiding activation-script watcher refreshes during deletion. Compare discovered environments by their manager and normalized prefix. Preserve the actual API removal and discovery assertions; retry only owned fixture teardown. Restore production deletion behavior unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../envCreation.integration.test.ts | 72 +++++++------------ 1 file changed, 25 insertions(+), 47 deletions(-) diff --git a/src/test/integration/envCreation.integration.test.ts b/src/test/integration/envCreation.integration.test.ts index 731f69879..b691d3ad4 100644 --- a/src/test/integration/envCreation.integration.test.ts +++ b/src/test/integration/envCreation.integration.test.ts @@ -20,13 +20,14 @@ import * as assert from 'assert'; import * as fs from 'fs-extra'; +import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; import { PythonEnvironment, PythonEnvironmentApi } from '../../api'; import { VENV_MANAGER_ID } from '../../common/constants'; import { normalizePath } from '../../common/utils/pathUtils'; import { ENVS_EXTENSION_ID } from '../constants'; -import { runWithBlockedDialogsAsCancellation, waitForCondition } from '../testUtils'; +import { retryUntilSuccess, runWithBlockedDialogsAsCancellation, waitForCondition } from '../testUtils'; suite('Integration: Environment Creation', function () { this.timeout(120_000); // Environment creation can be slow @@ -35,22 +36,9 @@ suite('Integration: Environment Creation', function () { let defaultManagerScope: vscode.Uri | undefined; let previousDefaultManager: string | undefined; let previousEffectiveDefaultManager: string | undefined; - let workspaceRoot: string | undefined; - let originalVenvDirectories = new Set(); + let fixtureRoot: string | undefined; + let creationScope: vscode.Uri; let settingSnapshotTaken = false; - let venvSnapshotTaken = false; - - async function getVenvDirectories(): Promise> { - if (!workspaceRoot) { - return new Set(); - } - const entries = await fs.readdir(workspaceRoot, { withFileTypes: true }); - return new Set( - entries - .filter((entry) => entry.isDirectory() && /^\.venv(?:-\d+)?$/.test(entry.name)) - .map((entry) => entry.name), - ); - } async function removeCreatedEnvironment(environment: PythonEnvironment): Promise { const environmentPath = path.resolve(environment.sysPrefix); @@ -68,10 +56,10 @@ suite('Integration: Environment Creation', function () { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; assert.ok(workspaceFolder, 'Environment creation tests require a workspace folder'); defaultManagerScope = workspaceFolder.uri; - workspaceRoot = workspaceFolder.uri.fsPath; - const venvDirectories = await getVenvDirectories(); - originalVenvDirectories = venvDirectories; - venvSnapshotTaken = true; + // Workspace activation-script watchers can schedule discovery after creation returns. + // Keep owned interpreters outside that workspace to avoid competing background refreshes. + fixtureRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'pyenvs-creation-'))); + creationScope = vscode.Uri.file(fixtureRoot); const defaultManagerConfig = vscode.workspace.getConfiguration('python-envs', defaultManagerScope); const inspected = defaultManagerConfig.inspect('defaultEnvManager'); previousDefaultManager = inspected?.workspaceValue; @@ -108,23 +96,15 @@ suite('Integration: Environment Creation', function () { suiteTeardown(async function () { this.timeout(30_000); const cleanupErrors: unknown[] = []; - if (venvSnapshotTaken && workspaceRoot) { + if (fixtureRoot) { + const root = fixtureRoot; try { - const currentVenvDirectories = await getVenvDirectories(); - for (const directory of currentVenvDirectories) { - if (!originalVenvDirectories.has(directory)) { - try { - await fs.rm(path.join(workspaceRoot, directory), { - recursive: true, - force: true, - maxRetries: 5, - retryDelay: 100, - }); - } catch (error) { - cleanupErrors.push(error); - } - } - } + await retryUntilSuccess( + () => fs.remove(root), + () => true, + 5_000, + 'Failed to remove owned environment creation fixture', + ); } catch (error) { cleanupErrors.push(error); } @@ -186,13 +166,12 @@ suite('Integration: Environment Creation', function () { return; } - const workspaceUri = workspaceFolders[0].uri; let createdEnv: PythonEnvironment | undefined; try { // --- ACTION: User creates environment --- createdEnv = await runWithBlockedDialogsAsCancellation(() => - api.createEnvironment(workspaceUri, { quickCreate: true }), + api.createEnvironment(creationScope, { quickCreate: true }), ); if (!createdEnv) { @@ -202,13 +181,14 @@ suite('Integration: Environment Creation', function () { } // --- VERIFY: Created environment is discoverable --- - await api.refreshEnvironments(workspaceUri); - const environments = await api.getEnvironments(workspaceUri); + await api.refreshEnvironments(creationScope); + const environments = await api.getEnvironments('all'); + const createdPrefix = normalizePath(path.resolve(createdEnv.sysPrefix)); const found = environments.some( (env) => - env.envId.id === createdEnv!.envId.id || - env.environmentPath.fsPath === createdEnv!.environmentPath.fsPath, + env.envId.managerId === createdEnv!.envId.managerId && + normalizePath(path.resolve(env.sysPrefix)) === createdPrefix, ); assert.ok(found, 'Created environment should appear in discovery'); @@ -244,12 +224,11 @@ suite('Integration: Environment Creation', function () { return; } - const workspaceUri = workspaceFolders[0].uri; let createdEnv: PythonEnvironment | undefined; try { createdEnv = await runWithBlockedDialogsAsCancellation(() => - api.createEnvironment(workspaceUri, { quickCreate: true }), + api.createEnvironment(creationScope, { quickCreate: true }), ); if (!createdEnv) { this.skip(); @@ -264,7 +243,7 @@ suite('Integration: Environment Creation', function () { await removeCreatedEnvironment(createdEnv); // --- VERIFY: Environment is no longer discoverable --- - await api.refreshEnvironments(workspaceUri); + await api.refreshEnvironments(creationScope); const environments = await api.getEnvironments('all'); const stillExists = environments.some( @@ -306,12 +285,11 @@ suite('Integration: Environment Creation', function () { return; } - const workspaceUri = workspaceFolders[0].uri; let createdEnv: PythonEnvironment | undefined; try { createdEnv = await runWithBlockedDialogsAsCancellation(() => - api.createEnvironment(workspaceUri, { quickCreate: true }), + api.createEnvironment(creationScope, { quickCreate: true }), ); if (!createdEnv) { From 92f0ac0fef2d7e0f834886f3822b130cb2348b71 Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Fri, 13 Nov 2026 10:32:05 -0800 Subject: [PATCH 2/2] Bound transient Windows virtual environment removal retries Retry only Windows EPERM/EBUSY failures around the entire deletion, with a short shared eligibility window and directory identity checks. Preserve the original removal primitive, confirmation, validation, untracking order, and workspace integration coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/managers/builtin/venvUtils.ts | 73 +++++- .../envCreation.integration.test.ts | 72 +++--- .../builtin/venvUtils.removeVenv.unit.test.ts | 207 +++++++++++++++++- 3 files changed, 324 insertions(+), 28 deletions(-) diff --git a/src/managers/builtin/venvUtils.ts b/src/managers/builtin/venvUtils.ts index c2923553d..c3894710a 100644 --- a/src/managers/builtin/venvUtils.ts +++ b/src/managers/builtin/venvUtils.ts @@ -785,6 +785,77 @@ async function validateVenvRemovalPath(envPath: string, log: LogOutputChannel): return undefined; } +function hasRemovalErrorCode(error: unknown, ...codes: string[]): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + typeof error.code === 'string' && + codes.includes(error.code) + ); +} + +async function removeVenvDirectory(envPath: string): Promise { + if (os.platform() !== 'win32') { + return fsapi.remove(envPath); + } + + // Bound retries across the entire deletion, not separately at every directory level. + const deadline = performance.now() + 500; + const target = await fsapi.lstat(envPath, { bigint: true }).catch((error: unknown) => { + if (!hasRemovalErrorCode(error, 'ENOENT')) { + traceVerbose(`Cannot capture venv directory identity; removal will not be retried: ${error}`); + } + return undefined; + }); + + for (let attempt = 1; ; attempt++) { + try { + await fsapi.remove(envPath); + return; + } catch (error) { + if ( + !target?.ino || + !hasRemovalErrorCode(error, 'EPERM', 'EBUSY') || + attempt >= 5 || + performance.now() + 100 >= deadline + ) { + throw error; + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + if (performance.now() >= deadline) { + throw error; + } + + // A partial deletion can remove pyvenv.cfg; verify directory identity instead. + let current; + try { + current = await fsapi.lstat(envPath, { bigint: true }); + } catch (identityError) { + if (hasRemovalErrorCode(identityError, 'ENOENT')) { + return; + } + throw identityError; + } + if (current.dev !== target.dev || current.ino !== target.ino) { + throw new Error('Virtual environment directory changed during removal'); + } + if (performance.now() >= deadline) { + throw error; + } + } + } +} + +/** + * Removes a validated virtual environment after confirmation. + * @param environment Environment whose directory should be removed. + * @param log Logger for validation and removal failures. + * @param options Allows an explicitly headless caller to skip confirmation. + * @param backends Environment trackers to update only after deletion succeeds. + * @returns Whether deletion and untracking succeeded; false on cancellation or failure. + */ export async function removeVenv( environment: PythonEnvironment, log: LogOutputChannel, @@ -825,7 +896,7 @@ export async function removeVenv( }, async () => { try { - await fsapi.remove(envPath); + await removeVenvDirectory(envPath); await backends.uv.untrackEnvironment(environment.environmentPath.fsPath); await backends.venv.untrackEnvironment(environment.environmentPath.fsPath); return true; diff --git a/src/test/integration/envCreation.integration.test.ts b/src/test/integration/envCreation.integration.test.ts index b691d3ad4..731f69879 100644 --- a/src/test/integration/envCreation.integration.test.ts +++ b/src/test/integration/envCreation.integration.test.ts @@ -20,14 +20,13 @@ import * as assert from 'assert'; import * as fs from 'fs-extra'; -import * as os from 'os'; import * as path from 'path'; import * as vscode from 'vscode'; import { PythonEnvironment, PythonEnvironmentApi } from '../../api'; import { VENV_MANAGER_ID } from '../../common/constants'; import { normalizePath } from '../../common/utils/pathUtils'; import { ENVS_EXTENSION_ID } from '../constants'; -import { retryUntilSuccess, runWithBlockedDialogsAsCancellation, waitForCondition } from '../testUtils'; +import { runWithBlockedDialogsAsCancellation, waitForCondition } from '../testUtils'; suite('Integration: Environment Creation', function () { this.timeout(120_000); // Environment creation can be slow @@ -36,9 +35,22 @@ suite('Integration: Environment Creation', function () { let defaultManagerScope: vscode.Uri | undefined; let previousDefaultManager: string | undefined; let previousEffectiveDefaultManager: string | undefined; - let fixtureRoot: string | undefined; - let creationScope: vscode.Uri; + let workspaceRoot: string | undefined; + let originalVenvDirectories = new Set(); let settingSnapshotTaken = false; + let venvSnapshotTaken = false; + + async function getVenvDirectories(): Promise> { + if (!workspaceRoot) { + return new Set(); + } + const entries = await fs.readdir(workspaceRoot, { withFileTypes: true }); + return new Set( + entries + .filter((entry) => entry.isDirectory() && /^\.venv(?:-\d+)?$/.test(entry.name)) + .map((entry) => entry.name), + ); + } async function removeCreatedEnvironment(environment: PythonEnvironment): Promise { const environmentPath = path.resolve(environment.sysPrefix); @@ -56,10 +68,10 @@ suite('Integration: Environment Creation', function () { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; assert.ok(workspaceFolder, 'Environment creation tests require a workspace folder'); defaultManagerScope = workspaceFolder.uri; - // Workspace activation-script watchers can schedule discovery after creation returns. - // Keep owned interpreters outside that workspace to avoid competing background refreshes. - fixtureRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'pyenvs-creation-'))); - creationScope = vscode.Uri.file(fixtureRoot); + workspaceRoot = workspaceFolder.uri.fsPath; + const venvDirectories = await getVenvDirectories(); + originalVenvDirectories = venvDirectories; + venvSnapshotTaken = true; const defaultManagerConfig = vscode.workspace.getConfiguration('python-envs', defaultManagerScope); const inspected = defaultManagerConfig.inspect('defaultEnvManager'); previousDefaultManager = inspected?.workspaceValue; @@ -96,15 +108,23 @@ suite('Integration: Environment Creation', function () { suiteTeardown(async function () { this.timeout(30_000); const cleanupErrors: unknown[] = []; - if (fixtureRoot) { - const root = fixtureRoot; + if (venvSnapshotTaken && workspaceRoot) { try { - await retryUntilSuccess( - () => fs.remove(root), - () => true, - 5_000, - 'Failed to remove owned environment creation fixture', - ); + const currentVenvDirectories = await getVenvDirectories(); + for (const directory of currentVenvDirectories) { + if (!originalVenvDirectories.has(directory)) { + try { + await fs.rm(path.join(workspaceRoot, directory), { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }); + } catch (error) { + cleanupErrors.push(error); + } + } + } } catch (error) { cleanupErrors.push(error); } @@ -166,12 +186,13 @@ suite('Integration: Environment Creation', function () { return; } + const workspaceUri = workspaceFolders[0].uri; let createdEnv: PythonEnvironment | undefined; try { // --- ACTION: User creates environment --- createdEnv = await runWithBlockedDialogsAsCancellation(() => - api.createEnvironment(creationScope, { quickCreate: true }), + api.createEnvironment(workspaceUri, { quickCreate: true }), ); if (!createdEnv) { @@ -181,14 +202,13 @@ suite('Integration: Environment Creation', function () { } // --- VERIFY: Created environment is discoverable --- - await api.refreshEnvironments(creationScope); - const environments = await api.getEnvironments('all'); + await api.refreshEnvironments(workspaceUri); + const environments = await api.getEnvironments(workspaceUri); - const createdPrefix = normalizePath(path.resolve(createdEnv.sysPrefix)); const found = environments.some( (env) => - env.envId.managerId === createdEnv!.envId.managerId && - normalizePath(path.resolve(env.sysPrefix)) === createdPrefix, + env.envId.id === createdEnv!.envId.id || + env.environmentPath.fsPath === createdEnv!.environmentPath.fsPath, ); assert.ok(found, 'Created environment should appear in discovery'); @@ -224,11 +244,12 @@ suite('Integration: Environment Creation', function () { return; } + const workspaceUri = workspaceFolders[0].uri; let createdEnv: PythonEnvironment | undefined; try { createdEnv = await runWithBlockedDialogsAsCancellation(() => - api.createEnvironment(creationScope, { quickCreate: true }), + api.createEnvironment(workspaceUri, { quickCreate: true }), ); if (!createdEnv) { this.skip(); @@ -243,7 +264,7 @@ suite('Integration: Environment Creation', function () { await removeCreatedEnvironment(createdEnv); // --- VERIFY: Environment is no longer discoverable --- - await api.refreshEnvironments(creationScope); + await api.refreshEnvironments(workspaceUri); const environments = await api.getEnvironments('all'); const stillExists = environments.some( @@ -285,11 +306,12 @@ suite('Integration: Environment Creation', function () { return; } + const workspaceUri = workspaceFolders[0].uri; let createdEnv: PythonEnvironment | undefined; try { createdEnv = await runWithBlockedDialogsAsCancellation(() => - api.createEnvironment(creationScope, { quickCreate: true }), + api.createEnvironment(workspaceUri, { quickCreate: true }), ); if (!createdEnv) { diff --git a/src/test/managers/builtin/venvUtils.removeVenv.unit.test.ts b/src/test/managers/builtin/venvUtils.removeVenv.unit.test.ts index ea25f971c..514b626a5 100644 --- a/src/test/managers/builtin/venvUtils.removeVenv.unit.test.ts +++ b/src/test/managers/builtin/venvUtils.removeVenv.unit.test.ts @@ -1,8 +1,10 @@ import * as assert from 'assert'; -import * as fse from 'fs-extra'; -import * as os from 'os'; +import type { BigIntStats } from 'fs'; +import fse from 'fs-extra'; +import os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; +import { Uri } from 'vscode'; import { Common } from '../../../common/localize'; import * as windowApis from '../../../common/window.apis'; import * as uvEnvironments from '../../../managers/builtin/uvEnvironments'; @@ -155,6 +157,207 @@ suite('venvUtils removeVenv validation integration', () => { ); }); + suite('venvUtils bounded Windows removal', () => { + const envPath = Uri.file(path.resolve('workspace', 'project', '.venv')).fsPath; + const identity = { dev: 1n, ino: 10n } as BigIntStats; + let clock: sinon.SinonFakeTimers; + let platform: sinon.SinonStub; + let remove: sinon.SinonStub; + let lstat: sinon.SinonStub; + let pathExists: sinon.SinonStub; + let untrack: sinon.SinonStub; + let showError: sinon.SinonStub; + let log: ReturnType; + + const sharingError = () => Object.assign(new Error('File is in use'), { code: 'EPERM' }); + const removeEnvironment = () => + removeVenv(createMockPythonEnvironment({ name: '.venv', envPath }), log, { runHeadless: true }); + + setup(() => { + clock = sinon.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + platform = sinon.stub(os, 'platform').returns('win32'); + pathExists = sinon.stub(fse, 'pathExists').resolves(true); + lstat = sinon.stub(fse, 'lstat').resolves(identity); + remove = sinon.stub(fse, 'remove').resolves(); + untrack = sinon.stub(uvEnvironments, 'removeUvEnvironment').resolves(); + showError = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); + sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => task({} as never, {} as never)); + log = createMockLogOutputChannel(); + }); + + teardown(() => sinon.restore()); + + for (const code of ['EPERM', 'EBUSY']) { + test(`recovers from ${code} after partial deletion, then untracks once`, async () => { + remove.onFirstCall().callsFake(async () => { + pathExists.resolves(false); + throw Object.assign(new Error('Temporary file lock'), { code }); + }); + const pending = removeEnvironment(); + + await clock.tickAsync(0); + sinon.assert.notCalled(untrack); + await clock.tickAsync(100); + + assert.strictEqual(await pending, true); + sinon.assert.calledTwice(remove); + sinon.assert.calledOnce(pathExists); + sinon.assert.calledOnce(untrack); + sinon.assert.notCalled(showError); + }); + } + + test('persistent fast failures share one bounded retry window', async () => { + const attempts: number[] = []; + remove.callsFake(async () => { + attempts.push(performance.now()); + throw sharingError(); + }); + const pending = removeEnvironment(); + await clock.tickAsync(1_000); + + assert.strictEqual(await pending, false); + assert.strictEqual(attempts.length, 5); + assert.ok(attempts.every((time) => time < 500)); + sinon.assert.notCalled(untrack); + sinon.assert.calledOnce(showError); + sinon.assert.calledOnce(log.error as sinon.SinonStub); + }); + + test('does not retry when the initial deletion already consumed the budget', async () => { + remove.callsFake(async () => { + await new Promise((resolve) => setTimeout(resolve, 600)); + throw sharingError(); + }); + const pending = removeEnvironment(); + await clock.tickAsync(1_000); + + assert.strictEqual(await pending, false); + sinon.assert.calledOnce(remove); + sinon.assert.notCalled(untrack); + sinon.assert.calledOnce(showError); + }); + + test('does not abandon a slow deletion that is still running', async () => { + remove.callsFake(async () => { + await new Promise((resolve) => setTimeout(resolve, 2_000)); + }); + const pending = removeEnvironment(); + await clock.tickAsync(1_000); + sinon.assert.notCalled(untrack); + await clock.tickAsync(1_000); + + assert.strictEqual(await pending, true); + sinon.assert.calledOnce(remove); + sinon.assert.calledOnce(untrack); + sinon.assert.notCalled(showError); + }); + + test('does not start another attempt after a delayed timer exhausts the budget', async () => { + remove.rejects(sharingError()); + const pending = removeEnvironment(); + await clock.tickAsync(0); + sinon.stub(performance, 'now').returns(1_000); + await clock.tickAsync(100); + + assert.strictEqual(await pending, false); + sinon.assert.calledOnce(remove); + sinon.assert.notCalled(untrack); + }); + + test('does not retry after an identity recheck consumes the remaining budget', async () => { + remove.rejects(sharingError()); + lstat.onSecondCall().callsFake(async () => { + await new Promise((resolve) => setTimeout(resolve, 600)); + return identity; + }); + const pending = removeEnvironment(); + await clock.tickAsync(1_000); + + assert.strictEqual(await pending, false); + sinon.assert.calledOnce(remove); + sinon.assert.notCalled(untrack); + }); + + test('does not delete a replacement directory at the same path', async () => { + remove.rejects(sharingError()); + lstat.onSecondCall().resolves({ ...identity, ino: identity.ino + 1n }); + const pending = removeEnvironment(); + await clock.tickAsync(100); + + assert.strictEqual(await pending, false); + sinon.assert.calledOnce(remove); + sinon.assert.notCalled(untrack); + sinon.assert.calledOnce(showError); + }); + + test('treats a directory removed by another operation as successfully removed', async () => { + remove.rejects(sharingError()); + lstat.onSecondCall().rejects(Object.assign(new Error('Already removed'), { code: 'ENOENT' })); + const pending = removeEnvironment(); + await clock.tickAsync(100); + + assert.strictEqual(await pending, true); + sinon.assert.calledOnce(remove); + sinon.assert.calledOnce(untrack); + sinon.assert.notCalled(showError); + }); + + test('keeps the original single-attempt behavior outside Windows', async () => { + platform.returns('linux'); + remove.rejects(sharingError()); + + assert.strictEqual(await removeEnvironment(), false); + sinon.assert.calledOnce(remove); + sinon.assert.notCalled(lstat); + sinon.assert.notCalled(untrack); + }); + + test('does not retry non-sharing errors', async () => { + remove.rejects(Object.assign(new Error('Permission denied'), { code: 'EACCES' })); + + assert.strictEqual(await removeEnvironment(), false); + sinon.assert.calledOnce(remove); + sinon.assert.notCalled(untrack); + sinon.assert.calledOnce(showError); + }); + + test('identity lookup failure does not prevent the original deletion attempt', async () => { + lstat.rejects(Object.assign(new Error('Identity unavailable'), { code: 'EACCES' })); + + assert.strictEqual(await removeEnvironment(), true); + sinon.assert.calledOnce(remove); + sinon.assert.calledOnce(untrack); + }); + + test('does not retry when the filesystem provides no usable directory identity', async () => { + lstat.resolves({ ...identity, ino: 0n }); + remove.rejects(sharingError()); + + assert.strictEqual(await removeEnvironment(), false); + sinon.assert.calledOnce(remove); + sinon.assert.notCalled(untrack); + }); + + test('never retries deletion after an untracking failure', async () => { + untrack.rejects(sharingError()); + + assert.strictEqual(await removeEnvironment(), false); + sinon.assert.calledOnce(remove); + sinon.assert.calledOnce(untrack); + sinon.assert.calledOnce(showError); + }); + + test('never attempts deletion when venv validation fails', async () => { + pathExists.resolves(false); + + assert.strictEqual(await removeEnvironment(), false); + sinon.assert.notCalled(remove); + sinon.assert.notCalled(lstat); + sinon.assert.notCalled(untrack); + }); + }); + test('headless removal skips confirmation and removes the environment', async () => { const tempRoot = await fse.mkdtemp(path.join(os.tmpdir(), 'remove-venv-')); const envPath = path.join(tempRoot, '.venv');