From c7d13a0df3a94dc4433da5780a7e3d58088c146b Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Thu, 23 Jul 2026 16:25:25 -0700 Subject: [PATCH 1/4] Add PEP 723 inline script environment creation Implement InlineScriptEnvManager.create(): select a compatible base interpreter and build or reuse a dependency-keyed virtual environment, with cache-ownership validation, cross-process locking, and cancellation-safe creation. Wire the manager's collaborators. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 39dcc6a3-0fbd-4f36-9d0f-68677de49c27 --- src/extension.ts | 11 +- .../builtin/inlineScriptEnvManager.ts | 413 ++++++++- src/managers/builtin/inlineScriptMain.ts | 15 +- .../inlineScriptEnvManager.unit.test.ts | 841 ++++++++++++++++-- .../builtin/inlineScriptMain.unit.test.ts | 14 +- 5 files changed, 1181 insertions(+), 113 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index e574eeab..fad12bb2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -657,7 +657,16 @@ export async function activate(context: ExtensionContext): Promise>(); + private readonly _onDidChangeEnvironments = new EventEmitter(); public readonly onDidChangeEnvironments: Event = this._onDidChangeEnvironments.event; @@ -39,7 +75,67 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { ); public readonly iconPath: IconPath = new ThemeIcon('file-code'); - constructor(public readonly log: LogOutputChannel) {} + constructor( + private readonly nativeFinder: NativePythonFinder, + private readonly api: PythonEnvironmentApi, + private readonly baseManager: EnvironmentManager, + private readonly globalStorageUri: Uri, + public readonly log: LogOutputChannel, + ) {} + + async create( + scope: CreateEnvironmentScope, + options?: CreateEnvironmentOptions, + ): Promise { + try { + const scriptUri = this.getScriptUri(scope); + if (!scriptUri) { + this.log.warn('Inline-script environment creation requires exactly one local file URI.'); + return undefined; + } + + const metadata = await readInlineScriptMetadataFromFile(scriptUri); + if (!metadata) { + this.log.warn(`No valid PEP 723 metadata found in ${scriptUri.fsPath}.`); + return undefined; + } + + const packages = [...(metadata.dependencies ?? []), ...(options?.additionalPackages ?? [])].map((value) => + value.trim(), + ); + if (packages.some((value) => value.length === 0)) { + this.log.warn(`Inline-script dependencies must not contain empty entries: ${scriptUri.fsPath}.`); + return undefined; + } + + const selectedBase = await this.selectBaseInterpreter(metadata); + if (!selectedBase) { + this.log.warn(`No installed Python satisfies the inline-script requirements for ${scriptUri.fsPath}.`); + return undefined; + } + const cacheKey = computeCacheKey({ + dependencies: packages, + interpreterPath: selectedBase.canonicalPath, + }); + const pending = this.pendingCreations.get(cacheKey); + if (pending) { + return await pending; + } + + const creation = this.createOrReuseEnvironment(cacheKey, packages, metadata, selectedBase); + this.pendingCreations.set(cacheKey, creation); + try { + return await creation; + } finally { + if (this.pendingCreations.get(cacheKey) === creation) { + this.pendingCreations.delete(cacheKey); + } + } + } catch (error) { + this.log.error(`Failed to set up inline-script environment: ${this.errorMessage(error)}`); + return undefined; + } + } async refresh(_scope: RefreshEnvironmentsScope): Promise { return; @@ -61,8 +157,309 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return undefined; } + private getScriptUri(scope: CreateEnvironmentScope): Uri | undefined { + const uri = scope instanceof Uri ? scope : Array.isArray(scope) && scope.length === 1 ? scope[0] : undefined; + return uri?.scheme === 'file' ? uri : undefined; + } + + private async selectBaseInterpreter(metadata: InlineScriptMetadata): Promise { + const reported = (await this.api.getEnvironments('global')).filter( + (environment) => + BASE_INTERPRETER_MANAGER_IDS.has(environment.envId.managerId) && + (environment.envId.managerId !== CONDA_MANAGER_ID || environment.name === 'base'), + ); + const derivedChecks = await Promise.all( + reported.map(async (environment) => { + if (!path.isAbsolute(environment.sysPrefix)) { + this.log.warn( + `Skipping base interpreter with a non-absolute sysPrefix: ${environment.sysPrefix || ''}.`, + ); + return { environment, derived: true }; + } + return { + environment, + derived: await fs.pathExists(path.join(environment.sysPrefix, 'pyvenv.cfg')), + }; + }), + ); + let candidates = derivedChecks + .filter((candidate) => !candidate.derived) + .map((candidate) => candidate.environment); + + while (candidates.length > 0) { + const environment = pickCompatibleInterpreter(candidates, metadata.requiresPython); + if (!environment) { + return undefined; + } + candidates = candidates.filter((candidate) => candidate !== environment); + + const executable = environment.execInfo?.run.executable; + if (!executable) { + continue; + } + try { + return { environment, canonicalPath: await fs.realpath(executable) }; + } catch (error) { + this.log.warn( + `Skipping base interpreter that cannot be resolved at ${executable}: ${this.errorMessage(error)}`, + ); + } + } + + return undefined; + } + + private async createOrReuseEnvironment( + cacheKey: string, + packages: ReadonlyArray, + metadata: InlineScriptMetadata, + selectedBase: SelectedBaseInterpreter, + ): Promise { + const cacheRoot = getScriptEnvCacheRoot(this.globalStorageUri); + const envDir = getScriptEnvDir(this.globalStorageUri, cacheKey); + await fs.ensureDir(cacheRoot.fsPath); + + let lock: AcquiredFileLock | undefined; + try { + lock = await acquireFileLock(envDir.fsPath, { + timeoutMs: CACHE_LOCK_TIMEOUT_MS, + retryIntervalMs: CACHE_LOCK_RETRY_MS, + }); + + const cached = await this.inspectCacheEntry(cacheRoot, envDir, metadata, selectedBase); + if (cached.kind === 'reusable') { + return cached.environment; + } + if (cached.kind === 'uncertain') { + this.log.warn( + `Preserving an inline-script cache entry that could not be safely inspected: ${envDir.fsPath}`, + ); + return undefined; + } + if (cached.kind === 'stale') { + if (!(await this.removeCacheEntry(envDir))) { + return undefined; + } + } + + const build = await this.buildCacheEntry(envDir, cacheRoot, packages, selectedBase); + if (build.retainLock) { + try { + await lock.retain(); + } catch (error) { + this.log.error( + `Failed to mark the inline-script cache lock as retained: ${this.errorMessage(error)}`, + ); + } + } + return build.environment; + } catch (error) { + this.log.error(`Failed to create or reuse inline-script cache entry: ${this.errorMessage(error)}`); + return undefined; + } finally { + if (lock) { + try { + await lock.release(); + } catch (error) { + this.log.warn(`Failed to release inline-script cache lock: ${this.errorMessage(error)}`); + } + } + } + } + + private async inspectCacheEntry( + cacheRoot: Uri, + envDir: Uri, + metadata: InlineScriptMetadata, + selectedBase: SelectedBaseInterpreter, + ): Promise { + try { + const stat = await fs.lstat(envDir.fsPath); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + return { kind: 'uncertain' }; + } + } catch (error) { + return this.isFileNotFoundError(error) ? { kind: 'absent' } : { kind: 'uncertain' }; + } + + let resolvedEntry: string | undefined; + try { + resolvedEntry = await resolveCacheEntryPath(cacheRoot, envDir); + } catch (error) { + this.log.warn(`Failed to resolve inline-script cache entry: ${this.errorMessage(error)}`); + return { kind: 'uncertain' }; + } + if (!resolvedEntry) { + return { kind: 'uncertain' }; + } + + let sidecarResult; + try { + sidecarResult = await inspectMetaJson(envDir); + } catch { + return { kind: 'uncertain' }; + } + if (sidecarResult.kind !== 'valid') { + return { kind: sidecarResult.kind === 'unavailable' ? 'uncertain' : 'stale' }; + } + const sidecar = sidecarResult.metadata; + if ( + normalizePath(sidecar.baseInterpreterPath) !== normalizePath(selectedBase.canonicalPath) || + sidecar.baseInterpreterVersion !== selectedBase.environment.version + ) { + return { kind: 'stale' }; + } + + const baseInterpreterStatus = await getBaseInterpreterStatus(envDir); + if (baseInterpreterStatus !== 'available') { + return { kind: baseInterpreterStatus === 'missing' ? 'stale' : 'uncertain' }; + } + + const environment = await resolveVenvPythonEnvironmentPath( + getVenvPythonPath(envDir.fsPath), + this.nativeFinder, + this.api, + this, + this.baseManager, + ); + if (!environment) { + return { kind: 'uncertain' }; + } + const environmentStatus = await inspectOwnedCacheEntry(environment, cacheRoot, envDir); + if (environmentStatus !== 'expected') { + return { kind: environmentStatus }; + } + const releaseComparison = this.comparePythonReleases(environment.version, selectedBase.environment.version); + if (releaseComparison !== 'same') { + return { kind: 'stale' }; + } + const requiresPython = metadata.requiresPython?.trim(); + if (requiresPython && !matchesPythonVersion(requiresPython, environment.version)) { + return { kind: 'stale' }; + } + + try { + await writeMetaJson(envDir, { ...sidecar, lastUsedAt: new Date().toISOString() }); + } catch (error) { + this.log.warn(`Failed to update inline-script cache metadata: ${this.errorMessage(error)}`); + } + return { kind: 'reusable', environment }; + } + + private async buildCacheEntry( + envDir: Uri, + cacheRoot: Uri, + packages: ReadonlyArray, + selectedBase: SelectedBaseInterpreter, + ): Promise { + let result; + try { + result = await createWithProgress( + this.nativeFinder, + this.api, + this.log, + this, + selectedBase.environment, + cacheRoot, + envDir.fsPath, + { install: [...packages], uninstall: [] }, + { trackUvEnvironment: false }, + ); + } catch (error) { + this.log.error(`Failed to build inline-script environment: ${this.errorMessage(error)}`); + await this.removeCacheEntry(envDir); + return {}; + } + + if (result?.pkgInstallationCancelled) { + this.log.warn( + 'Inline-script package installation was cancelled; retaining the cache lock until explicit cleanup.', + ); + return { retainLock: true }; + } + if (!result?.environment || result.envCreationErr || result.pkgInstallationErr) { + const error = + result?.envCreationErr ?? result?.pkgInstallationErr ?? 'environment creation returned no result'; + this.log.error(`Failed to build inline-script environment: ${error}`); + await this.removeCacheEntry(envDir); + return {}; + } + if ( + this.comparePythonReleases(result.environment.version, selectedBase.environment.version) !== 'same' || + (await inspectOwnedCacheEntry(result.environment, cacheRoot, envDir)) !== 'expected' + ) { + this.log.error('Created inline-script environment does not match the requested cache entry.'); + await this.removeCacheEntry(envDir); + return {}; + } + + const sidecar: InlineScriptEnvMeta = { + schemaVersion: META_SCHEMA_VERSION, + baseInterpreterPath: selectedBase.canonicalPath, + baseInterpreterVersion: selectedBase.environment.version, + lastUsedAt: new Date().toISOString(), + }; + try { + await writeMetaJson(envDir, sidecar); + } catch (error) { + this.log.error(`Failed to record inline-script cache metadata: ${this.errorMessage(error)}`); + await this.removeCacheEntry(envDir); + return {}; + } + + return { environment: result.environment }; + } + + private async removeCacheEntry(envDir: Uri): Promise { + try { + await fs.remove(envDir.fsPath); + return true; + } catch (error) { + this.log.error(`Failed to remove incomplete inline-script environment: ${this.errorMessage(error)}`); + return false; + } + } + + private isFileNotFoundError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); + } + + private comparePythonReleases(actual: string, expected: string): PythonReleaseComparison { + const actualRelease = parseReleaseSegments(actual); + const expectedRelease = parseReleaseSegments(expected); + if (actualRelease === undefined || expectedRelease === undefined) { + return 'uncertain'; + } + return compareReleaseSegments(actualRelease, expectedRelease) === 0 ? 'same' : 'different'; + } + + private errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } + dispose(): void { this._onDidChangeEnvironments.dispose(); this._onDidChangeEnvironment.dispose(); } } + +interface SelectedBaseInterpreter { + readonly environment: PythonEnvironment; + readonly canonicalPath: string; +} + +interface BuildCacheEntryResult { + readonly environment?: PythonEnvironment; + readonly retainLock?: boolean; +} + +type CacheEntryInspection = + | { readonly kind: 'absent' | 'stale' | 'uncertain' } + | { readonly kind: 'reusable'; readonly environment: PythonEnvironment }; + +type PythonReleaseComparison = 'same' | 'different' | 'uncertain'; diff --git a/src/managers/builtin/inlineScriptMain.ts b/src/managers/builtin/inlineScriptMain.ts index fcca5643..6b53c058 100644 --- a/src/managers/builtin/inlineScriptMain.ts +++ b/src/managers/builtin/inlineScriptMain.ts @@ -1,11 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { Disposable, LogOutputChannel } from 'vscode'; -import { PythonEnvironmentApi } from '../../api'; +import { Disposable, LogOutputChannel, Uri } from 'vscode'; +import { EnvironmentManager, PythonEnvironmentApi } from '../../api'; import { traceInfo, traceVerbose } from '../../common/logging'; import { getPythonApi } from '../../features/pythonApi'; import { isInlineScriptsFeatureEnabled } from '../../helpers'; +import { NativePythonFinder } from '../common/nativePythonFinder'; import { InlineScriptEnvManager } from './inlineScriptEnvManager'; /** @@ -13,14 +14,20 @@ import { InlineScriptEnvManager } from './inlineScriptEnvManager'; * `python-envs.inlineScripts.enabled` flag is true. The flag is * undeclared in `package.json`, so default users see nothing. */ -export async function registerInlineScriptFeatures(disposables: Disposable[], log: LogOutputChannel): Promise { +export async function registerInlineScriptFeatures( + nativeFinder: NativePythonFinder, + disposables: Disposable[], + log: LogOutputChannel, + baseManager: EnvironmentManager, + globalStorageUri: Uri, +): Promise { if (!isInlineScriptsFeatureEnabled()) { traceVerbose('Inline-script env manager: skipping registration (internal flag is off)'); return; } const api: PythonEnvironmentApi = await getPythonApi(); - const mgr = new InlineScriptEnvManager(log); + const mgr = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, log); disposables.push(mgr, api.registerEnvironmentManager(mgr)); traceInfo('Inline-script env manager: registered (internal flag is on)'); } diff --git a/src/test/managers/builtin/inlineScriptEnvManager.unit.test.ts b/src/test/managers/builtin/inlineScriptEnvManager.unit.test.ts index 91e1bd84..025f858d 100644 --- a/src/test/managers/builtin/inlineScriptEnvManager.unit.test.ts +++ b/src/test/managers/builtin/inlineScriptEnvManager.unit.test.ts @@ -2,160 +2,809 @@ // Licensed under the MIT License. import assert from 'assert'; +import * as fs from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; import * as sinon from 'sinon'; import { LogOutputChannel, Uri } from 'vscode'; -import { EnvironmentManager, PythonEnvironment } from '../../../api'; +import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi } from '../../../api'; +import * as cacheKey from '../../../common/inlineScriptCacheKey'; +import * as cacheLayout from '../../../common/inlineScriptCacheLayout'; +import * as lockfileApis from '../../../common/lockfile.apis'; +import * as metadataReader from '../../../common/inlineScriptMetadata'; +import { isWindows } from '../../../common/utils/platformUtils'; +import { getVenvPythonPath } from '../../../common/utils/virtualEnvironment'; import { InlineScriptEnvManager } from '../../../managers/builtin/inlineScriptEnvManager'; +import * as venvUtils from '../../../managers/builtin/venvUtils'; +import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; + +const CACHE_KEY = '0123456789abcdef'; +const NOW = new Date('2026-07-21T12:00:00.000Z'); +const VALID_METADATA: metadataReader.InlineScriptMetadata = { + requiresPython: '>=3.11', + dependencies: ['requests'], + range: { start: 0, end: 40 }, +}; function makeFakeLog(): LogOutputChannel { - return sinon.createStubInstance( - class { - info() {} - warn() {} - error() {} - debug() {} - trace() {} - show() {} - dispose() {} - append() {} - appendLine() {} - replace() {} - clear() {} - hide() {} - }, - ) as unknown as LogOutputChannel; + return { + info: sinon.stub(), + warn: sinon.stub(), + error: sinon.stub(), + debug: sinon.stub(), + trace: sinon.stub(), + show: sinon.stub(), + dispose: sinon.stub(), + append: sinon.stub(), + appendLine: sinon.stub(), + replace: sinon.stub(), + clear: sinon.stub(), + hide: sinon.stub(), + } as unknown as LogOutputChannel; } -function makeEnv(): PythonEnvironment { +function makeEnvironment( + managerId: string, + version: string, + executable: string, + sysPrefix: string = path.dirname(executable), + name: string = `Python ${version}`, +): PythonEnvironment { return { - envId: { id: 'fake', managerId: 'ms-python.python:inline-script' }, - name: 'fake', - displayName: 'fake', - displayPath: '/fake', - version: '3.12.0', - environmentPath: Uri.file('/fake'), - execInfo: { run: { executable: '/fake' } }, - sysPrefix: '/fake', + envId: { id: `${managerId}-${version}-${executable}`, managerId }, + name, + displayName: `Python ${version}`, + displayPath: executable, + version, + environmentPath: Uri.file(executable), + execInfo: { run: { executable } }, + sysPrefix, }; } -suite('InlineScriptEnvManager (skeleton)', () => { - let mgr: InlineScriptEnvManager; +const venvPythonPath = getVenvPythonPath; + +suite('InlineScriptEnvManager', () => { + let api: PythonEnvironmentApi; + let apiGetEnvironmentsStub: sinon.SinonStub; + let baseEnvironment: PythonEnvironment; + let baseExecutable: string; + let baseManager: EnvironmentManager; + let computeCacheKeyStub: sinon.SinonStub; + let createWithProgressStub: sinon.SinonStub; + let globalStorageUri: Uri; + let lockStub: sinon.SinonStub; + let manager: InlineScriptEnvManager; + let nativeFinder: NativePythonFinder; + let readMetadataStub: sinon.SinonStub; + let inspectMetaStub: sinon.SinonStub; + let retainLockStub: sinon.SinonStub; + let releaseLockStub: sinon.SinonStub; + let resolveVenvStub: sinon.SinonStub; + let tempRoot: string; + let baseInterpreterStatusStub: sinon.SinonStub; + let writeMetaStub: sinon.SinonStub; + + setup(async () => { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'inline-script-manager-')); + globalStorageUri = Uri.file(path.join(tempRoot, 'global-storage')); + baseExecutable = path.join(tempRoot, 'base-python', isWindows() ? 'python.exe' : 'python'); + await fs.outputFile(baseExecutable, ''); + baseEnvironment = makeEnvironment('ms-python.python:system', '3.12.4', baseExecutable); + + apiGetEnvironmentsStub = sinon.stub().resolves([baseEnvironment]); + api = { getEnvironments: apiGetEnvironmentsStub } as unknown as PythonEnvironmentApi; + nativeFinder = {} as NativePythonFinder; + baseManager = {} as EnvironmentManager; + + readMetadataStub = sinon.stub(metadataReader, 'readInlineScriptMetadataFromFile').resolves(VALID_METADATA); + computeCacheKeyStub = sinon.stub(cacheKey, 'computeCacheKey').returns(CACHE_KEY); + inspectMetaStub = sinon.stub(cacheLayout, 'inspectMetaJson').resolves({ kind: 'missing' }); + baseInterpreterStatusStub = sinon.stub(cacheLayout, 'getBaseInterpreterStatus').resolves('available'); + writeMetaStub = sinon.stub(cacheLayout, 'writeMetaJson').resolves(); + retainLockStub = sinon.stub().resolves(); + releaseLockStub = sinon.stub().resolves(); + lockStub = sinon + .stub(lockfileApis, 'acquireFileLock') + .resolves({ release: releaseLockStub, retain: retainLockStub }); + resolveVenvStub = sinon.stub(venvUtils, 'resolveVenvPythonEnvironmentPath').resolves(undefined); + createWithProgressStub = sinon.stub(venvUtils, 'createWithProgress').callsFake(async (...args: unknown[]) => { + const envDir = args[6] as string; + await fs.outputFile(getVenvPythonPath(envDir), ''); + return { + environment: makeEnvironment( + 'ms-python.python:inline-script', + '3.12.4', + getVenvPythonPath(envDir), + envDir, + ), + }; + }); - setup(() => { - mgr = new InlineScriptEnvManager(makeFakeLog()); + sinon.useFakeTimers({ now: NOW, toFake: ['Date'] }); + manager = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, makeFakeLog()); }); - teardown(() => { - mgr.dispose(); + teardown(async () => { + manager.dispose(); sinon.restore(); + await fs.remove(tempRoot); }); - suite('static metadata', () => { - test('name is "inline-script"', () => { - assert.strictEqual(mgr.name, 'inline-script'); + function scriptUri(name = 'script.py'): Uri { + return Uri.file(path.join(tempRoot, name)); + } + + function envDir(): Uri { + return cacheLayout.getScriptEnvDir(globalStorageUri, CACHE_KEY); + } + + function setSidecar(metadata: cacheLayout.InlineScriptEnvMeta): void { + inspectMetaStub.resolves({ kind: 'valid', metadata }); + } + + suite('static metadata and deferred methods', () => { + test('exposes creation but leaves later-phase methods empty', async () => { + const asInterface: EnvironmentManager = manager; + assert.strictEqual(typeof asInterface.create, 'function'); + assert.strictEqual(asInterface.remove, undefined); + assert.strictEqual(asInterface.quickCreateConfig, undefined); + assert.deepStrictEqual(await manager.getEnvironments('all'), []); + assert.strictEqual(await manager.get(scriptUri()), undefined); + assert.strictEqual(await manager.resolve(scriptUri()), undefined); + }); + + test('retains inline-script manager presentation metadata', () => { + assert.strictEqual(manager.name, 'inline-script'); + assert.ok(manager.displayName); + assert.strictEqual(manager.preferredPackageManagerId, 'ms-python.python:pip'); + assert.ok(manager.iconPath); + assert.ok(manager.tooltip); }); + }); - test('displayName is set (for the picker section header)', () => { - assert.ok(mgr.displayName); - assert.ok(mgr.displayName.length > 0); + suite('scope and metadata validation', () => { + test('rejects global, empty, multiple, and non-file scopes without reading metadata', async () => { + assert.strictEqual(await manager.create('global'), undefined); + assert.strictEqual(await manager.create([]), undefined); + assert.strictEqual(await manager.create([scriptUri('a.py'), scriptUri('b.py')]), undefined); + assert.strictEqual(await manager.create(Uri.parse('untitled:script.py')), undefined); + assert.strictEqual(readMetadataStub.callCount, 0); + assert.strictEqual(lockStub.callCount, 0); }); - test('preferredPackageManagerId is the standard pip manager id', () => { - assert.strictEqual(mgr.preferredPackageManagerId, 'ms-python.python:pip'); + test('accepts a singleton URI array', async () => { + const uri = scriptUri(); + const result = await manager.create([uri]); + assert.ok(result); + assert.ok(readMetadataStub.calledOnceWithExactly(uri)); }); - test('iconPath is defined (renders in the picker)', () => { - assert.ok(mgr.iconPath); + test('returns undefined without cache mutation when metadata is absent', async () => { + readMetadataStub.resolves(undefined); + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(apiGetEnvironmentsStub.callCount, 0); + assert.strictEqual(lockStub.callCount, 0); }); - test('tooltip is defined (shown on hover in the picker)', () => { - assert.ok(mgr.tooltip); + test('rejects empty dependency entries before selecting or locking', async () => { + readMetadataStub.resolves({ ...VALID_METADATA, dependencies: ['requests', ' '] }); + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(apiGetEnvironmentsStub.callCount, 0); + assert.strictEqual(lockStub.callCount, 0); }); }); - suite('skeleton method behavior', () => { - test('getEnvironments("all") returns []', async () => { - assert.deepStrictEqual(await mgr.getEnvironments('all'), []); + suite('base interpreter selection', () => { + test('excludes derived managers even when they report newer global environments', async () => { + const pipenv = makeEnvironment('ms-python.python:pipenv', '3.14.0', baseExecutable); + apiGetEnvironmentsStub.resolves([pipenv, baseEnvironment]); + + await manager.create(scriptUri()); + + assert.strictEqual(createWithProgressStub.firstCall.args[4], baseEnvironment); }); - test('getEnvironments("global") returns []', async () => { - assert.deepStrictEqual(await mgr.getEnvironments('global'), []); + test('excludes named conda environments even when they are newer than conda base', async () => { + const condaNamed = makeEnvironment( + 'ms-python.python:conda', + '3.14.0', + baseExecutable, + undefined, + 'project-env', + ); + const condaBase = makeEnvironment( + 'ms-python.python:conda', + '3.11.9', + baseExecutable, + undefined, + 'base', + ); + apiGetEnvironmentsStub.resolves([condaNamed, condaBase]); + + await manager.create(scriptUri()); + + assert.strictEqual(createWithProgressStub.firstCall.args[4], condaBase); }); - test('getEnvironments(Uri) returns []', async () => { - assert.deepStrictEqual(await mgr.getEnvironments(Uri.file('/tmp/script.py')), []); + test('falls back when the newest compatible interpreter cannot be canonicalized', async () => { + const missingExecutable = path.join(tempRoot, 'missing', 'python'); + const newest = makeEnvironment('ms-python.python:system', '3.13.0', missingExecutable); + apiGetEnvironmentsStub.resolves([baseEnvironment, newest]); + + await manager.create(scriptUri()); + + assert.strictEqual(createWithProgressStub.firstCall.args[4], baseEnvironment); }); - test('get(undefined) returns undefined', async () => { - assert.strictEqual(await mgr.get(undefined), undefined); + test('excludes pyenv virtual environments reported in global scope', async () => { + const pyenvVenvRoot = path.join(tempRoot, 'pyenv', 'versions', 'project-env'); + const pyenvVenvExecutable = venvPythonPath(pyenvVenvRoot); + await fs.outputFile(pyenvVenvExecutable, ''); + await fs.outputFile(path.join(pyenvVenvRoot, 'pyvenv.cfg'), 'home = base'); + const pyenvVenv = makeEnvironment( + 'ms-python.python:pyenv', + '3.13.0', + pyenvVenvExecutable, + pyenvVenvRoot, + ); + apiGetEnvironmentsStub.resolves([pyenvVenv, baseEnvironment]); + + await manager.create(scriptUri()); + + assert.strictEqual(createWithProgressStub.firstCall.args[4], baseEnvironment); }); - test('get(Uri) returns undefined', async () => { - assert.strictEqual(await mgr.get(Uri.file('/tmp/script.py')), undefined); + test('does not invoke creation when no installed base satisfies requires-python', async () => { + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '>=3.13' }); + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(lockStub.callCount, 0); + assert.strictEqual(createWithProgressStub.callCount, 0); }); - test('set(scope, env) is a no-op and does not throw', async () => { - await assert.doesNotReject(mgr.set(Uri.file('/tmp/script.py'), makeEnv())); - await assert.doesNotReject(mgr.set(undefined, undefined)); + test('skips base records with empty or relative sysPrefix values', async () => { + const emptyPrefixExecutable = path.join(tempRoot, 'empty-prefix-python'); + const relativePrefixExecutable = path.join(tempRoot, 'relative-prefix-python'); + await fs.outputFile(emptyPrefixExecutable, ''); + await fs.outputFile(relativePrefixExecutable, ''); + apiGetEnvironmentsStub.resolves([ + makeEnvironment('ms-python.python:system', '3.14.0', emptyPrefixExecutable, ''), + makeEnvironment('ms-python.python:system', '3.13.0', relativePrefixExecutable, 'relative-prefix'), + baseEnvironment, + ]); + + assert.ok(await manager.create(scriptUri())); + sinon.assert.calledWith(computeCacheKeyStub, { + dependencies: ['requests'], + interpreterPath: await fs.realpath(baseExecutable), + }); }); + }); + + suite('cache creation', () => { + test('hashes and installs metadata plus additional packages, then writes the sidecar', async () => { + const result = await manager.create(scriptUri(), { additionalPackages: ['pytest'] }); - test('refresh(scope) is a no-op and does not throw', async () => { - await assert.doesNotReject(mgr.refresh(undefined)); - await assert.doesNotReject(mgr.refresh(Uri.file('/tmp/script.py'))); + assert.ok(result); + assert.deepStrictEqual(computeCacheKeyStub.firstCall.args[0], { + dependencies: ['requests', 'pytest'], + interpreterPath: baseExecutable, + }); + assert.strictEqual(createWithProgressStub.firstCall.args[0], nativeFinder); + assert.strictEqual(createWithProgressStub.firstCall.args[1], api); + assert.strictEqual(createWithProgressStub.firstCall.args[3], manager); + assert.strictEqual(createWithProgressStub.firstCall.args[4], baseEnvironment); + assert.strictEqual(createWithProgressStub.firstCall.args[5].fsPath, cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath); + assert.strictEqual(createWithProgressStub.firstCall.args[6], envDir().fsPath); + assert.deepStrictEqual(createWithProgressStub.firstCall.args[7], { + install: ['requests', 'pytest'], + uninstall: [], + }); + assert.deepStrictEqual(writeMetaStub.firstCall.args, [ + envDir(), + { + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: baseExecutable, + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: NOW.toISOString(), + }, + ]); + assert.deepStrictEqual(createWithProgressStub.firstCall.args[8], { trackUvEnvironment: false }); + assert.ok(releaseLockStub.calledOnce); }); - test('resolve(Uri) returns undefined', async () => { - assert.strictEqual(await mgr.resolve(Uri.file('/tmp/script.py')), undefined); + test('uses a bounded cross-process lock at the final cache path', async () => { + await manager.create(scriptUri()); + + assert.strictEqual(lockStub.firstCall.args[0], envDir().fsPath); + const options = lockStub.firstCall.args[1]; + assert.ok(options.timeoutMs > 0); + assert.ok(options.retryIntervalMs > 0); }); - test('does not implement optional create / remove / quickCreateConfig', () => { - // Cast via the interface to probe optional methods (the concrete class type doesn't declare them). - const asInterface: EnvironmentManager = mgr; - assert.strictEqual(asInterface.create, undefined); - assert.strictEqual(asInterface.remove, undefined); - assert.strictEqual(asInterface.quickCreateConfig, undefined); + test('coalesces simultaneous same-key creation within one extension host', async () => { + let continueCreation: (() => void) | undefined; + let creationStarted: (() => void) | undefined; + let secondCallHashed: (() => void) | undefined; + const started = new Promise((resolve) => { + creationStarted = resolve; + }); + const secondHashed = new Promise((resolve) => { + secondCallHashed = resolve; + }); + const gate = new Promise((resolve) => { + continueCreation = resolve; + }); + computeCacheKeyStub.callsFake(() => { + if (computeCacheKeyStub.callCount === 2) { + secondCallHashed!(); + } + return CACHE_KEY; + }); + createWithProgressStub.callsFake(async (...args: unknown[]) => { + const target = args[6] as string; + await fs.outputFile(venvPythonPath(target), ''); + creationStarted!(); + await gate; + return { + environment: makeEnvironment( + 'ms-python.python:inline-script', + '3.12.4', + venvPythonPath(target), + target, + ), + }; + }); + + const first = manager.create(scriptUri('a.py')); + await started; + const second = manager.create(scriptUri('b.py')); + await secondHashed; + continueCreation!(); + const [firstResult, secondResult] = await Promise.all([first, second]); + + assert.strictEqual(firstResult, secondResult); + assert.strictEqual(lockStub.callCount, 1); + assert.strictEqual(createWithProgressStub.callCount, 1); + }); + + test('returns undefined without building when the cache lock cannot be acquired', async () => { + lockStub.rejects(Object.assign(new Error('already locked'), { code: 'ELOCKED' })); + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(createWithProgressStub.callCount, 0); + }); + }); + + suite('cache reuse', () => { + test('returns a valid cached environment and refreshes lastUsedAt', async () => { + await fs.ensureDir(envDir().fsPath); + const sidecar: cacheLayout.InlineScriptEnvMeta = { + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: baseExecutable, + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: '2026-07-01T00:00:00.000Z', + }; + const cached = makeEnvironment( + 'ms-python.python:inline-script', + '3.12.4', + venvPythonPath(envDir().fsPath), + envDir().fsPath, + ); + await fs.outputFile(venvPythonPath(envDir().fsPath), ''); + setSidecar(sidecar); + resolveVenvStub.resolves(cached); + + const result = await manager.create(scriptUri()); + + assert.strictEqual(result, cached); + assert.strictEqual(createWithProgressStub.callCount, 0); + assert.ok(baseInterpreterStatusStub.calledOnceWithExactly(envDir())); + assert.strictEqual(resolveVenvStub.firstCall.args[0], venvPythonPath(envDir().fsPath)); + assert.deepStrictEqual(writeMetaStub.firstCall.args, [ + envDir(), + { ...sidecar, lastUsedAt: NOW.toISOString() }, + ]); + }); + + test('returns a valid hit even when the last-used timestamp cannot be updated', async () => { + await fs.ensureDir(envDir().fsPath); + setSidecar({ + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: baseExecutable, + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: '2026-07-01T00:00:00.000Z', + }); + const cached = makeEnvironment( + 'ms-python.python:inline-script', + '3.12.4', + venvPythonPath(envDir().fsPath), + envDir().fsPath, + ); + await fs.outputFile(venvPythonPath(envDir().fsPath), ''); + resolveVenvStub.resolves(cached); + writeMetaStub.rejects(new Error('read-only filesystem')); + + assert.strictEqual(await manager.create(scriptUri()), cached); + assert.strictEqual(createWithProgressStub.callCount, 0); + }); + + test('removes and rebuilds a cache entry whose sidecar names another base', async () => { + await fs.ensureDir(envDir().fsPath); + setSidecar({ + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: path.join(tempRoot, 'different-python'), + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: NOW.toISOString(), + }); + + const result = await manager.create(scriptUri()); + + assert.ok(result); + assert.strictEqual(resolveVenvStub.callCount, 0); + assert.strictEqual(createWithProgressStub.callCount, 1); + }); + + test('rebuilds when the base version changed at the same canonical path', async () => { + await fs.ensureDir(envDir().fsPath); + setSidecar({ + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: baseExecutable, + baseInterpreterVersion: '3.11.9', + lastUsedAt: NOW.toISOString(), + }); + + assert.ok(await manager.create(scriptUri())); + assert.strictEqual(resolveVenvStub.callCount, 0); + assert.strictEqual(createWithProgressStub.callCount, 1); + }); + + test('preserves the entry when resolution returns an environment owned by another manager', async () => { + await fs.outputFile(venvPythonPath(envDir().fsPath), ''); + const markerPath = path.join(envDir().fsPath, 'keep.txt'); + await fs.outputFile(markerPath, 'keep'); + setSidecar({ + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: baseExecutable, + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: NOW.toISOString(), + }); + resolveVenvStub.resolves( + makeEnvironment( + 'ms-python.python:system', + '3.11.9', + venvPythonPath(envDir().fsPath), + envDir().fsPath, + ), + ); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.readFile(markerPath, 'utf8'), 'keep'); + assert.strictEqual(createWithProgressStub.callCount, 0); + assert.strictEqual(writeMetaStub.callCount, 0); + }); + + test('rebuilds an inline-owned entry whose resolved version is unparseable', async () => { + await fs.outputFile(getVenvPythonPath(envDir().fsPath), ''); + const markerPath = path.join(envDir().fsPath, 'keep.txt'); + await fs.outputFile(markerPath, 'keep'); + setSidecar({ + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: baseExecutable, + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: NOW.toISOString(), + }); + resolveVenvStub.resolves( + makeEnvironment( + 'ms-python.python:inline-script', + 'Unknown', + getVenvPythonPath(envDir().fsPath), + envDir().fsPath, + ), + ); + + assert.ok(await manager.create(scriptUri())); + assert.strictEqual(await fs.pathExists(markerPath), false); + assert.strictEqual(createWithProgressStub.callCount, 1); + }); + + test('rebuilds when the resolved environment no longer satisfies requires-python', async () => { + await fs.ensureDir(envDir().fsPath); + setSidecar({ + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: baseExecutable, + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: NOW.toISOString(), + }); + await fs.outputFile(venvPythonPath(envDir().fsPath), ''); + resolveVenvStub.resolves( + makeEnvironment( + 'ms-python.python:inline-script', + '3.10.0', + venvPythonPath(envDir().fsPath), + envDir().fsPath, + ), + ); + + assert.ok(await manager.create(scriptUri())); + assert.strictEqual(createWithProgressStub.callCount, 1); + }); + + test('rebuilds when the cached Python differs from the selected base but still satisfies the script', async () => { + await fs.outputFile(venvPythonPath(envDir().fsPath), ''); + setSidecar({ + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: baseExecutable, + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: NOW.toISOString(), + }); + resolveVenvStub.resolves( + makeEnvironment( + 'ms-python.python:inline-script', + '3.11.9', + venvPythonPath(envDir().fsPath), + envDir().fsPath, + ), + ); + + assert.ok(await manager.create(scriptUri())); + assert.strictEqual(createWithProgressStub.callCount, 1); + }); + + for (const metadataKind of ['missing', 'invalid'] as const) { + test(`rebuilds an existing cache entry when metadata is ${metadataKind}`, async () => { + const markerPath = path.join(envDir().fsPath, 'keep.txt'); + await fs.outputFile(markerPath, 'keep'); + inspectMetaStub.resolves({ kind: metadataKind }); + + assert.ok(await manager.create(scriptUri())); + assert.strictEqual(await fs.pathExists(markerPath), false); + assert.strictEqual(createWithProgressStub.callCount, 1); + assert.strictEqual(writeMetaStub.callCount, 1); + }); + } + + test('preserves an existing cache entry when metadata is unavailable', async () => { + const markerPath = path.join(envDir().fsPath, 'keep.txt'); + await fs.outputFile(markerPath, 'keep'); + inspectMetaStub.resolves({ kind: 'unavailable' }); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.readFile(markerPath, 'utf8'), 'keep'); + assert.strictEqual(createWithProgressStub.callCount, 0); + assert.strictEqual(writeMetaStub.callCount, 0); + }); + + test('preserves an existing cache entry when metadata inspection rejects', async () => { + const markerPath = path.join(envDir().fsPath, 'keep.txt'); + await fs.outputFile(markerPath, 'keep'); + inspectMetaStub.rejects(new Error('transient read failure')); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.readFile(markerPath, 'utf8'), 'keep'); + assert.strictEqual(createWithProgressStub.callCount, 0); + }); + + test('preserves an existing cache entry when its base interpreter cannot be inspected', async () => { + const markerPath = path.join(envDir().fsPath, 'keep.txt'); + await fs.outputFile(markerPath, 'keep'); + setSidecar({ + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: baseExecutable, + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: NOW.toISOString(), + }); + baseInterpreterStatusStub.resolves('unavailable'); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.readFile(markerPath, 'utf8'), 'keep'); + assert.strictEqual(createWithProgressStub.callCount, 0); + }); + + test('rebuilds an existing cache entry when its base interpreter is definitively missing', async () => { + await fs.ensureDir(envDir().fsPath); + setSidecar({ + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: baseExecutable, + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: NOW.toISOString(), + }); + baseInterpreterStatusStub.resolves('missing'); + + assert.ok(await manager.create(scriptUri())); + assert.strictEqual(createWithProgressStub.callCount, 1); + }); + + test('does not inspect or modify an entry resolving outside the physical cache root', async function () { + const cacheRoot = cacheLayout.getScriptEnvCacheRoot(globalStorageUri); + const externalEnv = path.join(tempRoot, 'external-env'); + const markerPath = path.join(externalEnv, 'keep.txt'); + await fs.ensureDir(cacheRoot.fsPath); + await fs.outputFile(markerPath, 'keep'); + try { + await fs.symlink(externalEnv, envDir().fsPath, isWindows() ? 'junction' : 'dir'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EPERM' || code === 'EACCES') { + this.skip(); + return; + } + throw error; + } + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.readFile(markerPath, 'utf8'), 'keep'); + assert.strictEqual((await fs.lstat(envDir().fsPath)).isSymbolicLink(), true); + assert.strictEqual(inspectMetaStub.callCount, 0); + assert.strictEqual(writeMetaStub.callCount, 0); + assert.strictEqual(createWithProgressStub.callCount, 0); + }); + + test('does not inspect or modify an entry aliasing another hash directory', async function () { + const cacheRoot = cacheLayout.getScriptEnvCacheRoot(globalStorageUri); + const otherEnv = path.join(cacheRoot.fsPath, 'fedcba9876543210'); + const markerPath = path.join(otherEnv, 'keep.txt'); + await fs.outputFile(markerPath, 'keep'); + try { + await fs.symlink(otherEnv, envDir().fsPath, isWindows() ? 'junction' : 'dir'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EPERM' || code === 'EACCES') { + this.skip(); + return; + } + throw error; + } + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.readFile(markerPath, 'utf8'), 'keep'); + assert.strictEqual((await fs.lstat(envDir().fsPath)).isSymbolicLink(), true); + assert.strictEqual(inspectMetaStub.callCount, 0); + assert.strictEqual(writeMetaStub.callCount, 0); + assert.strictEqual(createWithProgressStub.callCount, 0); }); }); - suite('events', () => { - test('onDidChangeEnvironments is exposed and subscribable', () => { - const disposable = mgr.onDidChangeEnvironments(() => undefined); - assert.ok(disposable); - disposable.dispose(); + suite('transaction rollback', () => { + test('retains the partial environment and lock when package installation is cancelled', async () => { + createWithProgressStub.callsFake(async (...args: unknown[]) => { + const target = args[6] as string; + await fs.outputFile(venvPythonPath(target), ''); + return { + environment: makeEnvironment( + 'ms-python.python:inline-script', + '3.12.4', + venvPythonPath(target), + target, + ), + pkgInstallationErr: 'Canceled', + pkgInstallationCancelled: true, + }; + }); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.pathExists(envDir().fsPath), true); + assert.strictEqual(writeMetaStub.callCount, 0); + assert.ok(retainLockStub.calledOnce); + assert.ok(releaseLockStub.calledOnce); }); - test('onDidChangeEnvironment is exposed and subscribable', () => { - const disposable = mgr.onDidChangeEnvironment(() => undefined); - assert.ok(disposable); - disposable.dispose(); + test('keeps a failed lock-retain transition fail-closed', async () => { + createWithProgressStub.resolves({ + environment: makeEnvironment( + 'ms-python.python:inline-script', + '3.12.4', + venvPythonPath(envDir().fsPath), + envDir().fsPath, + ), + pkgInstallationErr: 'Canceled', + pkgInstallationCancelled: true, + }); + retainLockStub.rejects(Object.assign(new Error('retention failed'), { code: 'EACCES' })); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.ok(retainLockStub.calledOnce); + assert.ok(releaseLockStub.calledOnce); }); - test('skeleton methods do not fire any events', async () => { - const envsListener = sinon.spy(); - const envListener = sinon.spy(); - mgr.onDidChangeEnvironments(envsListener); - mgr.onDidChangeEnvironment(envListener); + test('removes the partial environment when package installation fails', async () => { + createWithProgressStub.callsFake(async (...args: unknown[]) => { + const target = args[6] as string; + await fs.ensureDir(target); + return { + environment: makeEnvironment( + 'ms-python.python:inline-script', + '3.12.4', + venvPythonPath(target), + target, + ), + pkgInstallationErr: 'network failure', + }; + }); - await mgr.getEnvironments('all'); - await mgr.get(undefined); - await mgr.set(Uri.file('/tmp/script.py'), makeEnv()); - await mgr.refresh(undefined); - await mgr.resolve(Uri.file('/tmp/script.py')); + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.pathExists(envDir().fsPath), false); + assert.strictEqual(writeMetaStub.callCount, 0); + assert.ok(releaseLockStub.calledOnce); + }); - assert.strictEqual(envsListener.callCount, 0, 'getEnvironments/refresh must not fire envs event'); - assert.strictEqual(envListener.callCount, 0, 'set must not fire env event in the skeleton'); + test('removes the new environment when sidecar writing fails', async () => { + writeMetaStub.rejects(new Error('disk full')); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.pathExists(envDir().fsPath), false); + assert.ok(releaseLockStub.calledOnce); + }); + + test('removes a partial environment when createWithProgress throws', async () => { + createWithProgressStub.callsFake(async (...args: unknown[]) => { + await fs.ensureDir(args[6] as string); + throw new Error('unexpected create failure'); + }); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.pathExists(envDir().fsPath), false); + assert.ok(releaseLockStub.calledOnce); + }); + + test('rejects and removes a created environment with a different Python release', async () => { + createWithProgressStub.callsFake(async (...args: unknown[]) => { + const target = args[6] as string; + await fs.outputFile(venvPythonPath(target), ''); + return { + environment: makeEnvironment( + 'ms-python.python:inline-script', + '3.11.9', + venvPythonPath(target), + target, + ), + }; + }); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.pathExists(envDir().fsPath), false); + assert.strictEqual(writeMetaStub.callCount, 0); + }); + + test('rejects and removes a created environment outside the expected cache directory', async () => { + createWithProgressStub.callsFake(async (...args: unknown[]) => { + const target = args[6] as string; + const otherRoot = path.join(tempRoot, 'unexpected-env'); + const otherPython = venvPythonPath(otherRoot); + await fs.outputFile(venvPythonPath(target), ''); + await fs.outputFile(otherPython, ''); + return { + environment: makeEnvironment( + 'ms-python.python:inline-script', + '3.12.4', + otherPython, + otherRoot, + ), + }; + }); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.pathExists(envDir().fsPath), false); + assert.strictEqual(writeMetaStub.callCount, 0); }); }); - suite('disposal', () => { - test('dispose() does not throw', () => { - assert.doesNotThrow(() => mgr.dispose()); + suite('events and disposal', () => { + test('create does not establish an association or fire later-phase events', async () => { + const environmentsListener = sinon.spy(); + const environmentListener = sinon.spy(); + manager.onDidChangeEnvironments(environmentsListener); + manager.onDidChangeEnvironment(environmentListener); + + assert.ok(await manager.create(scriptUri())); + assert.deepStrictEqual(await manager.getEnvironments('all'), []); + assert.strictEqual(await manager.get(scriptUri()), undefined); + assert.strictEqual(environmentsListener.callCount, 0); + assert.strictEqual(environmentListener.callCount, 0); }); - test('dispose() is idempotent', () => { - mgr.dispose(); - assert.doesNotThrow(() => mgr.dispose()); + test('dispose is idempotent', () => { + manager.dispose(); + assert.doesNotThrow(() => manager.dispose()); }); }); }); diff --git a/src/test/managers/builtin/inlineScriptMain.unit.test.ts b/src/test/managers/builtin/inlineScriptMain.unit.test.ts index 4d5ac3a3..7b25b1ad 100644 --- a/src/test/managers/builtin/inlineScriptMain.unit.test.ts +++ b/src/test/managers/builtin/inlineScriptMain.unit.test.ts @@ -3,11 +3,12 @@ import assert from 'assert'; import * as sinon from 'sinon'; -import { Disposable, LogOutputChannel } from 'vscode'; -import { PythonEnvironmentApi } from '../../../api'; +import { Disposable, LogOutputChannel, Uri } from 'vscode'; +import { EnvironmentManager, PythonEnvironmentApi } from '../../../api'; import * as pythonApi from '../../../features/pythonApi'; import * as helpers from '../../../helpers'; import { registerInlineScriptFeatures } from '../../../managers/builtin/inlineScriptMain'; +import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; function makeFakeLog(): LogOutputChannel { return { @@ -30,6 +31,9 @@ suite('registerInlineScriptFeatures (feature-flag gate)', () => { let isEnabledStub: sinon.SinonStub; let getPythonApiStub: sinon.SinonStub; let registerEnvironmentManagerStub: sinon.SinonStub; + const nativeFinder = {} as NativePythonFinder; + const baseManager = {} as EnvironmentManager; + const globalStorageUri = Uri.file('inline-script-global-storage'); setup(() => { isEnabledStub = sinon.stub(helpers, 'isInlineScriptsFeatureEnabled'); @@ -47,7 +51,7 @@ suite('registerInlineScriptFeatures (feature-flag gate)', () => { isEnabledStub.returns(false); const disposables: Disposable[] = []; - await registerInlineScriptFeatures(disposables, makeFakeLog()); + await registerInlineScriptFeatures(nativeFinder, disposables, makeFakeLog(), baseManager, globalStorageUri); assert.strictEqual(disposables.length, 0, 'no disposables should be added when flag is off'); assert.strictEqual(getPythonApiStub.called, false, 'should not even call getPythonApi when gated off'); @@ -58,10 +62,12 @@ suite('registerInlineScriptFeatures (feature-flag gate)', () => { isEnabledStub.returns(true); const disposables: Disposable[] = []; - await registerInlineScriptFeatures(disposables, makeFakeLog()); + await registerInlineScriptFeatures(nativeFinder, disposables, makeFakeLog(), baseManager, globalStorageUri); assert.strictEqual(getPythonApiStub.callCount, 1); assert.strictEqual(registerEnvironmentManagerStub.callCount, 1); assert.strictEqual(disposables.length, 2, 'expected manager + registration disposable'); + const manager = registerEnvironmentManagerStub.firstCall.args[0]; + assert.strictEqual(typeof manager.create, 'function'); }); }); From b4c915d701d159d239ef8683a4438ee168ccccf6 Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Wed, 29 Jul 2026 14:19:04 -0700 Subject: [PATCH 2/4] Adapt inline manager to tracking boolean Use the merged createWithProgress boolean parameter while preserving that inline-script cache entries are not tracked as workspace uv environments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 39dcc6a3-0fbd-4f36-9d0f-68677de49c27 --- src/managers/builtin/inlineScriptEnvManager.ts | 2 +- .../managers/builtin/inlineScriptEnvManager.unit.test.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/managers/builtin/inlineScriptEnvManager.ts b/src/managers/builtin/inlineScriptEnvManager.ts index 074afa23..3ccb39f0 100644 --- a/src/managers/builtin/inlineScriptEnvManager.ts +++ b/src/managers/builtin/inlineScriptEnvManager.ts @@ -363,7 +363,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { cacheRoot, envDir.fsPath, { install: [...packages], uninstall: [] }, - { trackUvEnvironment: false }, + false, // trackUvEnvironment ); } catch (error) { this.log.error(`Failed to build inline-script environment: ${this.errorMessage(error)}`); diff --git a/src/test/managers/builtin/inlineScriptEnvManager.unit.test.ts b/src/test/managers/builtin/inlineScriptEnvManager.unit.test.ts index 025f858d..5c5a0533 100644 --- a/src/test/managers/builtin/inlineScriptEnvManager.unit.test.ts +++ b/src/test/managers/builtin/inlineScriptEnvManager.unit.test.ts @@ -309,7 +309,11 @@ suite('InlineScriptEnvManager', () => { lastUsedAt: NOW.toISOString(), }, ]); - assert.deepStrictEqual(createWithProgressStub.firstCall.args[8], { trackUvEnvironment: false }); + assert.strictEqual( + createWithProgressStub.firstCall.args[8], + false, + 'inline-script cache entries must not be tracked as workspace uv environments', + ); assert.ok(releaseLockStub.calledOnce); }); From 6b76d38a61e97a7bbcab7e8a729e7f6dfb945291 Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Wed, 29 Jul 2026 15:07:01 -0700 Subject: [PATCH 3/4] address feedback --- .../inlineScriptEnvManager.unit.test.ts | 21 ++++++++++++++++++- .../builtin/inlineScriptMain.unit.test.ts | 5 +++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/test/managers/builtin/inlineScriptEnvManager.unit.test.ts b/src/test/managers/builtin/inlineScriptEnvManager.unit.test.ts index 5c5a0533..41ccb44d 100644 --- a/src/test/managers/builtin/inlineScriptEnvManager.unit.test.ts +++ b/src/test/managers/builtin/inlineScriptEnvManager.unit.test.ts @@ -86,7 +86,7 @@ suite('InlineScriptEnvManager', () => { let writeMetaStub: sinon.SinonStub; setup(async () => { - tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'inline-script-manager-')); + tempRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'inline-script-manager-'))); globalStorageUri = Uri.file(path.join(tempRoot, 'global-storage')); baseExecutable = path.join(tempRoot, 'base-python', isWindows() ? 'python.exe' : 'python'); await fs.outputFile(baseExecutable, ''); @@ -432,6 +432,25 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(createWithProgressStub.callCount, 0); }); + test('preserves a valid cache entry when its environment cannot be resolved', async () => { + await fs.outputFile(venvPythonPath(envDir().fsPath), ''); + const markerPath = path.join(envDir().fsPath, 'keep.txt'); + await fs.outputFile(markerPath, 'keep'); + setSidecar({ + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: baseExecutable, + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: NOW.toISOString(), + }); + resolveVenvStub.resolves(undefined); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.readFile(markerPath, 'utf8'), 'keep'); + assert.strictEqual(resolveVenvStub.callCount, 1); + assert.strictEqual(createWithProgressStub.callCount, 0); + assert.strictEqual(writeMetaStub.callCount, 0); + }); + test('removes and rebuilds a cache entry whose sidecar names another base', async () => { await fs.ensureDir(envDir().fsPath); setSidecar({ diff --git a/src/test/managers/builtin/inlineScriptMain.unit.test.ts b/src/test/managers/builtin/inlineScriptMain.unit.test.ts index 7b25b1ad..d1e99edb 100644 --- a/src/test/managers/builtin/inlineScriptMain.unit.test.ts +++ b/src/test/managers/builtin/inlineScriptMain.unit.test.ts @@ -68,6 +68,11 @@ suite('registerInlineScriptFeatures (feature-flag gate)', () => { assert.strictEqual(registerEnvironmentManagerStub.callCount, 1); assert.strictEqual(disposables.length, 2, 'expected manager + registration disposable'); const manager = registerEnvironmentManagerStub.firstCall.args[0]; + assert.ok(disposables.includes(manager), 'manager itself should be disposed'); + assert.ok( + disposables.includes(registerEnvironmentManagerStub.firstCall.returnValue), + 'registration disposable should be disposed', + ); assert.strictEqual(typeof manager.create, 'function'); }); }); From 00386ec3848eaefa0c1b93941c2027dd2f797757 Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Thu, 30 Jul 2026 13:02:43 -0700 Subject: [PATCH 4/4] Address inline manager review feedback Centralize the pyenv manager ID, simplify inline manager data flow and release comparisons, and share filesystem not-found classification across cache components. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 39dcc6a3-0fbd-4f36-9d0f-68677de49c27 --- src/common/constants.ts | 1 + src/common/inlineScriptCacheLayout.ts | 5 +- src/common/utils/filesystem.ts | 11 ++ .../builtin/inlineScriptEnvManager.ts | 108 +++++++++--------- src/managers/pyenv/pyenvManager.ts | 3 +- src/test/common/filesystem.unit.test.ts | 20 ++++ 6 files changed, 90 insertions(+), 58 deletions(-) create mode 100644 src/common/utils/filesystem.ts create mode 100644 src/test/common/filesystem.unit.test.ts diff --git a/src/common/constants.ts b/src/common/constants.ts index 1509f39c..31894953 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -4,6 +4,7 @@ export const ENVS_EXTENSION_ID = 'ms-python.vscode-python-envs'; export const PYTHON_EXTENSION_ID = 'ms-python.python'; export const CONDA_MANAGER_ID = `${PYTHON_EXTENSION_ID}:conda`; export const INLINE_SCRIPT_MANAGER_ID = `${PYTHON_EXTENSION_ID}:inline-script`; +export const PYENV_MANAGER_ID = `${PYTHON_EXTENSION_ID}:pyenv`; export const JUPYTER_EXTENSION_ID = 'ms-toolsai.jupyter'; export const EXTENSION_ROOT_DIR = path.dirname(__dirname); export const ISSUES_URL = 'https://github.com/microsoft/vscode-python-environments/issues'; diff --git a/src/common/inlineScriptCacheLayout.ts b/src/common/inlineScriptCacheLayout.ts index e5b410fb..da714143 100644 --- a/src/common/inlineScriptCacheLayout.ts +++ b/src/common/inlineScriptCacheLayout.ts @@ -8,6 +8,7 @@ import { Uri } from 'vscode'; import type { PythonEnvironment } from '../api'; import { INLINE_SCRIPT_MANAGER_ID } from './constants'; import { traceWarn } from './logging'; +import { isFileNotFoundError } from './utils/filesystem'; import { normalizePath } from './utils/pathUtils'; import { isWindows } from './utils/platformUtils'; import { getVenvPythonPath } from './utils/virtualEnvironment'; @@ -273,10 +274,6 @@ function parsePyvenvHome(raw: string): string | undefined { return undefined; } -function isFileNotFoundError(err: unknown): boolean { - return typeof err === 'object' && err !== null && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT'; -} - function isDescendantPath(rootPath: string, candidatePath: string): boolean { const relative = path.relative(rootPath, candidatePath); return ( diff --git a/src/common/utils/filesystem.ts b/src/common/utils/filesystem.ts new file mode 100644 index 00000000..ae774563 --- /dev/null +++ b/src/common/utils/filesystem.ts @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +export function isFileNotFoundError(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/managers/builtin/inlineScriptEnvManager.ts b/src/managers/builtin/inlineScriptEnvManager.ts index 3ccb39f0..64fa9d7e 100644 --- a/src/managers/builtin/inlineScriptEnvManager.ts +++ b/src/managers/builtin/inlineScriptEnvManager.ts @@ -21,7 +21,6 @@ import { } from '../../api'; import { computeCacheKey } from '../../common/inlineScriptCacheKey'; import { - InlineScriptEnvMeta, META_SCHEMA_VERSION, getBaseInterpreterStatus, getScriptEnvCacheRoot, @@ -37,8 +36,9 @@ import { matchesPythonVersion, readInlineScriptMetadataFromFile, } from '../../common/inlineScriptMetadata'; -import { CONDA_MANAGER_ID, PYTHON_EXTENSION_ID, SYSTEM_MANAGER_ID } from '../../common/constants'; +import { CONDA_MANAGER_ID, PYENV_MANAGER_ID, SYSTEM_MANAGER_ID } from '../../common/constants'; import { acquireFileLock, AcquiredFileLock } from '../../common/lockfile.apis'; +import { isFileNotFoundError } from '../../common/utils/filesystem'; import { normalizePath } from '../../common/utils/pathUtils'; import { compareReleaseSegments, parseReleaseSegments } from '../../common/utils/pep440Release'; import { getVenvPythonPath } from '../../common/utils/virtualEnvironment'; @@ -48,12 +48,33 @@ import { createWithProgress, resolveVenvPythonEnvironmentPath } from './venvUtil const BASE_INTERPRETER_MANAGER_IDS = new Set([ SYSTEM_MANAGER_ID, CONDA_MANAGER_ID, - `${PYTHON_EXTENSION_ID}:pyenv`, + PYENV_MANAGER_ID, ]); const CACHE_LOCK_TIMEOUT_MS = 5 * 60 * 1000; const CACHE_LOCK_RETRY_MS = 500; +interface SelectedBaseInterpreter { + readonly environment: PythonEnvironment; + readonly canonicalPath: string; +} + +interface CreateOrReuseEnvironmentOptions { + readonly cacheKey: string; + readonly packages: ReadonlyArray; + readonly metadata: InlineScriptMetadata; + readonly selectedBase: SelectedBaseInterpreter; +} + +interface BuildCacheEntryResult { + readonly environment?: PythonEnvironment; + readonly retainLock?: boolean; +} + +type CacheEntryInspection = + | { readonly kind: 'absent' | 'stale' | 'uncertain' } + | { readonly kind: 'reusable'; readonly environment: PythonEnvironment }; + /** Manages extension-owned PEP 723 script environments. */ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { private readonly pendingCreations = new Map>(); @@ -100,9 +121,10 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return undefined; } - const packages = [...(metadata.dependencies ?? []), ...(options?.additionalPackages ?? [])].map((value) => - value.trim(), - ); + const packages = [ + ...(metadata.dependencies ?? []), + ...(options?.additionalPackages ?? []), + ].map((value) => value.trim()); if (packages.some((value) => value.length === 0)) { this.log.warn(`Inline-script dependencies must not contain empty entries: ${scriptUri.fsPath}.`); return undefined; @@ -113,6 +135,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { this.log.warn(`No installed Python satisfies the inline-script requirements for ${scriptUri.fsPath}.`); return undefined; } + const cacheKey = computeCacheKey({ dependencies: packages, interpreterPath: selectedBase.canonicalPath, @@ -122,7 +145,12 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return await pending; } - const creation = this.createOrReuseEnvironment(cacheKey, packages, metadata, selectedBase); + const creation = this.createOrReuseEnvironment({ + cacheKey, + packages, + metadata, + selectedBase, + }); this.pendingCreations.set(cacheKey, creation); try { return await creation; @@ -163,7 +191,8 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } private async selectBaseInterpreter(metadata: InlineScriptMetadata): Promise { - const reported = (await this.api.getEnvironments('global')).filter( + const globalEnvironments = await this.api.getEnvironments('global'); + const reported = globalEnvironments.filter( (environment) => BASE_INTERPRETER_MANAGER_IDS.has(environment.envId.managerId) && (environment.envId.managerId !== CONDA_MANAGER_ID || environment.name === 'base'), @@ -209,12 +238,12 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return undefined; } - private async createOrReuseEnvironment( - cacheKey: string, - packages: ReadonlyArray, - metadata: InlineScriptMetadata, - selectedBase: SelectedBaseInterpreter, - ): Promise { + private async createOrReuseEnvironment({ + cacheKey, + packages, + metadata, + selectedBase, + }: CreateOrReuseEnvironmentOptions): Promise { const cacheRoot = getScriptEnvCacheRoot(this.globalStorageUri); const envDir = getScriptEnvDir(this.globalStorageUri, cacheKey); await fs.ensureDir(cacheRoot.fsPath); @@ -279,7 +308,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return { kind: 'uncertain' }; } } catch (error) { - return this.isFileNotFoundError(error) ? { kind: 'absent' } : { kind: 'uncertain' }; + return isFileNotFoundError(error) ? { kind: 'absent' } : { kind: 'uncertain' }; } let resolvedEntry: string | undefined; @@ -329,8 +358,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { if (environmentStatus !== 'expected') { return { kind: environmentStatus }; } - const releaseComparison = this.comparePythonReleases(environment.version, selectedBase.environment.version); - if (releaseComparison !== 'same') { + if (!this.areEqualPythonReleases(environment.version, selectedBase.environment.version)) { return { kind: 'stale' }; } const requiresPython = metadata.requiresPython?.trim(); @@ -385,7 +413,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return {}; } if ( - this.comparePythonReleases(result.environment.version, selectedBase.environment.version) !== 'same' || + !this.areEqualPythonReleases(result.environment.version, selectedBase.environment.version) || (await inspectOwnedCacheEntry(result.environment, cacheRoot, envDir)) !== 'expected' ) { this.log.error('Created inline-script environment does not match the requested cache entry.'); @@ -393,14 +421,13 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return {}; } - const sidecar: InlineScriptEnvMeta = { - schemaVersion: META_SCHEMA_VERSION, - baseInterpreterPath: selectedBase.canonicalPath, - baseInterpreterVersion: selectedBase.environment.version, - lastUsedAt: new Date().toISOString(), - }; try { - await writeMetaJson(envDir, sidecar); + await writeMetaJson(envDir, { + schemaVersion: META_SCHEMA_VERSION, + baseInterpreterPath: selectedBase.canonicalPath, + baseInterpreterVersion: selectedBase.environment.version, + lastUsedAt: new Date().toISOString(), + }); } catch (error) { this.log.error(`Failed to record inline-script cache metadata: ${this.errorMessage(error)}`); await this.removeCacheEntry(envDir); @@ -420,22 +447,13 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } } - private isFileNotFoundError(error: unknown): boolean { - return ( - typeof error === 'object' && - error !== null && - 'code' in error && - (error as NodeJS.ErrnoException).code === 'ENOENT' - ); - } - - private comparePythonReleases(actual: string, expected: string): PythonReleaseComparison { + private areEqualPythonReleases(actual: string, expected: string): boolean { const actualRelease = parseReleaseSegments(actual); const expectedRelease = parseReleaseSegments(expected); if (actualRelease === undefined || expectedRelease === undefined) { - return 'uncertain'; + return false; } - return compareReleaseSegments(actualRelease, expectedRelease) === 0 ? 'same' : 'different'; + return compareReleaseSegments(actualRelease, expectedRelease) === 0; } private errorMessage(error: unknown): string { @@ -447,19 +465,3 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { this._onDidChangeEnvironment.dispose(); } } - -interface SelectedBaseInterpreter { - readonly environment: PythonEnvironment; - readonly canonicalPath: string; -} - -interface BuildCacheEntryResult { - readonly environment?: PythonEnvironment; - readonly retainLock?: boolean; -} - -type CacheEntryInspection = - | { readonly kind: 'absent' | 'stale' | 'uncertain' } - | { readonly kind: 'reusable'; readonly environment: PythonEnvironment }; - -type PythonReleaseComparison = 'same' | 'different' | 'uncertain'; diff --git a/src/managers/pyenv/pyenvManager.ts b/src/managers/pyenv/pyenvManager.ts index 5602136f..c5dc9828 100644 --- a/src/managers/pyenv/pyenvManager.ts +++ b/src/managers/pyenv/pyenvManager.ts @@ -15,6 +15,7 @@ import { ResolveEnvironmentContext, SetEnvironmentScope, } from '../../api'; +import { PYENV_MANAGER_ID } from '../../common/constants'; import { PyenvStrings } from '../../common/localize'; import { traceError, traceInfo } from '../../common/logging'; import { StopWatch } from '../../common/stopWatch'; @@ -120,7 +121,7 @@ export class PyEnvManager implements EnvironmentManager, Disposable { if (toolSource === 'none') { result = 'tool_not_found'; if (this.projectManager) { - await notifyMissingManagerIfDefault('ms-python.python:pyenv', this.projectManager, this.api); + await notifyMissingManagerIfDefault(PYENV_MANAGER_ID, this.projectManager, this.api); } } } catch (ex) { diff --git a/src/test/common/filesystem.unit.test.ts b/src/test/common/filesystem.unit.test.ts new file mode 100644 index 00000000..e95a10cb --- /dev/null +++ b/src/test/common/filesystem.unit.test.ts @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import { isFileNotFoundError } from '../../common/utils/filesystem'; + +suite('filesystem utilities', () => { + test('recognizes ENOENT errors', () => { + const error = Object.assign(new Error('missing'), { code: 'ENOENT' }); + + assert.strictEqual(isFileNotFoundError(error), true); + }); + + test('rejects other errors and non-errors', () => { + assert.strictEqual(isFileNotFoundError(Object.assign(new Error('not a directory'), { code: 'ENOTDIR' })), false); + assert.strictEqual(isFileNotFoundError(new Error('missing code')), false); + assert.strictEqual(isFileNotFoundError(undefined), false); + assert.strictEqual(isFileNotFoundError('ENOENT'), false); + }); +});