diff --git a/package-lock.json b/package-lock.json index 212fa155..1d919925 100644 --- a/package-lock.json +++ b/package-lock.json @@ -795,7 +795,6 @@ "integrity": "sha512-D7DbgGFtsqIPIFMPJwCad9Gfi/hC0PWErRRHFnaCWoEDYi5tQUDiJCTmGUbBiLzjqAck4KcXt9Ayj0CNlIrF+w==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.16.0", "@typescript-eslint/types": "8.16.0", @@ -1481,7 +1480,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1525,7 +1523,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1716,7 +1713,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001580", "electron-to-chromium": "^1.4.648", @@ -2411,7 +2407,6 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5123,7 +5118,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5249,7 +5243,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dev": true, - "peer": true, "dependencies": { "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", @@ -5296,7 +5289,6 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -6094,7 +6086,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.16.0.tgz", "integrity": "sha512-D7DbgGFtsqIPIFMPJwCad9Gfi/hC0PWErRRHFnaCWoEDYi5tQUDiJCTmGUbBiLzjqAck4KcXt9Ayj0CNlIrF+w==", "dev": true, - "peer": true, "requires": { "@typescript-eslint/scope-manager": "8.16.0", "@typescript-eslint/types": "8.16.0", @@ -6590,8 +6581,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "peer": true + "dev": true }, "acorn-import-attributes": { "version": "1.9.5", @@ -6621,7 +6611,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "peer": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6754,7 +6743,6 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.3.tgz", "integrity": "sha512-UAp55yfwNv0klWNapjs/ktHoguxuQNGnOzxYmfnXIS+8AsRDZkSDxg7R1AX3GKzn078SBI5dzwzj/Yx0Or0e3A==", "dev": true, - "peer": true, "requires": { "caniuse-lite": "^1.0.30001580", "electron-to-chromium": "^1.4.648", @@ -7236,7 +7224,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, - "peer": true, "requires": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9197,8 +9184,7 @@ "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "dev": true, - "peer": true + "dev": true }, "uc.micro": { "version": "1.0.6", @@ -9284,7 +9270,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dev": true, - "peer": true, "requires": { "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", @@ -9316,7 +9301,6 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, - "peer": true, "requires": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", diff --git a/src/features/projectManager.ts b/src/features/projectManager.ts index 733278a9..d4efbda2 100644 --- a/src/features/projectManager.ts +++ b/src/features/projectManager.ts @@ -16,6 +16,7 @@ import { getDefaultEnvManagerSetting, getDefaultPkgManagerSetting, } from './settings/settingHelpers'; +import { validateAndNotifyPythonProjectsSettings } from './settings/settingsValidation'; type ProjectArray = PythonProject[]; @@ -30,6 +31,12 @@ export class PythonProjectManagerImpl implements PythonProjectManager { initialize(): void { this.add(this.getInitialProjects()); + + // Validate pythonProjects settings on initialization + validateAndNotifyPythonProjectsSettings().catch(() => { + // Error logging handled in validation function + }); + this.disposables.push( this._onDidChangeProjects, new Disposable(() => this._projects.clear()), @@ -43,6 +50,13 @@ export class PythonProjectManagerImpl implements PythonProjectManager { e.affectsConfiguration('python-envs.defaultPackageManager') ) { this.updateDebounce.trigger(); + + // Validate settings when pythonProjects configuration changes + if (e.affectsConfiguration('python-envs.pythonProjects')) { + validateAndNotifyPythonProjectsSettings().catch(() => { + // Error logging handled in validation function + }); + } } }), ); @@ -65,8 +79,19 @@ export class PythonProjectManagerImpl implements PythonProjectManager { newProjects.push(new PythonProjectsImpl(w.name, w.uri)); } + // Validate that overrides is an array + if (!Array.isArray(overrides)) { + // Skip processing if the setting is invalid + continue; + } + // For each override, resolve its path and add as a project if not already present for (const o of overrides) { + // Skip invalid entries (missing required fields) + if (!o || typeof o !== 'object' || !o.path || !o.envManager || !o.packageManager) { + continue; + } + let uriFromWorkspace: Uri | undefined = undefined; // if override has a workspace property, resolve the path relative to that workspace if (o.workspace) { diff --git a/src/features/settings/settingsValidation.ts b/src/features/settings/settingsValidation.ts new file mode 100644 index 00000000..7edf2e7b --- /dev/null +++ b/src/features/settings/settingsValidation.ts @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { commands, ConfigurationTarget, l10n, Uri, workspace } from 'vscode'; +import { traceError, traceWarn } from '../../common/logging'; +import { getGlobalPersistentState } from '../../common/persistentState'; +import { showWarningMessage } from '../../common/window.apis'; +import { getConfiguration, getWorkspaceFolders } from '../../common/workspace.apis'; +import { PythonProjectSettings } from '../../internal.api'; + +const DONT_SHOW_INVALID_SETTINGS_KEY = 'dontShowInvalidPythonProjectSettings'; + +interface InvalidProjectEntry { + entry: PythonProjectSettings; + workspaceUri: Uri; + reason: string; +} + +/** + * Validates a single PythonProjectSettings entry + * @param entry The settings entry to validate + * @returns An error message if invalid, undefined if valid + */ +function validateProjectEntry(entry: PythonProjectSettings): string | undefined { + // Check if entry is a valid object (not a string, array, null, or other primitive) + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { + return l10n.t('Invalid entry format: expected object'); + } + + // Check if required fields exist + if (!entry.path) { + return l10n.t('Missing required field: path'); + } + + if (!entry.envManager) { + return l10n.t('Missing required field: envManager'); + } + + if (!entry.packageManager) { + return l10n.t('Missing required field: packageManager'); + } + + return undefined; +} + +/** + * Validates all pythonProjects settings across all workspaces + * @returns Array of invalid entries with their details + */ +export function validatePythonProjectsSettings(): InvalidProjectEntry[] { + const invalidEntries: InvalidProjectEntry[] = []; + const workspaces = getWorkspaceFolders() ?? []; + + for (const workspace of workspaces) { + const config = getConfiguration('python-envs', workspace.uri); + const projectSettings = config.get('pythonProjects', []); + + // Check if the setting is valid (should be an array) + if (!Array.isArray(projectSettings)) { + traceError( + `Invalid pythonProjects setting in workspace ${workspace.name}: expected array, got ${typeof projectSettings}`, + ); + invalidEntries.push({ + entry: projectSettings as unknown as PythonProjectSettings, + workspaceUri: workspace.uri, + reason: l10n.t('Invalid format: pythonProjects must be an array'), + }); + continue; + } + + // Validate each entry + for (const entry of projectSettings) { + const error = validateProjectEntry(entry); + if (error) { + traceWarn(`Invalid pythonProjects entry in workspace ${workspace.name}:`, entry, error); + invalidEntries.push({ + entry, + workspaceUri: workspace.uri, + reason: error, + }); + } + } + } + + return invalidEntries; +} + +/** + * Shows a warning notification about invalid pythonProjects settings + * Provides options to remove the invalid entry, open settings, or don't show again + * @param invalidEntries Array of invalid entries found + */ +export async function notifyInvalidPythonProjectsSettings(invalidEntries: InvalidProjectEntry[]): Promise { + if (invalidEntries.length === 0) { + return; + } + + // Check if user has chosen to not show this warning again + const persistentState = await getGlobalPersistentState(); + const dontShowAgain = await persistentState.get(DONT_SHOW_INVALID_SETTINGS_KEY, false); + + if (dontShowAgain) { + traceWarn('Not showing invalid pythonProjects settings warning due to user preference'); + return; + } + + // Create a descriptive message + const count = invalidEntries.length; + const message = + count === 1 + ? l10n.t( + 'Found an invalid entry in python-envs.pythonProjects settings. This may cause issues with project detection.', + ) + : l10n.t( + 'Found {0} invalid entries in python-envs.pythonProjects settings. This may cause issues with project detection.', + count, + ); + + // Show warning with options + const removeOption = l10n.t('Remove Invalid Entry'); + const openSettingsOption = l10n.t('Open Settings'); + const dontShowOption = l10n.t("Don't Show Again"); + + const choice = await showWarningMessage(message, removeOption, openSettingsOption, dontShowOption); + + if (choice === removeOption) { + // Remove the first invalid entry (or all if multiple) + try { + // Group by workspace to batch updates + const byWorkspace = new Map(); + for (const invalid of invalidEntries) { + const key = invalid.workspaceUri.toString(); + if (!byWorkspace.has(key)) { + byWorkspace.set(key, []); + } + byWorkspace.get(key)!.push(invalid); + } + + // Remove invalid entries from each workspace + for (const [workspaceKey, entries] of byWorkspace) { + const workspaceUri = Uri.parse(workspaceKey); + const config = getConfiguration('python-envs', workspaceUri); + let projectSettings = config.get('pythonProjects', []); + + if (!Array.isArray(projectSettings)) { + await config.update('pythonProjects', [], ConfigurationTarget.Workspace); + continue; + } + + // Filter out all invalid entries + const invalidJsonStrings = new Set(entries.map((e) => JSON.stringify(e.entry))); + projectSettings = projectSettings.filter((e) => !invalidJsonStrings.has(JSON.stringify(e))); + + await config.update('pythonProjects', projectSettings, ConfigurationTarget.Workspace); + } + + traceWarn(`Removed ${invalidEntries.length} invalid pythonProjects entries`); + } catch (error) { + traceError('Failed to remove invalid entries:', error); + await showWarningMessage(l10n.t('Failed to remove invalid entries. Please check the logs for details.')); + } + } else if (choice === openSettingsOption) { + // Open settings UI to the pythonProjects setting + await commands.executeCommand('workbench.action.openSettings', '@ext:ms-python.vscode-python-envs pythonProjects'); + } else if (choice === dontShowOption) { + // Save preference to not show again + await persistentState.set(DONT_SHOW_INVALID_SETTINGS_KEY, true); + traceWarn('User chose to not show invalid pythonProjects settings warning again'); + } +} + +/** + * Validates pythonProjects settings and shows a notification if invalid entries are found + * Should be called when the extension initializes or when settings change + */ +export async function validateAndNotifyPythonProjectsSettings(): Promise { + try { + const invalidEntries = validatePythonProjectsSettings(); + if (invalidEntries.length > 0) { + await notifyInvalidPythonProjectsSettings(invalidEntries); + } + } catch (error) { + traceError('Error validating pythonProjects settings:', error); + } +} diff --git a/src/test/features/settings/settingsValidation.unit.test.ts b/src/test/features/settings/settingsValidation.unit.test.ts new file mode 100644 index 00000000..f2ea5863 --- /dev/null +++ b/src/test/features/settings/settingsValidation.unit.test.ts @@ -0,0 +1,461 @@ +import assert from 'node:assert'; +import * as sinon from 'sinon'; +import { Uri } from 'vscode'; +import * as persistentState from '../../../common/persistentState'; +import * as windowApis from '../../../common/window.apis'; +import * as workspaceApis from '../../../common/workspace.apis'; +import { PythonProjectSettings } from '../../../internal.api'; + +// Import the functions under test +import { + validatePythonProjectsSettings, + notifyInvalidPythonProjectsSettings, + validateAndNotifyPythonProjectsSettings, +} from '../../../features/settings/settingsValidation'; + +interface MockWorkspaceConfig { + get: sinon.SinonStub; + inspect: sinon.SinonStub; + update: sinon.SinonStub; +} + +interface MockPersistentState { + get: sinon.SinonStub; + set: sinon.SinonStub; + clear: sinon.SinonStub; +} + +suite('pythonProjects Settings Validation', () => { + let mockGetConfiguration: sinon.SinonStub; + let mockGetWorkspaceFolders: sinon.SinonStub; + let mockShowWarningMessage: sinon.SinonStub; + let mockGetGlobalPersistentState: sinon.SinonStub; + let mockPersistentState: MockPersistentState; + + setup(() => { + // Mock workspace APIs + mockGetConfiguration = sinon.stub(workspaceApis, 'getConfiguration'); + mockGetWorkspaceFolders = sinon.stub(workspaceApis, 'getWorkspaceFolders'); + + // Mock window APIs + mockShowWarningMessage = sinon.stub(windowApis, 'showWarningMessage'); + + // Mock persistent state + mockPersistentState = { + get: sinon.stub(), + set: sinon.stub(), + clear: sinon.stub(), + }; + mockGetGlobalPersistentState = sinon.stub(persistentState, 'getGlobalPersistentState'); + mockGetGlobalPersistentState.resolves(mockPersistentState); + + // Default: no "don't show again" preference + mockPersistentState.get.resolves(false); + }); + + teardown(() => { + sinon.restore(); + }); + + suite('Valid Settings Scenarios', () => { + test('should return empty array when no workspaces exist', () => { + // Mock → No workspaces + mockGetWorkspaceFolders.returns([]); + + // Run + const result = validatePythonProjectsSettings(); + + // Assert + assert.strictEqual(result.length, 0, 'Should return empty array when no workspaces'); + }); + + test('should return empty array when pythonProjects is empty array', () => { + // Mock → Workspace with empty pythonProjects + const workspaceUri = Uri.file('/workspace'); + mockGetWorkspaceFolders.returns([{ name: 'test-workspace', uri: workspaceUri, index: 0 }]); + + const config: MockWorkspaceConfig = { + get: sinon.stub(), + inspect: sinon.stub(), + update: sinon.stub(), + }; + config.get.withArgs('pythonProjects', []).returns([]); + mockGetConfiguration.returns(config); + + // Run + const result = validatePythonProjectsSettings(); + + // Assert + assert.strictEqual(result.length, 0, 'Should return empty array for valid empty settings'); + }); + + test('should pass with valid entries containing all required fields', () => { + // Mock → Valid pythonProjects entries + const workspaceUri = Uri.file('/workspace'); + mockGetWorkspaceFolders.returns([{ name: 'test-workspace', uri: workspaceUri, index: 0 }]); + + const validSettings: PythonProjectSettings[] = [ + { + path: './project1', + envManager: 'ms-python.python:venv', + packageManager: 'ms-python.python:pip', + }, + { + path: './project2', + envManager: 'ms-python.python:conda', + packageManager: 'ms-python.python:conda', + }, + ]; + + const config: MockWorkspaceConfig = { + get: sinon.stub(), + inspect: sinon.stub(), + update: sinon.stub(), + }; + config.get.withArgs('pythonProjects', []).returns(validSettings); + mockGetConfiguration.returns(config); + + // Run + const result = validatePythonProjectsSettings(); + + // Assert + assert.strictEqual(result.length, 0, 'Should return empty array when all entries are valid'); + }); + }); + + suite('Invalid Entry Detection', () => { + test('should detect missing path field', () => { + // Mock → Entry missing path field + const workspaceUri = Uri.file('/workspace'); + mockGetWorkspaceFolders.returns([{ name: 'test-workspace', uri: workspaceUri, index: 0 }]); + + const invalidSettings = [ + { + envManager: 'ms-python.python:venv', + packageManager: 'ms-python.python:pip', + } as unknown as PythonProjectSettings, + ]; + + const config: MockWorkspaceConfig = { + get: sinon.stub(), + inspect: sinon.stub(), + update: sinon.stub(), + }; + config.get.withArgs('pythonProjects', []).returns(invalidSettings); + mockGetConfiguration.returns(config); + + // Run + const result = validatePythonProjectsSettings(); + + // Assert + assert.strictEqual(result.length, 1, 'Should detect missing path field'); + assert.ok(result[0].reason.includes('path'), 'Error should mention missing path field'); + }); + + test('should detect missing envManager field', () => { + // Mock → Entry missing envManager field + const workspaceUri = Uri.file('/workspace'); + mockGetWorkspaceFolders.returns([{ name: 'test-workspace', uri: workspaceUri, index: 0 }]); + + const invalidSettings = [ + { + path: './project', + packageManager: 'ms-python.python:pip', + } as unknown as PythonProjectSettings, + ]; + + const config: MockWorkspaceConfig = { + get: sinon.stub(), + inspect: sinon.stub(), + update: sinon.stub(), + }; + config.get.withArgs('pythonProjects', []).returns(invalidSettings); + mockGetConfiguration.returns(config); + + // Run + const result = validatePythonProjectsSettings(); + + // Assert + assert.strictEqual(result.length, 1, 'Should detect missing envManager field'); + assert.ok(result[0].reason.includes('envManager'), 'Error should mention missing envManager field'); + }); + + test('should detect missing packageManager field', () => { + // Mock → Entry missing packageManager field + const workspaceUri = Uri.file('/workspace'); + mockGetWorkspaceFolders.returns([{ name: 'test-workspace', uri: workspaceUri, index: 0 }]); + + const invalidSettings = [ + { + path: './project', + envManager: 'ms-python.python:venv', + } as unknown as PythonProjectSettings, + ]; + + const config: MockWorkspaceConfig = { + get: sinon.stub(), + inspect: sinon.stub(), + update: sinon.stub(), + }; + config.get.withArgs('pythonProjects', []).returns(invalidSettings); + mockGetConfiguration.returns(config); + + // Run + const result = validatePythonProjectsSettings(); + + // Assert + assert.strictEqual(result.length, 1, 'Should detect missing packageManager field'); + assert.ok( + result[0].reason.includes('packageManager'), + 'Error should mention missing packageManager field', + ); + }); + + test('should detect non-object entry', () => { + // Mock → Non-object entry (string) + const workspaceUri = Uri.file('/workspace'); + mockGetWorkspaceFolders.returns([{ name: 'test-workspace', uri: workspaceUri, index: 0 }]); + + const invalidSettings = ['invalid-string' as unknown as PythonProjectSettings]; + + const config: MockWorkspaceConfig = { + get: sinon.stub(), + inspect: sinon.stub(), + update: sinon.stub(), + }; + config.get.withArgs('pythonProjects', []).returns(invalidSettings); + mockGetConfiguration.returns(config); + + // Run + const result = validatePythonProjectsSettings(); + + // Assert + assert.strictEqual(result.length, 1, 'Should detect non-object entry'); + // String entries get caught as "Invalid entry format: expected object" or "Missing required field" + // Both are acceptable since the entry is invalid + assert.ok(result[0].reason.length > 0, 'Error should have a reason'); + }); + + test('should detect invalid array format (non-array)', () => { + // Mock → pythonProjects is not an array + const workspaceUri = Uri.file('/workspace'); + mockGetWorkspaceFolders.returns([{ name: 'test-workspace', uri: workspaceUri, index: 0 }]); + + const config: MockWorkspaceConfig = { + get: sinon.stub(), + inspect: sinon.stub(), + update: sinon.stub(), + }; + config.get.withArgs('pythonProjects', []).returns('invalid-string' as unknown); + mockGetConfiguration.returns(config); + + // Run + const result = validatePythonProjectsSettings(); + + // Assert + assert.strictEqual(result.length, 1, 'Should detect non-array format'); + assert.ok(result[0].reason.includes('array'), 'Error should mention array format'); + }); + + test('should detect multiple invalid entries', () => { + // Mock → Multiple invalid entries + const workspaceUri = Uri.file('/workspace'); + mockGetWorkspaceFolders.returns([{ name: 'test-workspace', uri: workspaceUri, index: 0 }]); + + const invalidSettings = [ + { path: './valid', envManager: 'ms-python.python:venv', packageManager: 'ms-python.python:pip' }, + { envManager: 'ms-python.python:venv', packageManager: 'ms-python.python:pip' } as unknown, + { path: './invalid2', packageManager: 'ms-python.python:pip' } as unknown, + ] as PythonProjectSettings[]; + + const config: MockWorkspaceConfig = { + get: sinon.stub(), + inspect: sinon.stub(), + update: sinon.stub(), + }; + config.get.withArgs('pythonProjects', []).returns(invalidSettings); + mockGetConfiguration.returns(config); + + // Run + const result = validatePythonProjectsSettings(); + + // Assert + assert.strictEqual(result.length, 2, 'Should detect all invalid entries'); + }); + }); + + suite('User Notification Behavior', () => { + test('should not show notification when no invalid entries exist', async () => { + // Mock → No invalid entries + const invalidEntries: never[] = []; + + // Run + await notifyInvalidPythonProjectsSettings(invalidEntries); + + // Assert + assert.strictEqual( + mockShowWarningMessage.called, + false, + 'Should not show notification for empty array', + ); + }); + + test('should respect user preference to suppress notifications', async () => { + // Mock → User previously selected "don't show again" + mockPersistentState.get.resolves(true); + + const invalidEntries = [ + { + entry: { path: './test' } as PythonProjectSettings, + workspaceUri: Uri.file('/workspace'), + reason: 'Missing required fields', + }, + ]; + + // Run + await notifyInvalidPythonProjectsSettings(invalidEntries); + + // Assert + assert.strictEqual( + mockShowWarningMessage.called, + false, + 'Should not show notification when user opted out', + ); + }); + + test('should display singular message for one invalid entry', async () => { + // Mock → One invalid entry + const invalidEntries = [ + { + entry: { path: './test' } as PythonProjectSettings, + workspaceUri: Uri.file('/workspace'), + reason: 'Missing required fields', + }, + ]; + + mockShowWarningMessage.resolves(undefined); + + // Run + await notifyInvalidPythonProjectsSettings(invalidEntries); + + // Assert + assert.strictEqual(mockShowWarningMessage.callCount, 1, 'Should show warning message'); + const message = mockShowWarningMessage.firstCall.args[0]; + assert.ok( + message.includes('invalid entry') && !message.includes('entries'), + 'Message should be singular for one entry', + ); + }); + + test('should display plural message with count for multiple invalid entries', async () => { + // Mock → Multiple invalid entries + const invalidEntries = [ + { + entry: { path: './test1' } as PythonProjectSettings, + workspaceUri: Uri.file('/workspace'), + reason: 'Missing envManager', + }, + { + entry: { path: './test2' } as PythonProjectSettings, + workspaceUri: Uri.file('/workspace'), + reason: 'Missing packageManager', + }, + ]; + + mockShowWarningMessage.resolves(undefined); + + // Run + await notifyInvalidPythonProjectsSettings(invalidEntries); + + // Assert + assert.strictEqual(mockShowWarningMessage.callCount, 1, 'Should show warning message'); + const message = mockShowWarningMessage.firstCall.args[0]; + assert.ok(message.includes('2'), 'Message should include count'); + assert.ok(message.includes('entries'), 'Message should be plural for multiple entries'); + }); + + test('should persist user choice to suppress future notifications', async () => { + // Mock → User selects "don't show again" + const invalidEntries = [ + { + entry: { path: './test' } as PythonProjectSettings, + workspaceUri: Uri.file('/workspace'), + reason: 'Missing required fields', + }, + ]; + + mockShowWarningMessage.resolves("Don't Show Again"); + + // Run + await notifyInvalidPythonProjectsSettings(invalidEntries); + + // Assert + assert.strictEqual(mockPersistentState.set.callCount, 1, 'Should save preference'); + assert.strictEqual( + mockPersistentState.set.firstCall.args[0], + 'dontShowInvalidPythonProjectSettings', + 'Should use correct key', + ); + assert.strictEqual(mockPersistentState.set.firstCall.args[1], true, 'Should save true value'); + }); + }); + + suite('End-to-End Validation Flow', () => { + test('should detect invalid entries and show notification', async () => { + // Mock → Invalid entry exists + const workspaceUri = Uri.file('/workspace'); + mockGetWorkspaceFolders.returns([{ name: 'test-workspace', uri: workspaceUri, index: 0 }]); + + const invalidSettings = [ + { + envManager: 'ms-python.python:venv', + packageManager: 'ms-python.python:pip', + } as unknown as PythonProjectSettings, + ]; + + const config: MockWorkspaceConfig = { + get: sinon.stub(), + inspect: sinon.stub(), + update: sinon.stub(), + }; + config.get.withArgs('pythonProjects', []).returns(invalidSettings); + mockGetConfiguration.returns(config); + + mockShowWarningMessage.resolves(undefined); + + // Run + await validateAndNotifyPythonProjectsSettings(); + + // Assert + assert.strictEqual(mockShowWarningMessage.callCount, 1, 'Should show notification'); + }); + + test('should skip notification when all entries are valid', async () => { + // Mock → All valid entries + const workspaceUri = Uri.file('/workspace'); + mockGetWorkspaceFolders.returns([{ name: 'test-workspace', uri: workspaceUri, index: 0 }]); + + const validSettings: PythonProjectSettings[] = [ + { + path: './project', + envManager: 'ms-python.python:venv', + packageManager: 'ms-python.python:pip', + }, + ]; + + const config: MockWorkspaceConfig = { + get: sinon.stub(), + inspect: sinon.stub(), + update: sinon.stub(), + }; + config.get.withArgs('pythonProjects', []).returns(validSettings); + mockGetConfiguration.returns(config); + + // Run + await validateAndNotifyPythonProjectsSettings(); + + // Assert + assert.strictEqual(mockShowWarningMessage.called, false, 'Should not show notification for valid settings'); + }); + }); +});