From 5deb4b82b100725711029d3729b520f458f4e12f Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 12 Feb 2026 10:12:35 -0800 Subject: [PATCH 1/5] Add smoke tests for functional checks and registration verification --- src/test/smoke/functional.smoke.test.ts | 235 ++++++++++++++++++ src/test/smoke/registration.smoke.test.ts | 283 ++++++++++++++++++++++ 2 files changed, 518 insertions(+) create mode 100644 src/test/smoke/functional.smoke.test.ts create mode 100644 src/test/smoke/registration.smoke.test.ts diff --git a/src/test/smoke/functional.smoke.test.ts b/src/test/smoke/functional.smoke.test.ts new file mode 100644 index 00000000..a47e81f7 --- /dev/null +++ b/src/test/smoke/functional.smoke.test.ts @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Smoke Test: Functional Checks + * + * PURPOSE: + * Verify that core extension features actually work, not just that they're registered. + * These tests require Python to be installed and may have side effects. + * + * WHAT THIS TESTS: + * 1. Environment discovery returns results + * 2. Projects API works correctly + * 3. Environment variables API works + * 4. Settings are not polluted on activation + */ + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { PythonEnvironmentApi } from '../../api'; +import { ENVS_EXTENSION_ID, MAX_EXTENSION_ACTIVATION_TIME } from '../constants'; +import { waitForCondition } from '../testUtils'; + +suite('Smoke: Functional Checks', function () { + this.timeout(MAX_EXTENSION_ACTIVATION_TIME); + + let api: PythonEnvironmentApi; + + suiteSetup(async function () { + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, `Extension ${ENVS_EXTENSION_ID} not found`); + + if (!extension.isActive) { + await extension.activate(); + await waitForCondition(() => extension.isActive, 30_000, 'Extension did not activate'); + } + + api = extension.exports; + assert.ok(api, 'API not exported'); + }); + + // ========================================================================= + // ENVIRONMENT DISCOVERY - Core feature must work + // ========================================================================= + + test('getEnvironments returns an array', async function () { + // This test verifies discovery machinery works + // Even if no Python is installed, it should return an empty array, not throw + + const environments = await api.getEnvironments('all'); + + assert.ok(Array.isArray(environments), 'getEnvironments("all") should return an array'); + }); + + test('getEnvironments finds Python installations when available', async function () { + // Skip this test if no Python is expected (CI without Python) + if (process.env.SKIP_PYTHON_TESTS) { + this.skip(); + return; + } + + const environments = await api.getEnvironments('all'); + + // On a typical dev machine, we expect at least one Python + // This test may need to be conditional based on CI environment + if (environments.length === 0) { + console.log('[WARN] No Python environments found - is Python installed?'); + // Don't fail - just warn. CI may not have Python. + return; + } + + // Verify environment structure + const env = environments[0]; + assert.ok(env.envId, 'Environment should have envId'); + assert.ok(env.envId.id, 'envId.id should be defined'); + assert.ok(env.envId.managerId, 'envId.managerId should be defined'); + assert.ok(env.name, 'Environment should have a name'); + assert.ok(env.version, 'Environment should have a version'); + assert.ok(env.environmentPath, 'Environment should have environmentPath'); + }); + + test('getEnvironments with scope "global" returns global interpreters', async function () { + const globalEnvs = await api.getEnvironments('global'); + + assert.ok(Array.isArray(globalEnvs), 'getEnvironments("global") should return an array'); + + // Global environments are system Python installations + // They should be a subset of 'all' environments + const allEnvs = await api.getEnvironments('all'); + assert.ok(globalEnvs.length <= allEnvs.length, 'Global environments should be a subset of all environments'); + }); + + test('refreshEnvironments completes without error', async function () { + // This should not throw + await api.refreshEnvironments(undefined); + + // Verify we can still get environments after refresh + const environments = await api.getEnvironments('all'); + assert.ok(Array.isArray(environments), 'Should be able to get environments after refresh'); + }); + + // ========================================================================= + // PROJECTS - Core project management features + // ========================================================================= + + test('getPythonProjects returns workspace folders by default', function () { + const projects = api.getPythonProjects(); + + assert.ok(Array.isArray(projects), 'getPythonProjects should return an array'); + + // By default, workspace folders are treated as projects + const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders && workspaceFolders.length > 0) { + assert.ok(projects.length > 0, 'With workspace folders open, there should be at least one project'); + + // Verify project structure + const project = projects[0]; + assert.ok(project.name, 'Project should have a name'); + assert.ok(project.uri, 'Project should have a uri'); + } + }); + + test('getPythonProject returns undefined for non-existent path', function () { + const fakeUri = vscode.Uri.file('/this/path/does/not/exist/anywhere'); + const project = api.getPythonProject(fakeUri); + + // Should return undefined, not throw + assert.strictEqual(project, undefined, 'getPythonProject should return undefined for non-existent path'); + }); + + // ========================================================================= + // ENVIRONMENT SELECTION - Get/Set environment + // ========================================================================= + + test('getEnvironment returns undefined or a valid environment', async function () { + // With no explicit selection, may return undefined or auto-selected env + const env = await api.getEnvironment(undefined); + + if (env !== undefined) { + // If an environment is returned, verify its structure + assert.ok(env.envId, 'Returned environment should have envId'); + assert.ok(env.name, 'Returned environment should have name'); + } + // undefined is also valid - no environment selected + }); + + // ========================================================================= + // ENVIRONMENT VARIABLES - .env file support + // ========================================================================= + + test('getEnvironmentVariables returns an object', async function () { + const envVars = await api.getEnvironmentVariables(undefined); + + assert.ok(envVars !== null, 'getEnvironmentVariables should not return null'); + assert.ok(typeof envVars === 'object', 'getEnvironmentVariables should return an object'); + + // Should at least contain PATH or similar system variables + // (merged from process.env by default) + const hasKeys = Object.keys(envVars).length > 0; + assert.ok(hasKeys, 'Environment variables object should have some entries'); + }); + + test('getEnvironmentVariables with workspace uri works', async function () { + const workspaceFolders = vscode.workspace.workspaceFolders; + + if (!workspaceFolders || workspaceFolders.length === 0) { + this.skip(); + return; + } + + const workspaceUri = workspaceFolders[0].uri; + const envVars = await api.getEnvironmentVariables(workspaceUri); + + assert.ok(envVars !== null, 'getEnvironmentVariables with workspace uri should not return null'); + assert.ok(typeof envVars === 'object', 'Should return an object'); + }); + + // ========================================================================= + // RESOLVE ENVIRONMENT - Detailed environment info + // ========================================================================= + + test('resolveEnvironment handles invalid path gracefully', async function () { + const fakeUri = vscode.Uri.file('/this/is/not/a/python/installation'); + + // Should return undefined, not throw + const resolved = await api.resolveEnvironment(fakeUri); + assert.strictEqual(resolved, undefined, 'resolveEnvironment should return undefined for invalid path'); + }); + + test('resolveEnvironment returns full details for valid environment', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length === 0) { + this.skip(); + return; + } + + // Try to resolve the first environment's path + const env = environments[0]; + const resolved = await api.resolveEnvironment(env.environmentPath); + + if (resolved) { + // Verify resolved environment has execution info + assert.ok(resolved.execInfo, 'Resolved environment should have execInfo'); + assert.ok(resolved.execInfo.run, 'execInfo should have run configuration'); + assert.ok(resolved.execInfo.run.executable, 'run should have executable path'); + } + }); + + // ========================================================================= + // PACKAGES - Package listing (read-only) + // ========================================================================= + + test('getPackages returns array or undefined for valid environment', async function () { + const environments = await api.getEnvironments('all'); + + if (environments.length === 0) { + this.skip(); + return; + } + + const env = environments[0]; + const packages = await api.getPackages(env); + + // Should return array or undefined, not throw + assert.ok(packages === undefined || Array.isArray(packages), 'getPackages should return undefined or an array'); + + // If packages exist, verify structure + if (packages && packages.length > 0) { + const pkg = packages[0]; + assert.ok(pkg.pkgId, 'Package should have pkgId'); + assert.ok(pkg.name, 'Package should have name'); + } + }); +}); diff --git a/src/test/smoke/registration.smoke.test.ts b/src/test/smoke/registration.smoke.test.ts new file mode 100644 index 00000000..1d199c79 --- /dev/null +++ b/src/test/smoke/registration.smoke.test.ts @@ -0,0 +1,283 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Smoke Test: Registration Checks + * + * PURPOSE: + * Comprehensive verification that all commands, API methods, and events + * are properly registered and accessible. These are fast, deterministic + * checks with no side effects. + * + * WHAT THIS TESTS: + * 1. All extension commands are registered with VS Code + * 2. API surface is correct (methods exist and are functions) + * 3. Event emitters are properly exposed + * 4. Environment managers are registered + */ + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { PythonEnvironmentApi } from '../../api'; +import { ENVS_EXTENSION_ID, MAX_EXTENSION_ACTIVATION_TIME } from '../constants'; +import { waitForCondition } from '../testUtils'; + +suite('Smoke: Registration Checks', function () { + this.timeout(MAX_EXTENSION_ACTIVATION_TIME); + + let api: PythonEnvironmentApi; + + suiteSetup(async function () { + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, `Extension ${ENVS_EXTENSION_ID} not found`); + + if (!extension.isActive) { + await extension.activate(); + await waitForCondition(() => extension.isActive, 30_000, 'Extension did not activate'); + } + + api = extension.exports; + assert.ok(api, 'API not exported'); + }); + + // ========================================================================= + // COMMANDS - All extension commands must be registered + // ========================================================================= + + test('All extension commands are registered', async function () { + const allCommands = await vscode.commands.getCommands(true); + + // Complete list of commands from package.json + const requiredCommands = [ + // Environment management + 'python-envs.create', + 'python-envs.createAny', + 'python-envs.set', + 'python-envs.setEnv', + 'python-envs.setEnvSelected', + 'python-envs.remove', + 'python-envs.setEnvManager', + 'python-envs.setPkgManager', + 'python-envs.refreshAllManagers', + 'python-envs.clearCache', + 'python-envs.searchSettings', + + // Package management + 'python-envs.packages', + 'python-envs.refreshPackages', + 'python-envs.uninstallPackage', + + // Projects + 'python-envs.addPythonProject', + 'python-envs.addPythonProjectGivenResource', + 'python-envs.removePythonProject', + 'python-envs.createNewProjectFromTemplate', + 'python-envs.revealProjectInExplorer', + + // Terminal + 'python-envs.createTerminal', + 'python-envs.runInTerminal', + 'python-envs.runAsTask', + 'python-envs.terminal.activate', + 'python-envs.terminal.deactivate', + 'python-envs.terminal.revertStartupScriptChanges', + + // Utility + 'python-envs.copyEnvPath', + 'python-envs.copyEnvPathCopied', + 'python-envs.copyProjectPath', + 'python-envs.copyProjectPathCopied', + 'python-envs.revealEnvInManagerView', + 'python-envs.reportIssue', + 'python-envs.runPetInTerminal', + ]; + + const missingCommands: string[] = []; + + for (const cmd of requiredCommands) { + if (!allCommands.includes(cmd)) { + missingCommands.push(cmd); + } + } + + assert.strictEqual( + missingCommands.length, + 0, + `Missing commands:\n${missingCommands.map((c) => ` - ${c}`).join('\n')}\n\n` + + 'Check that each command is defined in package.json and registered in the extension.', + ); + }); + + // ========================================================================= + // API METHODS - All API methods must exist and be functions + // ========================================================================= + + test('Environment management API methods exist', function () { + // PythonEnvironmentsApi + assert.strictEqual(typeof api.refreshEnvironments, 'function', 'refreshEnvironments should be a function'); + assert.strictEqual(typeof api.getEnvironments, 'function', 'getEnvironments should be a function'); + assert.strictEqual(typeof api.resolveEnvironment, 'function', 'resolveEnvironment should be a function'); + + // PythonEnvironmentManagementApi + assert.strictEqual(typeof api.createEnvironment, 'function', 'createEnvironment should be a function'); + assert.strictEqual(typeof api.removeEnvironment, 'function', 'removeEnvironment should be a function'); + + // PythonProjectEnvironmentApi + assert.strictEqual(typeof api.setEnvironment, 'function', 'setEnvironment should be a function'); + assert.strictEqual(typeof api.getEnvironment, 'function', 'getEnvironment should be a function'); + + // PythonEnvironmentItemApi + assert.strictEqual( + typeof api.createPythonEnvironmentItem, + 'function', + 'createPythonEnvironmentItem should be a function', + ); + + // PythonEnvironmentManagerRegistrationApi + assert.strictEqual( + typeof api.registerEnvironmentManager, + 'function', + 'registerEnvironmentManager should be a function', + ); + }); + + test('Package management API methods exist', function () { + // PythonPackageGetterApi + assert.strictEqual(typeof api.refreshPackages, 'function', 'refreshPackages should be a function'); + assert.strictEqual(typeof api.getPackages, 'function', 'getPackages should be a function'); + + // PythonPackageManagementApi + assert.strictEqual(typeof api.managePackages, 'function', 'managePackages should be a function'); + + // PythonPackageItemApi + assert.strictEqual(typeof api.createPackageItem, 'function', 'createPackageItem should be a function'); + + // PythonPackageManagerRegistrationApi + assert.strictEqual( + typeof api.registerPackageManager, + 'function', + 'registerPackageManager should be a function', + ); + }); + + test('Project API methods exist', function () { + // PythonProjectGetterApi + assert.strictEqual(typeof api.getPythonProjects, 'function', 'getPythonProjects should be a function'); + assert.strictEqual(typeof api.getPythonProject, 'function', 'getPythonProject should be a function'); + + // PythonProjectModifyApi + assert.strictEqual(typeof api.addPythonProject, 'function', 'addPythonProject should be a function'); + assert.strictEqual(typeof api.removePythonProject, 'function', 'removePythonProject should be a function'); + + // PythonProjectCreationApi + assert.strictEqual( + typeof api.registerPythonProjectCreator, + 'function', + 'registerPythonProjectCreator should be a function', + ); + }); + + test('Execution API methods exist', function () { + // PythonTerminalCreateApi + assert.strictEqual(typeof api.createTerminal, 'function', 'createTerminal should be a function'); + + // PythonTerminalRunApi + assert.strictEqual(typeof api.runInTerminal, 'function', 'runInTerminal should be a function'); + assert.strictEqual( + typeof api.runInDedicatedTerminal, + 'function', + 'runInDedicatedTerminal should be a function', + ); + + // PythonTaskRunApi + assert.strictEqual(typeof api.runAsTask, 'function', 'runAsTask should be a function'); + + // PythonBackgroundRunApi + assert.strictEqual(typeof api.runInBackground, 'function', 'runInBackground should be a function'); + }); + + test('Environment variables API methods exist', function () { + assert.strictEqual( + typeof api.getEnvironmentVariables, + 'function', + 'getEnvironmentVariables should be a function', + ); + }); + + // ========================================================================= + // API EVENTS - All events must be defined + // ========================================================================= + + test('Environment events are defined', function () { + // Check events exist and have the expected shape + assert.ok(api.onDidChangeEnvironments, 'onDidChangeEnvironments should be defined'); + assert.ok(api.onDidChangeEnvironment, 'onDidChangeEnvironment should be defined'); + + // Events should be subscribable (have a function signature) + assert.strictEqual( + typeof api.onDidChangeEnvironments, + 'function', + 'onDidChangeEnvironments should be subscribable', + ); + assert.strictEqual( + typeof api.onDidChangeEnvironment, + 'function', + 'onDidChangeEnvironment should be subscribable', + ); + }); + + test('Package events are defined', function () { + assert.ok(api.onDidChangePackages, 'onDidChangePackages should be defined'); + assert.strictEqual(typeof api.onDidChangePackages, 'function', 'onDidChangePackages should be subscribable'); + }); + + test('Project events are defined', function () { + assert.ok(api.onDidChangePythonProjects, 'onDidChangePythonProjects should be defined'); + assert.strictEqual( + typeof api.onDidChangePythonProjects, + 'function', + 'onDidChangePythonProjects should be subscribable', + ); + }); + + test('Environment variables events are defined', function () { + assert.ok(api.onDidChangeEnvironmentVariables, 'onDidChangeEnvironmentVariables should be defined'); + assert.strictEqual( + typeof api.onDidChangeEnvironmentVariables, + 'function', + 'onDidChangeEnvironmentVariables should be subscribable', + ); + }); + + // ========================================================================= + // ENVIRONMENT MANAGERS - Built-in managers should be registered + // ========================================================================= + + test('Built-in environment managers are registered', async function () { + // Get all environments to verify managers are working + const environments = await api.getEnvironments('all'); + + // We can't guarantee environments exist, but the call should succeed + assert.ok(Array.isArray(environments), 'getEnvironments should return an array'); + + // If environments exist, verify they have the expected shape + if (environments.length > 0) { + const env = environments[0]; + assert.ok(env.envId, 'Environment should have envId'); + assert.ok(env.envId.id, 'envId should have id'); + assert.ok(env.envId.managerId, 'envId should have managerId'); + assert.ok(env.name, 'Environment should have name'); + assert.ok(env.displayName, 'Environment should have displayName'); + } + }); + + // ========================================================================= + // PROJECTS - Project API should be callable + // ========================================================================= + + test('getPythonProjects is callable', function () { + // Should not throw, even if no projects are configured + const projects = api.getPythonProjects(); + assert.ok(Array.isArray(projects), 'getPythonProjects should return an array'); + }); +}); From 1ba6c77948fde9a91a6be15694720cf6bbd9e44d Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:10:19 -0800 Subject: [PATCH 2/5] fixes --- src/extension.ts | 64 +++++++++++++---------- src/test/smoke/functional.smoke.test.ts | 60 ++++++++++++++++++++- src/test/smoke/registration.smoke.test.ts | 18 ++++++- src/test/testUtils.ts | 53 +++++++++++++++++++ 4 files changed, 166 insertions(+), 29 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 09e9b3d1..75c60395 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -515,34 +515,44 @@ export async function activate(context: ExtensionContext): Promise { - // This is the finder that is used by all the built in environment managers - const nativeFinder: NativePythonFinder = await createNativePythonFinder(outputChannel, api, context); - context.subscriptions.push(nativeFinder); - const sysMgr = new SysPythonManager(nativeFinder, api, outputChannel); - sysPythonManager.resolve(sysMgr); - await Promise.all([ - registerSystemPythonFeatures(nativeFinder, context.subscriptions, outputChannel, sysMgr), - registerCondaFeatures(nativeFinder, context.subscriptions, outputChannel, projectManager), - registerPyenvFeatures(nativeFinder, context.subscriptions, projectManager), - registerPipenvFeatures(nativeFinder, context.subscriptions, projectManager), - registerPoetryFeatures(nativeFinder, context.subscriptions, outputChannel, projectManager), - shellStartupVarsMgr.initialize(), - ]); - - await applyInitialEnvironmentSelection(envManagers, projectManager, nativeFinder, api); - - // Register manager-agnostic terminal watcher for package-modifying commands - registerTerminalPackageWatcher(api, terminalActivation, outputChannel, context.subscriptions); - - // Register listener for interpreter settings changes for interpreter re-selection - context.subscriptions.push( - registerInterpreterSettingsChangeListener(envManagers, projectManager, nativeFinder, api), - ); + try { + // This is the finder that is used by all the built in environment managers + const nativeFinder: NativePythonFinder = await createNativePythonFinder(outputChannel, api, context); + context.subscriptions.push(nativeFinder); + const sysMgr = new SysPythonManager(nativeFinder, api, outputChannel); + sysPythonManager.resolve(sysMgr); + await Promise.all([ + registerSystemPythonFeatures(nativeFinder, context.subscriptions, outputChannel, sysMgr), + registerCondaFeatures(nativeFinder, context.subscriptions, outputChannel, projectManager), + registerPyenvFeatures(nativeFinder, context.subscriptions, projectManager), + registerPipenvFeatures(nativeFinder, context.subscriptions, projectManager), + registerPoetryFeatures(nativeFinder, context.subscriptions, outputChannel, projectManager), + shellStartupVarsMgr.initialize(), + ]); + + await applyInitialEnvironmentSelection(envManagers, projectManager, nativeFinder, api); + + // Register manager-agnostic terminal watcher for package-modifying commands + registerTerminalPackageWatcher(api, terminalActivation, outputChannel, context.subscriptions); + + // Register listener for interpreter settings changes for interpreter re-selection + context.subscriptions.push( + registerInterpreterSettingsChangeListener(envManagers, projectManager, nativeFinder, api), + ); - sendTelemetryEvent(EventNames.EXTENSION_MANAGER_REGISTRATION_DURATION, start.elapsedTime); - await terminalManager.initialize(api); - sendManagerSelectionTelemetry(projectManager); - await sendProjectStructureTelemetry(projectManager, envManagers); + sendTelemetryEvent(EventNames.EXTENSION_MANAGER_REGISTRATION_DURATION, start.elapsedTime); + await terminalManager.initialize(api); + sendManagerSelectionTelemetry(projectManager); + await sendProjectStructureTelemetry(projectManager, envManagers); + } catch (error) { + traceError('Failed to initialize environment managers:', error); + // Show a user-friendly error message + window.showErrorMessage( + l10n.t( + 'Python Environments: Failed to initialize environment managers. Some features may not work correctly. Check the Output panel for details.', + ), + ); + } }); sendTelemetryEvent(EventNames.EXTENSION_ACTIVATION_DURATION, start.elapsedTime); diff --git a/src/test/smoke/functional.smoke.test.ts b/src/test/smoke/functional.smoke.test.ts index a47e81f7..e4f5ae2e 100644 --- a/src/test/smoke/functional.smoke.test.ts +++ b/src/test/smoke/functional.smoke.test.ts @@ -19,12 +19,13 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; import { PythonEnvironmentApi } from '../../api'; import { ENVS_EXTENSION_ID, MAX_EXTENSION_ACTIVATION_TIME } from '../constants'; -import { waitForCondition } from '../testUtils'; +import { waitForApiReady, waitForCondition } from '../testUtils'; suite('Smoke: Functional Checks', function () { this.timeout(MAX_EXTENSION_ACTIVATION_TIME); let api: PythonEnvironmentApi; + let managersReady = false; suiteSetup(async function () { const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); @@ -37,6 +38,15 @@ suite('Smoke: Functional Checks', function () { api = extension.exports; assert.ok(api, 'API not exported'); + + // Wait for environment managers to register (happens async in setImmediate) + // This may fail in CI if the pet binary is not available + const result = await waitForApiReady(api, 45_000); + managersReady = result.ready; + if (!result.ready) { + console.log(`[WARN] Managers not ready: ${result.error}`); + console.log('[WARN] Tests requiring managers will be skipped'); + } }); // ========================================================================= @@ -44,6 +54,12 @@ suite('Smoke: Functional Checks', function () { // ========================================================================= test('getEnvironments returns an array', async function () { + // Skip if managers aren't ready (e.g., pet binary not available in CI) + if (!managersReady) { + this.skip(); + return; + } + // This test verifies discovery machinery works // Even if no Python is installed, it should return an empty array, not throw @@ -53,6 +69,12 @@ suite('Smoke: Functional Checks', function () { }); test('getEnvironments finds Python installations when available', async function () { + // Skip if managers aren't ready (e.g., pet binary not available in CI) + if (!managersReady) { + this.skip(); + return; + } + // Skip this test if no Python is expected (CI without Python) if (process.env.SKIP_PYTHON_TESTS) { this.skip(); @@ -80,6 +102,12 @@ suite('Smoke: Functional Checks', function () { }); test('getEnvironments with scope "global" returns global interpreters', async function () { + // Skip if managers aren't ready (e.g., pet binary not available in CI) + if (!managersReady) { + this.skip(); + return; + } + const globalEnvs = await api.getEnvironments('global'); assert.ok(Array.isArray(globalEnvs), 'getEnvironments("global") should return an array'); @@ -91,6 +119,12 @@ suite('Smoke: Functional Checks', function () { }); test('refreshEnvironments completes without error', async function () { + // Skip if managers aren't ready (e.g., pet binary not available in CI) + if (!managersReady) { + this.skip(); + return; + } + // This should not throw await api.refreshEnvironments(undefined); @@ -133,6 +167,12 @@ suite('Smoke: Functional Checks', function () { // ========================================================================= test('getEnvironment returns undefined or a valid environment', async function () { + // Skip if managers aren't ready (e.g., pet binary not available in CI) + if (!managersReady) { + this.skip(); + return; + } + // With no explicit selection, may return undefined or auto-selected env const env = await api.getEnvironment(undefined); @@ -180,6 +220,12 @@ suite('Smoke: Functional Checks', function () { // ========================================================================= test('resolveEnvironment handles invalid path gracefully', async function () { + // Skip if managers aren't ready (e.g., pet binary not available in CI) + if (!managersReady) { + this.skip(); + return; + } + const fakeUri = vscode.Uri.file('/this/is/not/a/python/installation'); // Should return undefined, not throw @@ -188,6 +234,12 @@ suite('Smoke: Functional Checks', function () { }); test('resolveEnvironment returns full details for valid environment', async function () { + // Skip if managers aren't ready (e.g., pet binary not available in CI) + if (!managersReady) { + this.skip(); + return; + } + const environments = await api.getEnvironments('all'); if (environments.length === 0) { @@ -212,6 +264,12 @@ suite('Smoke: Functional Checks', function () { // ========================================================================= test('getPackages returns array or undefined for valid environment', async function () { + // Skip if managers aren't ready (e.g., pet binary not available in CI) + if (!managersReady) { + this.skip(); + return; + } + const environments = await api.getEnvironments('all'); if (environments.length === 0) { diff --git a/src/test/smoke/registration.smoke.test.ts b/src/test/smoke/registration.smoke.test.ts index 1d199c79..cf297b05 100644 --- a/src/test/smoke/registration.smoke.test.ts +++ b/src/test/smoke/registration.smoke.test.ts @@ -20,12 +20,13 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; import { PythonEnvironmentApi } from '../../api'; import { ENVS_EXTENSION_ID, MAX_EXTENSION_ACTIVATION_TIME } from '../constants'; -import { waitForCondition } from '../testUtils'; +import { waitForApiReady, waitForCondition } from '../testUtils'; suite('Smoke: Registration Checks', function () { this.timeout(MAX_EXTENSION_ACTIVATION_TIME); let api: PythonEnvironmentApi; + let managersReady = false; suiteSetup(async function () { const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); @@ -38,6 +39,15 @@ suite('Smoke: Registration Checks', function () { api = extension.exports; assert.ok(api, 'API not exported'); + + // Wait for environment managers to register (happens async in setImmediate) + // This may fail in CI if the pet binary is not available + const result = await waitForApiReady(api, 45_000); + managersReady = result.ready; + if (!result.ready) { + console.log(`[WARN] Managers not ready: ${result.error}`); + console.log('[WARN] Some tests will be skipped'); + } }); // ========================================================================= @@ -254,6 +264,12 @@ suite('Smoke: Registration Checks', function () { // ========================================================================= test('Built-in environment managers are registered', async function () { + // Skip if managers aren't ready (e.g., pet binary not available in CI) + if (!managersReady) { + this.skip(); + return; + } + // Get all environments to verify managers are working const environments = await api.getEnvironments('all'); diff --git a/src/test/testUtils.ts b/src/test/testUtils.ts index 8e550bb5..5d0e4195 100644 --- a/src/test/testUtils.ts +++ b/src/test/testUtils.ts @@ -114,6 +114,59 @@ export async function retryUntilSuccess( throw new Error(`${errorMessage}: ${lastError?.message || 'validation failed'}`); } +/** + * Result of waiting for API readiness. + */ +export interface ApiReadyResult { + /** Whether the API is ready and managers have registered */ + ready: boolean; + /** Error message if not ready */ + error?: string; +} + +/** + * Wait for the API to be fully ready, including manager registration. + * + * This waits for the async initialization that happens in setImmediate() after + * extension.activate() returns. Without this, calls to getEnvironments() may + * hang waiting for managers that haven't registered yet. + * + * @param api - The Python environment API + * @param timeoutMs - Maximum time to wait (default: 30 seconds) + * @returns Result indicating if API is ready + * + * @example + * const result = await waitForApiReady(api, 30_000); + * if (!result.ready) { + * this.skip(); // Skip test if managers not available + * } + */ +export async function waitForApiReady( + api: { getEnvironments: (scope: 'all' | 'global') => Promise }, + timeoutMs: number = 30_000, +): Promise { + // Race the getEnvironments call against a timeout + // This ensures managers have registered before we proceed + const timeoutPromise = new Promise((_, reject) => { + setTimeout( + () => reject(new Error(`API not ready within ${timeoutMs}ms - managers may not have registered`)), + timeoutMs, + ); + }); + + try { + // If getEnvironments completes, managers are ready + await Promise.race([api.getEnvironments('all'), timeoutPromise]); + return { ready: true }; + } catch (error) { + // Return error info instead of throwing + return { + ready: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + /** * Helper class to test events. * From e24c4acc4e67ca2e0b7c6513cc1d99cfc6d1da5c Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:10:51 -0800 Subject: [PATCH 3/5] Enhance testing workflow: add error handling for async initialization, remove retries to address flakiness, and improve smoke test assertions for environment managers. --- .../testing-workflow.instructions.md | 3 + .github/workflows/pr-check.yml | 96 +++++++++++++++++++ .vscode-test.mjs | 3 - src/test/e2e/index.ts | 1 - src/test/integration/index.ts | 1 - src/test/smoke/functional.smoke.test.ts | 72 +------------- src/test/smoke/index.ts | 1 - src/test/smoke/registration.smoke.test.ts | 15 +-- 8 files changed, 105 insertions(+), 87 deletions(-) diff --git a/.github/instructions/testing-workflow.instructions.md b/.github/instructions/testing-workflow.instructions.md index 8dd6067b..83b565a3 100644 --- a/.github/instructions/testing-workflow.instructions.md +++ b/.github/instructions/testing-workflow.instructions.md @@ -602,3 +602,6 @@ envConfig.inspect - Never create "documentation tests" that just `assert.ok(true)` — if mocking limitations prevent testing, either test a different layer that IS mockable, or skip the test entirely with a clear explanation (1) - When stubbing vscode APIs in tests via wrapper modules (e.g., `workspaceApis`), the production code must also use those wrappers — sinon cannot stub properties directly on the vscode namespace like `workspace.workspaceFolders`, so both production and test code must reference the same stubbable wrapper functions (4) - **Before writing tests**, check if the function under test calls VS Code APIs directly (e.g., `commands.executeCommand`, `window.createTreeView`, `workspace.getConfiguration`). If so, FIRST update the production code to use wrapper functions from `src/common/*.apis.ts` (create the wrapper if it doesn't exist), THEN write tests that stub those wrappers. This prevents CI failures where sinon cannot stub the vscode namespace (4) +- **Async initialization in `setImmediate` must have error handling**: When extension activation uses `setImmediate(async () => {...})` for async manager registration, wrap it in try-catch with proper error logging. Unhandled errors cause silent failures where managers never register, making smoke/E2E tests hang forever (1) +- **Never skip tests to hide infrastructure problems**: If tests require native binaries (like `pet`), the CI workflow must build/download them. Skipping tests when infrastructure is missing gives false confidence. Build from source (like vscode-python does) rather than skipping. Tests should fail clearly when something is wrong (2) +- **No retries for masking flakiness**: Mocha `retries` should not be used to mask test flakiness. If a test is flaky, fix the root cause. Retries hide real issues and slow down CI (1) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 03efdd0d..e5a56d22 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -88,6 +88,38 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Checkout Python Environment Tools + uses: actions/checkout@v4 + with: + repository: 'microsoft/python-environment-tools' + path: 'python-env-tools-src' + sparse-checkout: | + crates + Cargo.toml + Cargo.lock + sparse-checkout-cone-mode: false + + - name: Install Rust Toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Build Python Environment Tools + run: cargo build --release --package pet + working-directory: python-env-tools-src + + - name: Copy pet binary (Unix) + if: runner.os != 'Windows' + run: | + mkdir -p python-env-tools/bin + cp python-env-tools-src/target/release/pet python-env-tools/bin/ + chmod +x python-env-tools/bin/pet + + - name: Copy pet binary (Windows) + if: runner.os == 'Windows' + run: | + mkdir -p python-env-tools/bin + cp python-env-tools-src/target/release/pet.exe python-env-tools/bin/ + shell: bash + - name: Install Node uses: actions/setup-node@v4 with: @@ -138,6 +170,38 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Checkout Python Environment Tools + uses: actions/checkout@v4 + with: + repository: 'microsoft/python-environment-tools' + path: 'python-env-tools-src' + sparse-checkout: | + crates + Cargo.toml + Cargo.lock + sparse-checkout-cone-mode: false + + - name: Install Rust Toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Build Python Environment Tools + run: cargo build --release --package pet + working-directory: python-env-tools-src + + - name: Copy pet binary (Unix) + if: runner.os != 'Windows' + run: | + mkdir -p python-env-tools/bin + cp python-env-tools-src/target/release/pet python-env-tools/bin/ + chmod +x python-env-tools/bin/pet + + - name: Copy pet binary (Windows) + if: runner.os == 'Windows' + run: | + mkdir -p python-env-tools/bin + cp python-env-tools-src/target/release/pet.exe python-env-tools/bin/ + shell: bash + - name: Install Node uses: actions/setup-node@v4 with: @@ -188,6 +252,38 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Checkout Python Environment Tools + uses: actions/checkout@v4 + with: + repository: 'microsoft/python-environment-tools' + path: 'python-env-tools-src' + sparse-checkout: | + crates + Cargo.toml + Cargo.lock + sparse-checkout-cone-mode: false + + - name: Install Rust Toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Build Python Environment Tools + run: cargo build --release --package pet + working-directory: python-env-tools-src + + - name: Copy pet binary (Unix) + if: runner.os != 'Windows' + run: | + mkdir -p python-env-tools/bin + cp python-env-tools-src/target/release/pet python-env-tools/bin/ + chmod +x python-env-tools/bin/pet + + - name: Copy pet binary (Windows) + if: runner.os == 'Windows' + run: | + mkdir -p python-env-tools/bin + cp python-env-tools-src/target/release/pet.exe python-env-tools/bin/ + shell: bash + - name: Install Node uses: actions/setup-node@v4 with: diff --git a/.vscode-test.mjs b/.vscode-test.mjs index f6a5edf7..6bd41d01 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -11,7 +11,6 @@ export default defineConfig([ mocha: { ui: 'tdd', timeout: 120000, - retries: 1, }, env: { VSC_PYTHON_SMOKE_TEST: '1', @@ -32,7 +31,6 @@ export default defineConfig([ mocha: { ui: 'tdd', timeout: 180000, - retries: 1, }, env: { VSC_PYTHON_E2E_TEST: '1', @@ -51,7 +49,6 @@ export default defineConfig([ mocha: { ui: 'tdd', timeout: 60000, - retries: 1, }, env: { VSC_PYTHON_INTEGRATION_TEST: '1', diff --git a/src/test/e2e/index.ts b/src/test/e2e/index.ts index fa7822e8..25ef2564 100644 --- a/src/test/e2e/index.ts +++ b/src/test/e2e/index.ts @@ -20,7 +20,6 @@ export async function run(): Promise { ui: 'tdd', color: true, timeout: 180_000, // 3 minutes - E2E workflows can be slow - retries: 1, // Retry once on failure slow: 30_000, // Mark tests as slow if they take > 30s }); diff --git a/src/test/integration/index.ts b/src/test/integration/index.ts index 55454103..f805b5eb 100644 --- a/src/test/integration/index.ts +++ b/src/test/integration/index.ts @@ -17,7 +17,6 @@ export async function run(): Promise { ui: 'tdd', color: true, timeout: 120_000, // 2 minutes - retries: 1, slow: 15_000, // Mark as slow if > 15s }); diff --git a/src/test/smoke/functional.smoke.test.ts b/src/test/smoke/functional.smoke.test.ts index e4f5ae2e..2f2e8491 100644 --- a/src/test/smoke/functional.smoke.test.ts +++ b/src/test/smoke/functional.smoke.test.ts @@ -25,7 +25,6 @@ suite('Smoke: Functional Checks', function () { this.timeout(MAX_EXTENSION_ACTIVATION_TIME); let api: PythonEnvironmentApi; - let managersReady = false; suiteSetup(async function () { const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); @@ -40,13 +39,9 @@ suite('Smoke: Functional Checks', function () { assert.ok(api, 'API not exported'); // Wait for environment managers to register (happens async in setImmediate) - // This may fail in CI if the pet binary is not available + // This will fail if the pet binary is not available - which is correct behavior const result = await waitForApiReady(api, 45_000); - managersReady = result.ready; - if (!result.ready) { - console.log(`[WARN] Managers not ready: ${result.error}`); - console.log('[WARN] Tests requiring managers will be skipped'); - } + assert.ok(result.ready, `Environment managers failed to initialize: ${result.error}`); }); // ========================================================================= @@ -54,12 +49,6 @@ suite('Smoke: Functional Checks', function () { // ========================================================================= test('getEnvironments returns an array', async function () { - // Skip if managers aren't ready (e.g., pet binary not available in CI) - if (!managersReady) { - this.skip(); - return; - } - // This test verifies discovery machinery works // Even if no Python is installed, it should return an empty array, not throw @@ -69,27 +58,10 @@ suite('Smoke: Functional Checks', function () { }); test('getEnvironments finds Python installations when available', async function () { - // Skip if managers aren't ready (e.g., pet binary not available in CI) - if (!managersReady) { - this.skip(); - return; - } - - // Skip this test if no Python is expected (CI without Python) - if (process.env.SKIP_PYTHON_TESTS) { - this.skip(); - return; - } - const environments = await api.getEnvironments('all'); - // On a typical dev machine, we expect at least one Python - // This test may need to be conditional based on CI environment - if (environments.length === 0) { - console.log('[WARN] No Python environments found - is Python installed?'); - // Don't fail - just warn. CI may not have Python. - return; - } + // CI should have Python installed, so we expect at least one environment + assert.ok(environments.length > 0, 'Expected at least one Python environment - is Python installed?'); // Verify environment structure const env = environments[0]; @@ -102,12 +74,6 @@ suite('Smoke: Functional Checks', function () { }); test('getEnvironments with scope "global" returns global interpreters', async function () { - // Skip if managers aren't ready (e.g., pet binary not available in CI) - if (!managersReady) { - this.skip(); - return; - } - const globalEnvs = await api.getEnvironments('global'); assert.ok(Array.isArray(globalEnvs), 'getEnvironments("global") should return an array'); @@ -119,12 +85,6 @@ suite('Smoke: Functional Checks', function () { }); test('refreshEnvironments completes without error', async function () { - // Skip if managers aren't ready (e.g., pet binary not available in CI) - if (!managersReady) { - this.skip(); - return; - } - // This should not throw await api.refreshEnvironments(undefined); @@ -167,12 +127,6 @@ suite('Smoke: Functional Checks', function () { // ========================================================================= test('getEnvironment returns undefined or a valid environment', async function () { - // Skip if managers aren't ready (e.g., pet binary not available in CI) - if (!managersReady) { - this.skip(); - return; - } - // With no explicit selection, may return undefined or auto-selected env const env = await api.getEnvironment(undefined); @@ -220,12 +174,6 @@ suite('Smoke: Functional Checks', function () { // ========================================================================= test('resolveEnvironment handles invalid path gracefully', async function () { - // Skip if managers aren't ready (e.g., pet binary not available in CI) - if (!managersReady) { - this.skip(); - return; - } - const fakeUri = vscode.Uri.file('/this/is/not/a/python/installation'); // Should return undefined, not throw @@ -234,12 +182,6 @@ suite('Smoke: Functional Checks', function () { }); test('resolveEnvironment returns full details for valid environment', async function () { - // Skip if managers aren't ready (e.g., pet binary not available in CI) - if (!managersReady) { - this.skip(); - return; - } - const environments = await api.getEnvironments('all'); if (environments.length === 0) { @@ -264,12 +206,6 @@ suite('Smoke: Functional Checks', function () { // ========================================================================= test('getPackages returns array or undefined for valid environment', async function () { - // Skip if managers aren't ready (e.g., pet binary not available in CI) - if (!managersReady) { - this.skip(); - return; - } - const environments = await api.getEnvironments('all'); if (environments.length === 0) { diff --git a/src/test/smoke/index.ts b/src/test/smoke/index.ts index dbdafafc..7957417f 100644 --- a/src/test/smoke/index.ts +++ b/src/test/smoke/index.ts @@ -23,7 +23,6 @@ export async function run(): Promise { ui: 'tdd', color: true, timeout: 120_000, // 2 minutes - smoke tests can be slow - retries: 1, // Retry once on failure to handle flakiness slow: 10_000, // Mark tests as slow if they take > 10s }); diff --git a/src/test/smoke/registration.smoke.test.ts b/src/test/smoke/registration.smoke.test.ts index cf297b05..bd8d469e 100644 --- a/src/test/smoke/registration.smoke.test.ts +++ b/src/test/smoke/registration.smoke.test.ts @@ -26,7 +26,6 @@ suite('Smoke: Registration Checks', function () { this.timeout(MAX_EXTENSION_ACTIVATION_TIME); let api: PythonEnvironmentApi; - let managersReady = false; suiteSetup(async function () { const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); @@ -41,13 +40,9 @@ suite('Smoke: Registration Checks', function () { assert.ok(api, 'API not exported'); // Wait for environment managers to register (happens async in setImmediate) - // This may fail in CI if the pet binary is not available + // This will fail if the pet binary is not available - which is correct behavior const result = await waitForApiReady(api, 45_000); - managersReady = result.ready; - if (!result.ready) { - console.log(`[WARN] Managers not ready: ${result.error}`); - console.log('[WARN] Some tests will be skipped'); - } + assert.ok(result.ready, `Environment managers failed to initialize: ${result.error}`); }); // ========================================================================= @@ -264,12 +259,6 @@ suite('Smoke: Registration Checks', function () { // ========================================================================= test('Built-in environment managers are registered', async function () { - // Skip if managers aren't ready (e.g., pet binary not available in CI) - if (!managersReady) { - this.skip(); - return; - } - // Get all environments to verify managers are working const environments = await api.getEnvironments('all'); From be419a4878dcef79cabcf1c8e492cec32c39b2d8 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:15:45 -0800 Subject: [PATCH 4/5] updates --- .../testing-workflow.instructions.md | 1 + .github/workflows/pr-check.yml | 33 ++++ src/test/common/testUtils.unit.test.ts | 174 ++++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 src/test/common/testUtils.unit.test.ts diff --git a/.github/instructions/testing-workflow.instructions.md b/.github/instructions/testing-workflow.instructions.md index 83b565a3..b374c38d 100644 --- a/.github/instructions/testing-workflow.instructions.md +++ b/.github/instructions/testing-workflow.instructions.md @@ -605,3 +605,4 @@ envConfig.inspect - **Async initialization in `setImmediate` must have error handling**: When extension activation uses `setImmediate(async () => {...})` for async manager registration, wrap it in try-catch with proper error logging. Unhandled errors cause silent failures where managers never register, making smoke/E2E tests hang forever (1) - **Never skip tests to hide infrastructure problems**: If tests require native binaries (like `pet`), the CI workflow must build/download them. Skipping tests when infrastructure is missing gives false confidence. Build from source (like vscode-python does) rather than skipping. Tests should fail clearly when something is wrong (2) - **No retries for masking flakiness**: Mocha `retries` should not be used to mask test flakiness. If a test is flaky, fix the root cause. Retries hide real issues and slow down CI (1) +- **pet binary is required for environment manager registration**: The smoke/E2E/integration tests require the `pet` binary from `microsoft/python-environment-tools` to be built and placed in `python-env-tools/bin/`. Without it, `waitForApiReady()` will timeout because managers never register. CI must build pet from source using `cargo build --release --package pet` (2) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index e5a56d22..3e2e446d 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -102,6 +102,17 @@ jobs: - name: Install Rust Toolchain uses: dtolnay/rust-toolchain@stable + - name: Cache Rust build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + python-env-tools-src/target + key: ${{ runner.os }}-cargo-pet-${{ hashFiles('python-env-tools-src/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-pet- + - name: Build Python Environment Tools run: cargo build --release --package pet working-directory: python-env-tools-src @@ -184,6 +195,17 @@ jobs: - name: Install Rust Toolchain uses: dtolnay/rust-toolchain@stable + - name: Cache Rust build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + python-env-tools-src/target + key: ${{ runner.os }}-cargo-pet-${{ hashFiles('python-env-tools-src/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-pet- + - name: Build Python Environment Tools run: cargo build --release --package pet working-directory: python-env-tools-src @@ -266,6 +288,17 @@ jobs: - name: Install Rust Toolchain uses: dtolnay/rust-toolchain@stable + - name: Cache Rust build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + python-env-tools-src/target + key: ${{ runner.os }}-cargo-pet-${{ hashFiles('python-env-tools-src/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-pet- + - name: Build Python Environment Tools run: cargo build --release --package pet working-directory: python-env-tools-src diff --git a/src/test/common/testUtils.unit.test.ts b/src/test/common/testUtils.unit.test.ts new file mode 100644 index 00000000..526367a6 --- /dev/null +++ b/src/test/common/testUtils.unit.test.ts @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import { retryUntilSuccess, sleep, waitForApiReady, waitForCondition } from '../testUtils'; + +suite('Test Utilities', () => { + suite('sleep', () => { + test('should resolve after specified time', async () => { + const start = Date.now(); + await sleep(50); + const elapsed = Date.now() - start; + assert.ok(elapsed >= 45, `Expected at least 45ms, got ${elapsed}ms`); + }); + }); + + suite('waitForCondition', () => { + test('should resolve immediately when condition is true', async () => { + await waitForCondition(() => true, 100, 'Should not fail'); + }); + + test('should resolve when condition becomes true', async () => { + let counter = 0; + await waitForCondition( + () => { + counter++; + return counter >= 3; + }, + 1000, + 'Condition did not become true', + 10, + ); + assert.ok(counter >= 3, 'Condition should have been checked multiple times'); + }); + + test('should reject when timeout is reached', async () => { + await assert.rejects( + () => waitForCondition(() => false, 100, 'Custom error message', 10), + /Custom error message \(waited 100ms\)/, + ); + }); + + test('should handle async conditions', async () => { + let counter = 0; + await waitForCondition( + async () => { + counter++; + await sleep(5); + return counter >= 2; + }, + 1000, + 'Async condition failed', + 10, + ); + assert.ok(counter >= 2); + }); + + test('should continue polling when condition throws', async () => { + let counter = 0; + await waitForCondition( + () => { + counter++; + if (counter < 3) { + throw new Error('Not ready yet'); + } + return true; + }, + 1000, + 'Should eventually succeed', + 10, + ); + assert.ok(counter >= 3); + }); + }); + + suite('retryUntilSuccess', () => { + test('should return result when function succeeds immediately', async () => { + const result = await retryUntilSuccess( + () => 42, + () => true, + 1000, + 'Should not fail', + ); + assert.strictEqual(result, 42); + }); + + test('should return result when validation passes', async () => { + let counter = 0; + const result = await retryUntilSuccess( + () => { + counter++; + return counter; + }, + (val) => val >= 3, + 1000, + 'Validation failed', + ); + assert.ok(result >= 3); + }); + + test('should reject when timeout reached', async () => { + await assert.rejects( + () => + retryUntilSuccess( + () => 1, + (val) => val > 10, + 100, + 'Custom timeout error', + ), + /Custom timeout error: validation failed/, + ); + }); + + test('should include last error message in rejection', async () => { + await assert.rejects( + () => + retryUntilSuccess( + () => { + throw new Error('Specific failure'); + }, + () => true, + 100, + 'Operation failed', + ), + /Operation failed: Specific failure/, + ); + }); + + test('should handle async functions', async () => { + let counter = 0; + const result = await retryUntilSuccess( + async () => { + counter++; + await sleep(5); + return counter; + }, + (val) => val >= 2, + 1000, + 'Async retry failed', + ); + assert.ok(result >= 2); + }); + }); + + suite('waitForApiReady', () => { + test('should return ready:true when getEnvironments succeeds', async () => { + const mockApi = { + getEnvironments: async () => [], + }; + const result = await waitForApiReady(mockApi, 1000); + assert.deepStrictEqual(result, { ready: true }); + }); + + test('should return ready:false with error when timeout reached', async () => { + const mockApi = { + getEnvironments: (): Promise => new Promise(() => {}), // Never resolves + }; + const result = await waitForApiReady(mockApi, 100); + assert.strictEqual(result.ready, false); + assert.ok(result.error?.includes('API not ready within 100ms')); + }); + + test('should return ready:false when getEnvironments throws', async () => { + const mockApi = { + getEnvironments: async () => { + throw new Error('Manager not registered'); + }, + }; + const result = await waitForApiReady(mockApi, 1000); + assert.strictEqual(result.ready, false); + assert.ok(result.error?.includes('Manager not registered')); + }); + }); +}); From cac6050b0ded969d62b8f9c29c3a68aa5b411d63 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:21:06 -0800 Subject: [PATCH 5/5] improve merge --- src/test/smoke/functional.smoke.test.ts | 35 +++---------------------- 1 file changed, 4 insertions(+), 31 deletions(-) diff --git a/src/test/smoke/functional.smoke.test.ts b/src/test/smoke/functional.smoke.test.ts index ab2c9fcf..2f2e8491 100644 --- a/src/test/smoke/functional.smoke.test.ts +++ b/src/test/smoke/functional.smoke.test.ts @@ -39,13 +39,9 @@ suite('Smoke: Functional Checks', function () { assert.ok(api, 'API not exported'); // Wait for environment managers to register (happens async in setImmediate) - // This may fail in CI if the pet binary is not available + // This will fail if the pet binary is not available - which is correct behavior const result = await waitForApiReady(api, 45_000); - managersReady = result.ready; - if (!result.ready) { - console.log(`[WARN] Managers not ready: ${result.error}`); - console.log('[WARN] Tests requiring managers will be skipped'); - } + assert.ok(result.ready, `Environment managers failed to initialize: ${result.error}`); }); // ========================================================================= @@ -53,12 +49,6 @@ suite('Smoke: Functional Checks', function () { // ========================================================================= test('getEnvironments returns an array', async function () { - // Skip if managers aren't ready (e.g., pet binary not available in CI) - if (!managersReady) { - this.skip(); - return; - } - // This test verifies discovery machinery works // Even if no Python is installed, it should return an empty array, not throw @@ -68,27 +58,10 @@ suite('Smoke: Functional Checks', function () { }); test('getEnvironments finds Python installations when available', async function () { - // Skip if managers aren't ready (e.g., pet binary not available in CI) - if (!managersReady) { - this.skip(); - return; - } - - // Skip this test if no Python is expected (CI without Python) - if (process.env.SKIP_PYTHON_TESTS) { - this.skip(); - return; - } - const environments = await api.getEnvironments('all'); - // On a typical dev machine, we expect at least one Python - // This test may need to be conditional based on CI environment - if (environments.length === 0) { - console.log('[WARN] No Python environments found - is Python installed?'); - // Don't fail - just warn. CI may not have Python. - return; - } + // CI should have Python installed, so we expect at least one environment + assert.ok(environments.length > 0, 'Expected at least one Python environment - is Python installed?'); // Verify environment structure const env = environments[0];