diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 3e7e9e9cf091..ffc0150ebac5 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/devcontainers/typescript-node:18-bookworm +FROM mcr.microsoft.com/devcontainers/typescript-node:22-bookworm RUN apt-get install -y wget bzip2 diff --git a/.github/actions/build-vsix/action.yml b/.github/actions/build-vsix/action.yml index 1b665363b34f..912ff2c34a74 100644 --- a/.github/actions/build-vsix/action.yml +++ b/.github/actions/build-vsix/action.yml @@ -31,10 +31,10 @@ runs: uses: dtolnay/rust-toolchain@stable # Jedi LS depends on dataclasses which is not in the stdlib in Python 3.7. - - name: Use Python 3.9 for JediLSP + - name: Use Python 3.10 for JediLSP uses: actions/setup-python@v6 with: - python-version: 3.9 + python-version: '3.10' cache: 'pip' cache-dependency-path: | requirements.txt @@ -93,7 +93,7 @@ runs: VSIX_NAME: ${{ inputs.vsix_name }} - name: Upload VSIX - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v7 with: name: ${{ inputs.artifact_name }} path: ${{ inputs.vsix_name }} diff --git a/.github/commands.json b/.github/commands.json index 2fb6684a7ee6..171f33f380c3 100644 --- a/.github/commands.json +++ b/.github/commands.json @@ -153,5 +153,14 @@ "addLabel": "info-needed", "removeLabel": "~confirmation-needed", "comment": "Please diagnose the root cause of the issue by running the command `F1 > Help: Troubleshoot Issue` and following the instructions. Once you have done that, please update the issue with the results.\n\nHappy Coding!" + }, + { + "type": "label", + "name": "~spam", + "removeLabel": "~spam", + "addLabel": "spam", + "action": "close", + "reason": "not_planned", + "comment": "Thank you for your submission. This issue has been closed as it doesn't meet our community guidelines or appears to be spam.\n\n**If you believe this was closed in error:**\n- Please review our [Code of Conduct](https://opensource.microsoft.com/codeofconduct/)\n- Ensure your issue contains a clear description of the problem or feature request\n- Feel free to open a new issue with appropriate detail if this was a legitimate concern\n\n**For legitimate issues, please include:**\n- Clear description of the problem\n- Steps to reproduce (for bugs)\n- Expected vs actual behavior\n- VS Code version and environment details\n\nThank you for helping us maintain a welcoming and productive community." } ] diff --git a/.github/instructions/testing-workflow.instructions.md b/.github/instructions/testing-workflow.instructions.md new file mode 100644 index 000000000000..844946404328 --- /dev/null +++ b/.github/instructions/testing-workflow.instructions.md @@ -0,0 +1,581 @@ +--- +applyTo: '**/test/**' +--- + +# AI Testing Workflow Guide: Write, Run, and Fix Tests + +This guide provides comprehensive instructions for AI agents on the complete testing workflow: writing tests, running them, diagnosing failures, and fixing issues. Use this guide whenever working with test files or when users request testing tasks. + +## Complete Testing Workflow + +This guide covers the full testing lifecycle: + +1. **πŸ“ Writing Tests** - Create comprehensive test suites +2. **▢️ Running Tests** - Execute tests using VS Code tools +3. **πŸ” Diagnosing Issues** - Analyze failures and errors +4. **πŸ› οΈ Fixing Problems** - Resolve compilation and runtime issues +5. **βœ… Validation** - Ensure coverage and resilience + +### When to Use This Guide + +**User Requests Testing:** + +- "Write tests for this function" +- "Run the tests" +- "Fix the failing tests" +- "Test this code" +- "Add test coverage" + +**File Context Triggers:** + +- Working in `**/test/**` directories +- Files ending in `.test.ts` or `.unit.test.ts` +- Test failures or compilation errors +- Coverage reports or test output analysis + +## Test Types + +When implementing tests as an AI agent, choose between two main types: + +### Unit Tests (`*.unit.test.ts`) + +- **Fast isolated testing** - Mock all external dependencies +- **Use for**: Pure functions, business logic, data transformations +- **Execute with**: `runTests` tool with specific file patterns +- **Mock everything** - VS Code APIs automatically mocked via `/src/test/unittests.ts` + +### Extension Tests (`*.test.ts`) + +- **Full VS Code integration** - Real environment with actual APIs +- **Use for**: Command registration, UI interactions, extension lifecycle +- **Execute with**: VS Code launch configurations or `runTests` tool +- **Slower but comprehensive** - Tests complete user workflows + +## πŸ€– Agent Tool Usage for Test Execution + +### Primary Tool: `runTests` + +Use the `runTests` tool to execute tests programmatically rather than terminal commands for better integration and result parsing: + +```typescript +// Run specific test files +await runTests({ + files: ['/absolute/path/to/test.unit.test.ts'], + mode: 'run', +}); + +// Run tests with coverage +await runTests({ + files: ['/absolute/path/to/test.unit.test.ts'], + mode: 'coverage', + coverageFiles: ['/absolute/path/to/source.ts'], +}); + +// Run specific test names +await runTests({ + files: ['/absolute/path/to/test.unit.test.ts'], + testNames: ['should handle edge case', 'should validate input'], +}); +``` + +### Compilation Requirements + +Before running tests, ensure compilation. Always start compilation with `npm run watch-tests` before test execution to ensure TypeScript files are built. Recompile after making import/export changes before running tests, as stubs won't work if they're applied to old compiled JavaScript that doesn't have the updated imports: + +```typescript +// Start watch mode for auto-compilation +await run_in_terminal({ + command: 'npm run watch-tests', + isBackground: true, + explanation: 'Start test compilation in watch mode', +}); + +// Or compile manually +await run_in_terminal({ + command: 'npm run compile-tests', + isBackground: false, + explanation: 'Compile TypeScript test files', +}); +``` + +### Alternative: Terminal Execution + +For targeted test runs when `runTests` tool is unavailable. Note: When a targeted test run yields 0 tests, first verify the compiled JS exists under `out/test` (rootDir is `src`); absence almost always means the test file sits outside `src` or compilation hasn't run yet: + +```typescript +// Run specific test suite +await run_in_terminal({ + command: 'npm run unittest -- --grep "Suite Name"', + isBackground: false, + explanation: 'Run targeted unit tests', +}); +``` + +## πŸ” Diagnosing Test Failures + +### Common Failure Patterns + +**Compilation Errors:** + +```typescript +// Missing imports +if (error.includes('Cannot find module')) { + await addMissingImports(testFile); +} + +// Type mismatches +if (error.includes("Type '" && error.includes("' is not assignable"))) { + await fixTypeIssues(testFile); +} +``` + +**Runtime Errors:** + +```typescript +// Mock setup issues +if (error.includes('stub') || error.includes('mock')) { + await fixMockConfiguration(testFile); +} + +// Assertion failures +if (error.includes('AssertionError')) { + await analyzeAssertionFailure(error); +} +``` + +### Systematic Failure Analysis + +Fix test issues iteratively - run tests, analyze failures, apply fixes, repeat until passing. When unit tests fail with VS Code API errors like `TypeError: X is not a constructor` or `Cannot read properties of undefined (reading 'Y')`, check if VS Code APIs are properly mocked in `/src/test/unittests.ts` - add missing APIs following the existing pattern. + +```typescript +interface TestFailureAnalysis { + type: 'compilation' | 'runtime' | 'assertion' | 'timeout'; + message: string; + location: { file: string; line: number; col: number }; + suggestedFix: string; +} + +function analyzeFailure(failure: TestFailure): TestFailureAnalysis { + if (failure.message.includes('Cannot find module')) { + return { + type: 'compilation', + message: failure.message, + location: failure.location, + suggestedFix: 'Add missing import statement', + }; + } + // ... other failure patterns +} +``` + +### Agent Decision Logic for Test Type Selection + +**Choose Unit Tests (`*.unit.test.ts`) when analyzing:** + +- Functions with clear inputs/outputs and no VS Code API dependencies +- Data transformation, parsing, or utility functions +- Business logic that can be isolated with mocks +- Error handling scenarios with predictable inputs + +**Choose Extension Tests (`*.test.ts`) when analyzing:** + +- Functions that register VS Code commands or use `vscode.*` APIs +- UI components, tree views, or command palette interactions +- File system operations requiring workspace context +- Extension lifecycle events (activation, deactivation) + +**Agent Implementation Pattern:** + +```typescript +function determineTestType(functionCode: string): 'unit' | 'extension' { + if ( + functionCode.includes('vscode.') || + functionCode.includes('commands.register') || + functionCode.includes('window.') || + functionCode.includes('workspace.') + ) { + return 'extension'; + } + return 'unit'; +} +``` + +## 🎯 Step 1: Automated Function Analysis + +As an AI agent, analyze the target function systematically: + +### Code Analysis Checklist + +```typescript +interface FunctionAnalysis { + name: string; + inputs: string[]; // Parameter types and names + outputs: string; // Return type + dependencies: string[]; // External modules/APIs used + sideEffects: string[]; // Logging, file system, network calls + errorPaths: string[]; // Exception scenarios + testType: 'unit' | 'extension'; +} +``` + +### Analysis Implementation + +1. **Read function source** using `read_file` tool +2. **Identify imports** - look for `vscode.*`, `child_process`, `fs`, etc. +3. **Map data flow** - trace inputs through transformations to outputs +4. **Catalog dependencies** - external calls that need mocking +5. **Document side effects** - logging, file operations, state changes + +### Test Setup Differences + +#### Unit Test Setup (\*.unit.test.ts) + +```typescript +// Mock VS Code APIs - handled automatically by unittests.ts +import * as sinon from 'sinon'; +import * as workspaceApis from '../../common/workspace.apis'; // Wrapper functions + +// Stub wrapper functions, not VS Code APIs directly +// Always mock wrapper functions (e.g., workspaceApis.getConfiguration()) instead of +// VS Code APIs directly to avoid stubbing issues +const mockGetConfiguration = sinon.stub(workspaceApis, 'getConfiguration'); +``` + +#### Extension Test Setup (\*.test.ts) + +```typescript +// Use real VS Code APIs +import * as vscode from 'vscode'; + +// Real VS Code APIs available - no mocking needed +const config = vscode.workspace.getConfiguration('python'); +``` + +## 🎯 Step 2: Generate Test Coverage Matrix + +Based on function analysis, automatically generate comprehensive test scenarios: + +### Coverage Matrix Generation + +```typescript +interface TestScenario { + category: 'happy-path' | 'edge-case' | 'error-handling' | 'side-effects'; + description: string; + inputs: Record; + expectedOutput?: any; + expectedSideEffects?: string[]; + shouldThrow?: boolean; +} +``` + +### Automated Scenario Creation + +1. **Happy Path**: Normal execution with typical inputs +2. **Edge Cases**: Boundary conditions, empty/null inputs, unusual but valid data +3. **Error Scenarios**: Invalid inputs, dependency failures, exception paths +4. **Side Effects**: Verify logging calls, file operations, state changes + +### Agent Pattern for Scenario Generation + +```typescript +function generateTestScenarios(analysis: FunctionAnalysis): TestScenario[] { + const scenarios: TestScenario[] = []; + + // Generate happy path for each input combination + scenarios.push(...generateHappyPathScenarios(analysis)); + + // Generate edge cases for boundary conditions + scenarios.push(...generateEdgeCaseScenarios(analysis)); + + // Generate error scenarios for each dependency + scenarios.push(...generateErrorScenarios(analysis)); + + return scenarios; +} +``` + +## πŸ—ΊοΈ Step 3: Plan Your Test Coverage + +### Create a Test Coverage Matrix + +#### Main Flows + +- βœ… **Happy path scenarios** - normal expected usage +- βœ… **Alternative paths** - different configuration combinations +- βœ… **Integration scenarios** - multiple features working together + +#### Edge Cases + +- πŸ”Έ **Boundary conditions** - empty inputs, missing data +- πŸ”Έ **Error scenarios** - network failures, permission errors +- πŸ”Έ **Data validation** - invalid inputs, type mismatches + +#### Real-World Scenarios + +- βœ… **Fresh install** - clean slate +- βœ… **Existing user** - migration scenarios +- βœ… **Power user** - complex configurations +- πŸ”Έ **Error recovery** - graceful degradation + +### Example Test Plan Structure + +```markdown +## Test Categories + +### 1. Configuration Migration Tests + +- No legacy settings exist +- Legacy settings already migrated +- Fresh migration needed +- Partial migration required +- Migration failures + +### 2. Configuration Source Tests + +- Global search paths +- Workspace search paths +- Settings precedence +- Configuration errors + +### 3. Path Resolution Tests + +- Absolute vs relative paths +- Workspace folder resolution +- Path validation and filtering + +### 4. Integration Scenarios + +- Combined configurations +- Deduplication logic +- Error handling flows +``` + +## πŸ”§ Step 4: Set Up Your Test Infrastructure + +### Test File Structure + +```typescript +// 1. Imports - group logically +import assert from 'node:assert'; +import * as sinon from 'sinon'; +import { Uri } from 'vscode'; +import * as logging from '../../../common/logging'; +import * as pathUtils from '../../../common/utils/pathUtils'; +import * as workspaceApis from '../../../common/workspace.apis'; + +// 2. Function under test +import { getAllExtraSearchPaths } from '../../../managers/common/nativePythonFinder'; + +// 3. Mock interfaces +interface MockWorkspaceConfig { + get: sinon.SinonStub; + inspect: sinon.SinonStub; + update: sinon.SinonStub; +} +``` + +### Mock Setup Strategy + +Create minimal mock objects with only required methods and use TypeScript type assertions (e.g., `mockApi as PythonEnvironmentApi`) to satisfy interface requirements instead of implementing all interface methods when only specific methods are needed for the test. Simplify mock setup by only mocking methods actually used in tests and use `as unknown as Type` for TypeScript compatibility. + +```typescript +suite('Function Integration Tests', () => { + // 1. Declare all mocks + let mockGetConfiguration: sinon.SinonStub; + let mockGetWorkspaceFolders: sinon.SinonStub; + let mockTraceLog: sinon.SinonStub; + let mockTraceError: sinon.SinonStub; + let mockTraceWarn: sinon.SinonStub; + + // 2. Mock complex objects + let pythonConfig: MockWorkspaceConfig; + let envConfig: MockWorkspaceConfig; + + setup(() => { + // 3. Initialize all mocks + mockGetConfiguration = sinon.stub(workspaceApis, 'getConfiguration'); + mockGetWorkspaceFolders = sinon.stub(workspaceApis, 'getWorkspaceFolders'); + mockTraceLog = sinon.stub(logging, 'traceLog'); + mockTraceError = sinon.stub(logging, 'traceError'); + mockTraceWarn = sinon.stub(logging, 'traceWarn'); + + // 4. Set up default behaviors + mockGetWorkspaceFolders.returns(undefined); + + // 5. Create mock configuration objects + // When fixing mock environment creation, use null to truly omit + // properties rather than undefined + pythonConfig = { + get: sinon.stub(), + inspect: sinon.stub(), + update: sinon.stub(), + }; + + envConfig = { + get: sinon.stub(), + inspect: sinon.stub(), + update: sinon.stub(), + }; + }); + + teardown(() => { + sinon.restore(); // Always clean up! + }); +}); +``` + +## Step 4: Write Tests Using Mock β†’ Run β†’ Assert Pattern + +### The Three-Phase Pattern + +#### Phase 1: Mock (Set up the scenario) + +```typescript +test('Description of what this tests', async () => { + // Mock β†’ Clear description of the scenario + pythonConfig.inspect.withArgs('venvPath').returns({ globalValue: '/path' }); + envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] }); + mockGetWorkspaceFolders.returns([{ uri: Uri.file('/workspace') }]); +``` + +#### Phase 2: Run (Execute the function) + +```typescript +// Run +const result = await getAllExtraSearchPaths(); +``` + +#### Phase 3: Assert (Verify the behavior) + +```typescript + // Assert - Use set-based comparison for order-agnostic testing + const expected = new Set(['/expected', '/paths']); + const actual = new Set(result); + assert.strictEqual(actual.size, expected.size, 'Should have correct number of paths'); + assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); + + // Verify side effects + // Use sinon.match() patterns for resilient assertions that don't break on minor output changes + assert(mockTraceLog.calledWith(sinon.match(/completion/i)), 'Should log completion'); +}); +``` + +## Step 6: Make Tests Resilient + +### Use Order-Agnostic Comparisons + +```typescript +// ❌ Brittle - depends on order +assert.deepStrictEqual(result, ['/path1', '/path2', '/path3']); + +// βœ… Resilient - order doesn't matter +const expected = new Set(['/path1', '/path2', '/path3']); +const actual = new Set(result); +assert.strictEqual(actual.size, expected.size, 'Should have correct number of paths'); +assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); +``` + +### Use Flexible Error Message Testing + +```typescript +// ❌ Brittle - exact text matching +assert(mockTraceError.calledWith('Error during legacy python settings migration:')); + +// βœ… Resilient - pattern matching +assert(mockTraceError.calledWith(sinon.match.string, sinon.match.instanceOf(Error)), 'Should log migration error'); + +// βœ… Resilient - key terms with regex +assert(mockTraceError.calledWith(sinon.match(/migration.*error/i)), 'Should log migration error'); +``` + +### Handle Complex Mock Scenarios + +```typescript +// For functions that call the same mock multiple times +envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] }); +envConfig.inspect + .withArgs('globalSearchPaths') + .onSecondCall() + .returns({ + globalValue: ['/migrated/paths'], + }); + +// Testing async functions with child processes: +// Call the function first to get a promise, then use setTimeout to emit mock events, +// then await the promise - this ensures proper timing of mock setup versus function execution + +// Cannot stub internal function calls within the same module after import - stub external +// dependencies instead (e.g., stub childProcessApis.spawnProcess rather than trying to stub +// helpers.isUvInstalled when testing helpers.shouldUseUv) because intra-module calls use +// direct references, not module exports +``` + +## πŸ§ͺ Step 7: Test Categories and Patterns + +### Configuration Tests + +- Test different setting combinations +- Test setting precedence (workspace > user > default) +- Test configuration errors and recovery +- Always use dynamic path construction with Node.js `path` module when testing functions that resolve paths against workspace folders to ensure cross-platform compatibility + +### Data Flow Tests + +- Test how data moves through the system +- Test transformations (path resolution, filtering) +- Test state changes (migrations, updates) + +### Error Handling Tests + +- Test graceful degradation +- Test error logging +- Test fallback behaviors + +### Integration Tests + +- Test multiple features together +- Test real-world scenarios +- Test edge case combinations + +## πŸ“Š Step 8: Review and Refine + +### Test Quality Checklist + +- [ ] **Clear naming** - test names describe the scenario and expected outcome +- [ ] **Good coverage** - main flows, edge cases, error scenarios +- [ ] **Resilient assertions** - won't break due to minor changes +- [ ] **Readable structure** - follows Mock β†’ Run β†’ Assert pattern +- [ ] **Isolated tests** - each test is independent +- [ ] **Fast execution** - tests run quickly with proper mocking + +### Common Anti-Patterns to Avoid + +- ❌ Testing implementation details instead of behavior +- ❌ Brittle assertions that break on cosmetic changes +- ❌ Order-dependent tests that fail due to processing changes +- ❌ Tests that don't clean up mocks properly +- ❌ Overly complex test setup that's hard to understand + +## πŸ”„ Reviewing and Improving Existing Tests + +### Quick Review Process + +1. **Read test files** - Check structure and mock setup +2. **Run tests** - Establish baseline functionality +3. **Apply improvements** - Use patterns below. When reviewing existing tests, focus on behavior rather than implementation details in test names and assertions +4. **Verify** - Ensure tests still pass + +### Common Fixes + +- Over-complex mocks β†’ Minimal mocks with only needed methods +- Brittle assertions β†’ Behavior-focused with error messages +- Vague test names β†’ Clear scenario descriptions (transform "should return X when Y" into "should [expected behavior] when [scenario context]") +- Missing structure β†’ Mock β†’ Run β†’ Assert pattern +- Untestable Node.js APIs β†’ Create proxy abstraction functions (use function overloads to preserve intelligent typing while making functions mockable) + +## 🧠 Agent Learnings + +- When mocking `testController.createTestItem()` in unit tests, use `typemoq.It.isAny()` for parameters when testing handler behavior (not ID/label generation logic), but consider using specific matchers (e.g., `It.is((id: string) => id.startsWith('_error_'))`) when the actual values being passed are important for correctness - this balances test precision with maintainability (2) +- Remove unused variables from test code immediately - leftover tracking variables like `validationCallCount` that aren't referenced indicate dead code that should be simplified (1) +- Use `Uri.file(path).fsPath` for both sides of path comparisons in tests to ensure cross-platform compatibility - Windows converts forward slashes to backslashes automatically (1) +- When tests fail with "Cannot stub non-existent property", the method likely moved to a different class during refactoring - find the class that owns the method and test that class directly instead of stubbing on the original class (1) diff --git a/.github/instructions/testing_feature_area.instructions.md b/.github/instructions/testing_feature_area.instructions.md index 038dc1025ea5..a4e11523d7c8 100644 --- a/.github/instructions/testing_feature_area.instructions.md +++ b/.github/instructions/testing_feature_area.instructions.md @@ -26,6 +26,10 @@ This document maps the testing support in the extension: discovery, execution (r - `src/client/testing/serviceRegistry.ts` β€” DI/wiring for testing services. - Workspace orchestration - `src/client/testing/testController/workspaceTestAdapter.ts` β€” `WorkspaceTestAdapter` (provider-agnostic entry used by controller). +- **Project-based testing (multi-project workspaces)** + - `src/client/testing/testController/common/testProjectRegistry.ts` β€” `TestProjectRegistry` (manages project lifecycle, discovery, and nested project handling). + - `src/client/testing/testController/common/projectAdapter.ts` β€” `ProjectAdapter` interface (represents a single Python project with its own test infrastructure). + - `src/client/testing/testController/common/projectUtils.ts` β€” utilities for project ID generation, display names, and shared adapter creation. - Provider adapters - Unittest - `src/client/testing/testController/unittest/testDiscoveryAdapter.ts` @@ -151,6 +155,78 @@ The adapters in the extension don't implement test discovery/run logic themselve - Settings are consumed by `src/client/testing/common/testConfigurationManager.ts`, `src/client/testing/configurationFactory.ts`, and adapters under `src/client/testing/testController/*` which read settings to build CLI args and env for subprocesses. - The setting definitions and descriptions are in `package.json` and localized strings in `package.nls.json`. +## Project-based testing (multi-project workspaces) + +Project-based testing enables multi-project workspace support where each Python project gets its own test tree root with its own Python environment. + +### Architecture + +- **TestProjectRegistry** (`testProjectRegistry.ts`): Central registry that: + + - Discovers Python projects via the Python Environments API + - Creates and manages `ProjectAdapter` instances per workspace + - Computes nested project relationships and configures ignore lists + - Falls back to "legacy" single-adapter mode when API unavailable + +- **ProjectAdapter** (`projectAdapter.ts`): Interface representing a single project with: + - Project identity (ID, name, URI from Python Environments API) + - Python environment with execution details + - Test framework adapters (discovery/execution) + - Nested project ignore paths (for parent projects) + +### How it works + +1. **Activation**: When the extension activates, `PythonTestController` checks if the Python Environments API is available. +2. **Project discovery**: `TestProjectRegistry.discoverAndRegisterProjects()` queries the API for all Python projects in each workspace. +3. **Nested handling**: `configureNestedProjectIgnores()` identifies child projects and adds their paths to parent projects' ignore lists. +4. **Test discovery**: For each project, the controller calls `project.discoveryAdapter.discoverTests()` with the project's URI. The adapter sets `PROJECT_ROOT_PATH` environment variable for the Python runner. +5. **Python side**: + - For pytest: `get_test_root_path()` in `vscode_pytest/__init__.py` returns `PROJECT_ROOT_PATH` (if set) or falls back to `cwd`. + - For unittest: `discovery.py` uses `PROJECT_ROOT_PATH` as `top_level_dir` and `project_root_path` to root the test tree at the project directory. +6. **Test tree**: Each project gets its own root node in the Test Explorer, with test IDs scoped by project ID using the `@@vsc@@` separator (defined in `projectUtils.ts`). + +### Nested project handling: pytest vs unittest + +**pytest** supports the `--ignore` flag to exclude paths during test collection. When nested projects are detected, parent projects automatically receive `--ignore` flags for child project paths. This ensures each test appears under exactly one project in the test tree. + +**unittest** does not support path exclusion during `discover()`. Therefore, tests in nested project directories may appear under multiple project roots (both the parent and the child project). This is **expected behavior** for unittest: + +- Each project discovers and displays all tests it finds within its directory structure +- There is no deduplication or collision detection +- Users may see the same test file under multiple project roots if their project structure has nesting + +This approach was chosen because: + +1. unittest's `TestLoader.discover()` has no built-in path exclusion mechanism +2. Implementing custom exclusion would add significant complexity with minimal benefit +3. The existing approach is transparent and predictable - each project shows what it finds + +### Empty projects and root nodes + +If a project discovers zero tests, its root node will still appear in the Test Explorer as an empty folder. This ensures consistent behavior and makes it clear which projects were discovered, even if they have no tests yet. + +### Logging prefix + +All project-based testing logs use the `[test-by-project]` prefix for easy filtering in the output channel. + +### Key files + +- Python side: + - `python_files/vscode_pytest/__init__.py` β€” `get_test_root_path()` function and `PROJECT_ROOT_PATH` environment variable for pytest. + - `python_files/unittestadapter/discovery.py` β€” `discover_tests()` with `project_root_path` parameter and `PROJECT_ROOT_PATH` handling for unittest discovery. + - `python_files/unittestadapter/execution.py` β€” `run_tests()` with `project_root_path` parameter and `PROJECT_ROOT_PATH` handling for unittest execution. +- TypeScript: `testProjectRegistry.ts`, `projectAdapter.ts`, `projectUtils.ts`, and the discovery/execution adapters. + +### Tests + +- `src/test/testing/testController/common/testProjectRegistry.unit.test.ts` β€” TestProjectRegistry tests +- `src/test/testing/testController/common/projectUtils.unit.test.ts` β€” Project utility function tests +- `python_files/tests/pytestadapter/test_discovery.py` β€” pytest PROJECT_ROOT_PATH tests (see `test_project_root_path_env_var()` and `test_symlink_with_project_root_path()`) +- `python_files/tests/unittestadapter/test_discovery.py` β€” unittest `project_root_path` / PROJECT_ROOT_PATH discovery tests +- `python_files/tests/unittestadapter/test_execution.py` β€” unittest `project_root_path` / PROJECT_ROOT_PATH execution tests +- `src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts` β€” unittest discovery adapter PROJECT_ROOT_PATH tests +- `src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts` β€” unittest execution adapter PROJECT_ROOT_PATH tests + ## Coverage support (how it works) - Coverage is supported by running the Python helper scripts with coverage enabled and then collecting a coverage payload from the runner. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b4ac46558b63..09d019dec4a7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,7 +11,7 @@ on: permissions: {} env: - NODE_VERSION: 22.17.0 + NODE_VERSION: 22.21.1 PYTHON_VERSION: '3.10' # YML treats 3.10 the number as 3.1, so quotes around 3.10 # Force a path with spaces and to test extension works in these scenarios # Unicode characters are causing 2.7 failures so skip that for now. @@ -84,12 +84,12 @@ jobs: # vsix-target: alpine-arm64 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Checkout Python Environment Tools - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: 'microsoft/python-environment-tools' path: 'python-env-tools' @@ -115,7 +115,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false @@ -135,7 +135,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false @@ -155,7 +155,7 @@ jobs: python -m pip install --upgrade -r build/test-requirements.txt - name: Run Pyright - uses: jakebailey/pyright-action@6cabc0f01c4994be48fd45cd9dbacdd6e1ee6e5e # v2.3.3 + uses: jakebailey/pyright-action@8ec14b5cfe41f26e5f41686a31eb6012758217ef # v3.0.2 with: version: 1.1.308 working-directory: 'python_files' @@ -174,11 +174,11 @@ jobs: # macOS runners are expensive, and we assume that Ubuntu is enough to cover the Unix case. os: [ubuntu-latest, windows-latest] # Run the tests on the oldest and most recent versions of Python. - python: ['3.9', '3.x', '3.13'] + python: ['3.10', '3.x', '3.13'] steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: path: ${{ env.special-working-directory-relative }} persist-credentials: false @@ -218,13 +218,13 @@ jobs: test-suite: [ts-unit, venv, single-workspace, multi-workspace, debugger, functional] steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: path: ${{ env.special-working-directory-relative }} persist-credentials: false - name: Checkout Python Environment Tools - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: 'microsoft/python-environment-tools' path: ${{ env.special-working-directory-relative }}/python-env-tools @@ -426,12 +426,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Checkout Python Environment Tools - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: 'microsoft/python-environment-tools' path: ${{ env.special-working-directory-relative }}/python-env-tools diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 168ef0a05b3d..5528fbbe9c0a 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -36,7 +36,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false diff --git a/.github/workflows/gen-issue-velocity.yml b/.github/workflows/gen-issue-velocity.yml index fdcb41cdaba9..41d79e4074d0 100644 --- a/.github/workflows/gen-issue-velocity.yml +++ b/.github/workflows/gen-issue-velocity.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false diff --git a/.github/workflows/info-needed-closer.yml b/.github/workflows/info-needed-closer.yml index 33f53fc20dca..46892a58e800 100644 --- a/.github/workflows/info-needed-closer.yml +++ b/.github/workflows/info-needed-closer.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Actions - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: 'microsoft/vscode-github-triage-actions' path: ./actions diff --git a/.github/workflows/issue-labels.yml b/.github/workflows/issue-labels.yml index a78ca03d5ee9..dcbd114086e2 100644 --- a/.github/workflows/issue-labels.yml +++ b/.github/workflows/issue-labels.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Actions - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: 'microsoft/vscode-github-triage-actions' ref: stable diff --git a/.github/workflows/lock-issues.yml b/.github/workflows/lock-issues.yml index cb6ed2e9d54e..544d04ee185e 100644 --- a/.github/workflows/lock-issues.yml +++ b/.github/workflows/lock-issues.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Lock Issues' - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1 + uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0 with: github-token: ${{ github.token }} issue-inactive-days: '30' diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 102258fb2d18..c8a6f2dd416e 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -10,7 +10,7 @@ on: permissions: {} env: - NODE_VERSION: 22.17.0 + NODE_VERSION: 22.21.1 PYTHON_VERSION: '3.10' # YML treats 3.10 the number as 3.1, so quotes around 3.10 MOCHA_REPORTER_JUNIT: true # Use the mocha-multi-reporters and send output to both console (spec) and JUnit (mocha-junit-reporter). Also enables a reporter which exits the process running the tests if it haven't already. ARTIFACT_NAME_VSIX: ms-python-insiders-vsix @@ -57,12 +57,12 @@ jobs: # vsix-target: alpine-arm64 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Checkout Python Environment Tools - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: 'microsoft/python-environment-tools' path: 'python-env-tools' @@ -87,7 +87,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false @@ -106,12 +106,12 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Checkout Python Environment Tools - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: 'microsoft/python-environment-tools' path: 'python-env-tools' @@ -138,7 +138,7 @@ jobs: python -m pip install --upgrade -r build/test-requirements.txt - name: Run Pyright - uses: jakebailey/pyright-action@6cabc0f01c4994be48fd45cd9dbacdd6e1ee6e5e # v2.3.3 + uses: jakebailey/pyright-action@8ec14b5cfe41f26e5f41686a31eb6012758217ef # v3.0.2 with: version: 1.1.308 working-directory: 'python_files' @@ -157,12 +157,12 @@ jobs: # macOS runners are expensive, and we assume that Ubuntu is enough to cover the Unix case. os: [ubuntu-latest, windows-latest] # Run the tests on the oldest and most recent versions of Python. - python: ['3.9', '3.x', '3.13'] # run for 3 pytest versions, most recent stable, oldest version supported and pre-release + python: ['3.10', '3.x', '3.13'] # run for 3 pytest versions, most recent stable, oldest version supported and pre-release pytest-version: ['pytest', 'pytest@pre-release', 'pytest==6.2.0'] steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: path: ${{ env.special-working-directory-relative }} persist-credentials: false @@ -215,13 +215,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: path: ${{ env.special-working-directory-relative }} persist-credentials: false - name: Checkout Python Environment Tools - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: 'microsoft/python-environment-tools' path: ${{ env.special-working-directory-relative }}/python-env-tools @@ -412,13 +412,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: path: ${{ env.special-working-directory-relative }} persist-credentials: false - name: Checkout Python Environment Tools - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: 'microsoft/python-environment-tools' path: ${{ env.special-working-directory-relative }}/python-env-tools @@ -452,12 +452,12 @@ jobs: steps: # Need the source to have the tests available. - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Checkout Python Environment Tools - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: 'microsoft/python-environment-tools' path: python-env-tools @@ -477,6 +477,8 @@ jobs: ### Coverage run coverage: name: Coverage + # TEMPORARILY DISABLED - hanging in CI, needs investigation + if: false # The value of runs-on is the OS of the current job (specified in the strategy matrix below) instead of being hardcoded. runs-on: ${{ matrix.os }} needs: [lint, check-types, python-tests, tests, native-tests] @@ -488,12 +490,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: persist-credentials: false - name: Checkout Python Environment Tools - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: 'microsoft/python-environment-tools' path: python-env-tools @@ -680,7 +682,7 @@ jobs: run: npm run test:cover:report - name: Upload HTML report - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v7 with: name: ${{ runner.os }}-coverage-report-html path: ./coverage diff --git a/.github/workflows/pr-issue-check.yml b/.github/workflows/pr-issue-check.yml index 25ac91bbd279..5587227d2848 100644 --- a/.github/workflows/pr-issue-check.yml +++ b/.github/workflows/pr-issue-check.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Ensure PR has an associated issue' - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const labels = context.payload.pull_request.labels.map(label => label.name); diff --git a/.github/workflows/pr-labels.yml b/.github/workflows/pr-labels.yml index 04f87a6d49ba..af24ac10772c 100644 --- a/.github/workflows/pr-labels.yml +++ b/.github/workflows/pr-labels.yml @@ -17,7 +17,7 @@ jobs: pull-requests: write steps: - name: 'PR impact specified' - uses: mheap/github-action-required-labels@8afbe8ae6ab7647d0c9f0cfa7c2f939650d22509 # v5.5.1 + uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5.5.2 with: mode: exactly count: 1 diff --git a/.github/workflows/test-plan-item-validator.yml b/.github/workflows/test-plan-item-validator.yml index 6e62058b04c6..57db4a3e18a7 100644 --- a/.github/workflows/test-plan-item-validator.yml +++ b/.github/workflows/test-plan-item-validator.yml @@ -12,7 +12,7 @@ jobs: if: contains(github.event.issue.labels.*.name, 'testplan-item') || contains(github.event.issue.labels.*.name, 'invalid-testplan-item') steps: - name: Checkout Actions - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: 'microsoft/vscode-github-triage-actions' path: ./actions diff --git a/.github/workflows/triage-info-needed.yml b/.github/workflows/triage-info-needed.yml index f61d17c033d7..c7a37ba0c78d 100644 --- a/.github/workflows/triage-info-needed.yml +++ b/.github/workflows/triage-info-needed.yml @@ -15,7 +15,7 @@ jobs: issues: write steps: - name: Checkout Actions - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: 'microsoft/vscode-github-triage-actions' ref: stable @@ -39,7 +39,7 @@ jobs: issues: write steps: - name: Checkout Actions - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: 'microsoft/vscode-github-triage-actions' ref: stable diff --git a/.gitignore b/.gitignore index 1b47f15705bb..2fa056f84fa6 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,5 @@ python_files/tests/*/.data/.coverage* python_files/tests/*/.data/*/.coverage* src/testTestingRootWkspc/coverageWorkspace/.coverage +# ignore ai artifacts generated and placed in this folder +ai-artifacts/* diff --git a/.nvmrc b/.nvmrc index 67e145bf0f9d..c6a66a6e6a68 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.18.0 +v22.21.1 diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 0e33420c11db..c5a054ed43cf 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -39,6 +39,24 @@ "problemMatcher": ["$python"], "label": "npm: check-python", "detail": "npm run check-python:ruff && npm run check-python:pyright" + }, + { + "label": "npm: check-python (venv)", + "type": "shell", + "command": "bash", + "args": ["-lc", "source .venv/bin/activate && npm run check-python"], + "problemMatcher": [], + "detail": "Activates the repo .venv first (avoids pyenv/shim Python) then runs: npm run check-python", + "windows": { + "command": "pwsh", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + ".\\.venv\\Scripts\\Activate.ps1; npm run check-python" + ] + } } ] } diff --git a/build/azure-pipeline.pre-release.yml b/build/azure-pipeline.pre-release.yml index e7159618d3ae..c300039f4ef4 100644 --- a/build/azure-pipeline.pre-release.yml +++ b/build/azure-pipeline.pre-release.yml @@ -71,7 +71,7 @@ extends: - task: UsePythonVersion@0 inputs: - versionSpec: '3.9' + versionSpec: '3.10' addToPath: true architecture: 'x64' displayName: Select Python version diff --git a/build/azure-pipeline.stable.yml b/build/azure-pipeline.stable.yml index 71399281efd9..024417da0e00 100644 --- a/build/azure-pipeline.stable.yml +++ b/build/azure-pipeline.stable.yml @@ -65,7 +65,7 @@ extends: - task: UsePythonVersion@0 inputs: - versionSpec: '3.9' + versionSpec: '3.10' addToPath: true architecture: 'x64' displayName: Select Python version @@ -128,7 +128,7 @@ extends: project: 'Monaco' definition: 593 buildVersionToDownload: 'latestFromBranch' - branchName: 'refs/heads/release/2025.14' + branchName: 'refs/heads/release/2026.4' targetPath: '$(Build.SourcesDirectory)/python-env-tools/bin' artifactName: 'bin-$(buildTarget)' itemPattern: | diff --git a/build/azure-pipelines/pipeline.yml b/build/azure-pipelines/pipeline.yml index 46302aa6ff90..0796e38ca598 100644 --- a/build/azure-pipelines/pipeline.yml +++ b/build/azure-pipelines/pipeline.yml @@ -37,13 +37,13 @@ extends: testPlatforms: - name: Linux nodeVersions: - - 22.17.0 + - 22.21.1 - name: MacOS nodeVersions: - - 22.17.0 + - 22.21.1 - name: Windows nodeVersions: - - 22.17.0 + - 22.21.1 testSteps: - template: /build/azure-pipelines/templates/test-steps.yml@self parameters: diff --git a/build/ci/conda_env_1.yml b/build/ci/conda_env_1.yml index 4f9ceefd27fb..e3230ad0096e 100644 --- a/build/ci/conda_env_1.yml +++ b/build/ci/conda_env_1.yml @@ -1,4 +1,4 @@ name: conda_env_1 dependencies: - - python=3.9 + - python=3.10 - pip diff --git a/build/ci/conda_env_2.yml b/build/ci/conda_env_2.yml index af9d7a46ba3e..38f551da2580 100644 --- a/build/ci/conda_env_2.yml +++ b/build/ci/conda_env_2.yml @@ -1,4 +1,4 @@ name: conda_env_2 dependencies: - - python=3.9 + - python=3.10 - pip diff --git a/build/test-requirements.txt b/build/test-requirements.txt index 6d64ff72ac7f..ff9afdfc8a2e 100644 --- a/build/test-requirements.txt +++ b/build/test-requirements.txt @@ -12,8 +12,8 @@ flask fastapi uvicorn django -testresources testscenarios +testtools # Integrated TensorBoard tests tensorboard diff --git a/package-lock.json b/package-lock.json index b3dd75602dc8..82053df77576 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "python", - "version": "2025.19.0-dev", + "version": "2026.5.0-dev", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "python", - "version": "2025.19.0-dev", + "version": "2026.5.0-dev", "license": "MIT", "dependencies": { "@iarna/toml": "^3.0.0", @@ -17,8 +17,8 @@ "iconv-lite": "^0.6.3", "inversify": "^6.0.2", "jsonc-parser": "^3.0.0", - "lodash": "^4.17.21", - "minimatch": "^5.0.1", + "lodash": "^4.18.1", + "minimatch": "^5.1.8", "named-js-regexp": "^1.3.3", "node-stream-zip": "^1.6.0", "reflect-metadata": "^0.2.2", @@ -50,7 +50,7 @@ "@types/glob": "^7.2.0", "@types/lodash": "^4.14.104", "@types/mocha": "^9.1.0", - "@types/node": "^22.5.0", + "@types/node": "^22.19.1", "@types/semver": "^5.5.0", "@types/shortid": "^0.0.29", "@types/sinon": "^17.0.3", @@ -103,8 +103,8 @@ "tsconfig-paths-webpack-plugin": "^3.2.0", "typemoq": "^2.1.0", "typescript": "~5.2", - "uuid": "^8.3.2", - "webpack": "^5.76.0", + "uuid": "^14.0.0", + "webpack": "^5.105.0", "webpack-bundle-analyzer": "^4.5.0", "webpack-cli": "^4.9.2", "webpack-fix-default-import-plugin": "^1.0.3", @@ -285,6 +285,14 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, + "node_modules/@azure/core-rest-pipeline/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@azure/core-tracing": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.0.1.tgz", @@ -434,6 +442,15 @@ "node": ">=0.8.0" } }, + "node_modules/@azure/msal-node/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@azure/opentelemetry-instrumentation-azure-sdk": { "version": "1.0.0-beta.5", "resolved": "https://registry.npmjs.org/@azure/opentelemetry-instrumentation-azure-sdk/-/opentelemetry-instrumentation-azure-sdk-1.0.0-beta.5.tgz", @@ -945,9 +962,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -1043,9 +1060,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -1265,9 +1282,9 @@ } }, "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "dev": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -1780,10 +1797,30 @@ "@types/node": "*" } }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, "node_modules/@types/fs-extra": { @@ -1894,12 +1931,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "22.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.0.tgz", - "integrity": "sha512-DkFrJOe+rfdHTqqMg0bSNlGlQ85hSoh2TPzZyhHsXnMtligRWpxUySiyw8FY14ITt24HVCiQPWxS3KO/QlGmWg==", + "version": "22.19.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", + "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~6.19.2" + "undici-types": "~6.21.0" } }, "node_modules/@types/semver": { @@ -2180,11 +2218,10 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -2503,10 +2540,11 @@ } }, "node_modules/@vscode/vsce/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -2515,148 +2553,148 @@ } }, "node_modules/@webassemblyjs/ast": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", - "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "dev": true, "dependencies": { - "@webassemblyjs/helper-numbers": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6" + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", - "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", "dev": true }, "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", - "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", "dev": true }, "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", - "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", "dev": true }, "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", - "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "dev": true, "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.11.6", - "@webassemblyjs/helper-api-error": "1.11.6", + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", - "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", "dev": true }, "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", - "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/wasm-gen": "1.12.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" } }, "node_modules/@webassemblyjs/ieee754": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", - "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "dev": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "node_modules/@webassemblyjs/leb128": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", - "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "dev": true, "dependencies": { "@xtuc/long": "4.2.2" } }, "node_modules/@webassemblyjs/utf8": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", - "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", "dev": true }, "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", - "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/helper-wasm-section": "1.12.1", - "@webassemblyjs/wasm-gen": "1.12.1", - "@webassemblyjs/wasm-opt": "1.12.1", - "@webassemblyjs/wasm-parser": "1.12.1", - "@webassemblyjs/wast-printer": "1.12.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" } }, "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", - "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/ieee754": "1.11.6", - "@webassemblyjs/leb128": "1.11.6", - "@webassemblyjs/utf8": "1.11.6" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" } }, "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", - "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/wasm-gen": "1.12.1", - "@webassemblyjs/wasm-parser": "1.12.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" } }, "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", - "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-api-error": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/ieee754": "1.11.6", - "@webassemblyjs/leb128": "1.11.6", - "@webassemblyjs/utf8": "1.11.6" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" } }, "node_modules/@webassemblyjs/wast-printer": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", - "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, @@ -2709,10 +2747,9 @@ "dev": true }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", - "license": "MIT", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "bin": { "acorn": "bin/acorn" }, @@ -2728,13 +2765,16 @@ "acorn": "^8" } }, - "node_modules/acorn-import-attributes": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "dev": true, + "engines": { + "node": ">=10.13.0" + }, "peerDependencies": { - "acorn": "^8" + "acorn": "^8.14.0" } }, "node_modules/acorn-jsx": { @@ -2797,10 +2837,11 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2812,6 +2853,46 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, "node_modules/ajv-keywords": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", @@ -3163,15 +3244,15 @@ } }, "node_modules/asn1.js": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", - "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", + "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", "dev": true, + "license": "MIT", "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0", - "safer-buffer": "^2.1.0" + "minimalistic-assert": "^1.0.0" } }, "node_modules/assert": { @@ -3419,6 +3500,15 @@ } ] }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/bent": { "version": "7.3.12", "resolved": "https://registry.npmjs.org/bent/-/bent-7.3.12.tgz", @@ -3472,10 +3562,11 @@ } }, "node_modules/bn.js": { - "version": "4.11.8", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", - "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==", - "dev": true + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "dev": true, + "license": "MIT" }, "node_modules/boolbase": { "version": "1.0.0", @@ -3484,10 +3575,9 @@ "dev": true }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "license": "MIT", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3555,60 +3645,75 @@ } }, "node_modules/browserify-rsa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.0.tgz", - "integrity": "sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.1.tgz", + "integrity": "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==", "dev": true, + "license": "MIT", "dependencies": { - "bn.js": "^5.0.0", - "randombytes": "^2.0.1" + "bn.js": "^5.2.1", + "randombytes": "^2.1.0", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" } }, "node_modules/browserify-rsa/node_modules/bn.js": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", + "dev": true, + "license": "MIT" + }, + "node_modules/browserify-rsa/node_modules/safe-buffer": { "version": "5.2.1", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz", - "integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==", - "dev": true + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" }, "node_modules/browserify-sign": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.2.tgz", - "integrity": "sha512-1rudGyeYY42Dk6texmv7c4VcQ0EsvVbLwZkA+AQB7SxvXxmcD93jcHie8bzecJ+ChDlmAm2Qyu0+Ccg5uhZXCg==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.5.tgz", + "integrity": "sha512-C2AUdAJg6rlM2W5QMp2Q4KGQMVBwR1lIimTsUnutJ8bMpW5B52pGpR2gEnNBNwijumDo5FojQ0L9JrXA8m4YEw==", "dev": true, + "license": "ISC", "dependencies": { - "bn.js": "^5.2.1", - "browserify-rsa": "^4.1.0", + "bn.js": "^5.2.2", + "browserify-rsa": "^4.1.1", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", - "elliptic": "^6.5.4", + "elliptic": "^6.6.1", "inherits": "^2.0.4", - "parse-asn1": "^5.1.6", - "readable-stream": "^3.6.2", + "parse-asn1": "^5.1.9", + "readable-stream": "^2.3.8", "safe-buffer": "^5.2.1" }, "engines": { - "node": ">= 4" + "node": ">= 0.10" } }, "node_modules/browserify-sign/node_modules/bn.js": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz", - "integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==", - "dev": true - }, - "node_modules/browserify-sign/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } + "license": "MIT" }, "node_modules/browserify-sign/node_modules/safe-buffer": { "version": "5.2.1", @@ -3640,9 +3745,9 @@ } }, "node_modules/browserslist": { - "version": "4.23.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", - "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -3659,10 +3764,11 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001646", - "electron-to-chromium": "^1.5.4", - "node-releases": "^2.0.18", - "update-browserslist-db": "^1.1.0" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -3879,9 +3985,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001655", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz", - "integrity": "sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==", + "version": "1.0.30001768", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001768.tgz", + "integrity": "sha512-qY3aDRZC5nWPgHUgIB84WL+nySuo19wk0VJpp/XI9T34lrvkyhRvNVOFJOp2kxClQhiFBu+TaUSudf6oa3vkSA==", "dev": true, "funding": [ { @@ -4258,15 +4364,41 @@ } }, "node_modules/cipher-base": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", - "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", + "integrity": "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==", "dev": true, + "license": "MIT", "dependencies": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.2" + }, + "engines": { + "node": ">= 0.10" } }, + "node_modules/cipher-base/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/circular-json": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/circular-json/-/circular-json-0.3.3.tgz", @@ -4581,13 +4713,14 @@ "dev": true }, "node_modules/create-ecdh": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz", - "integrity": "sha512-GbEHQPMOswGpKXM9kCWVrremUcBmjteUaQ01T9rkKCPDXfUHX0IoP9LpHYo2NPFampa4e+/pFDc3jQdxrxQLaw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz", + "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==", "dev": true, + "license": "MIT", "dependencies": { "bn.js": "^4.1.0", - "elliptic": "^6.0.0" + "elliptic": "^6.5.3" } }, "node_modules/create-hash": { @@ -4734,25 +4867,30 @@ } }, "node_modules/crypto-browserify": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz", - "integrity": "sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==", + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.1.tgz", + "integrity": "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==", "dev": true, + "license": "MIT", "dependencies": { - "browserify-cipher": "^1.0.0", - "browserify-sign": "^4.0.0", - "create-ecdh": "^4.0.0", - "create-hash": "^1.1.0", - "create-hmac": "^1.1.0", - "diffie-hellman": "^5.0.0", - "inherits": "^2.0.1", - "pbkdf2": "^3.0.3", - "public-encrypt": "^4.0.0", - "randombytes": "^2.0.0", - "randomfill": "^1.0.3" + "browserify-cipher": "^1.0.1", + "browserify-sign": "^4.2.3", + "create-ecdh": "^4.0.4", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "diffie-hellman": "^5.0.3", + "hash-base": "~3.0.4", + "inherits": "^2.0.4", + "pbkdf2": "^3.1.2", + "public-encrypt": "^4.0.3", + "randombytes": "^2.1.0", + "randomfill": "^1.0.4" }, "engines": { - "node": "*" + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/damerau-levenshtein": { @@ -5207,10 +5345,11 @@ } }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } @@ -5385,9 +5524,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.13", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.13.tgz", - "integrity": "sha512-lbBcvtIJ4J6sS4tb5TLp1b4LyfCdMkwStzXPyAgVgTRAsep4bvrAGaBOP7ZJtQMNJpSQ9SqG4brWOroNaQtm7Q==", + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", "dev": true }, "node_modules/elliptic": { @@ -5406,12 +5545,6 @@ "minimalistic-crypto-utils": "^1.0.1" } }, - "node_modules/elliptic/node_modules/bn.js": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", - "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", - "dev": true - }, "node_modules/emitter-listener": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/emitter-listener/-/emitter-listener-1.1.2.tgz", @@ -5445,13 +5578,13 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.17.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", - "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", "dev": true, "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.0" }, "engines": { "node": ">=10.13.0" @@ -5556,9 +5689,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", - "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true }, "node_modules/es-object-atoms": { @@ -5804,10 +5937,11 @@ } }, "node_modules/eslint-plugin-import/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -5870,10 +6004,11 @@ "dev": true }, "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -5941,10 +6076,11 @@ } }, "node_modules/eslint-plugin-react/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -6224,10 +6360,11 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -6661,6 +6798,22 @@ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, "node_modules/fastest-levenshtein": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.12.tgz", @@ -6855,9 +7008,9 @@ } }, "node_modules/flatted": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.4.tgz", - "integrity": "sha512-8/sOawo8tJ4QOBX8YlQBMxL8+RLZfxMQOif9o0KUKTNTjMYElWPE0r/m5VNFxTRd0NSw8qSy8dajrwX4RYI1Hw==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true }, "node_modules/flush-write-stream": { @@ -7326,9 +7479,10 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -9007,6 +9161,15 @@ "node": ">=8" } }, + "node_modules/istanbul-lib-processinfo/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/istanbul-lib-report": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", @@ -9263,23 +9426,23 @@ } }, "node_modules/jsonwebtoken/node_modules/jwa": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", - "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", "dev": true, "dependencies": { - "buffer-equal-constant-time": "1.0.1", + "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "node_modules/jsonwebtoken/node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", "dev": true, "dependencies": { - "jwa": "^1.4.1", + "jwa": "^1.4.2", "safe-buffer": "^5.0.1" } }, @@ -9315,23 +9478,23 @@ "dev": true }, "node_modules/jwa": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", - "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "dev": true, "dependencies": { - "buffer-equal-constant-time": "1.0.1", + "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "node_modules/jws": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", - "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "dev": true, "dependencies": { - "jwa": "^2.0.0", + "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, @@ -9482,12 +9645,16 @@ } }, "node_modules/loader-runner": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.2.0.tgz", - "integrity": "sha512-92+huvxMvYlMzMt0iIOukcwYBFpkYJdpl2xsZ7LrlayO7E8SOv+JJUEK17B/dJIHAOLMfh2dZZ/Y18WgmGtYNw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "dev": true, "engines": { "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/loader-utils": { @@ -9517,9 +9684,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" }, "node_modules/lodash.flattendeep": { "version": "4.4.0", @@ -9910,9 +10077,9 @@ "dev": true }, "node_modules/minimatch": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.0.tgz", - "integrity": "sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==", + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.8.tgz", + "integrity": "sha512-7RN35vit8DeBclkofOVmBY0eDAZZQd1HzmukRdSyz95CRh8FT54eqnbj0krQr3mrHR6sfRyYkyhwBWjoV5uqlQ==", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -9921,10 +10088,9 @@ } }, "node_modules/minimatch/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dependencies": { "balanced-match": "^1.0.0" } @@ -9965,29 +10131,30 @@ "optional": true }, "node_modules/mocha": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.1.0.tgz", - "integrity": "sha512-8uJR5RTC2NgpY3GrYcgpZrsEd9zKbPDpob1RezyR2upGHRQtHWofmzTMzTMSV6dru3tj5Ukt0+Vnq1qhFEEwAg==", + "version": "11.7.5", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz", + "integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==", "dev": true, "license": "MIT", "dependencies": { - "ansi-colors": "^4.1.3", "browser-stdout": "^1.3.1", - "chokidar": "^3.5.3", + "chokidar": "^4.0.1", "debug": "^4.3.5", - "diff": "^5.2.0", + "diff": "^7.0.0", "escape-string-regexp": "^4.0.0", "find-up": "^5.0.0", "glob": "^10.4.5", "he": "^1.2.0", + "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "log-symbols": "^4.1.0", - "minimatch": "^5.1.6", + "minimatch": "^9.0.5", "ms": "^2.1.3", + "picocolors": "^1.1.1", "serialize-javascript": "^6.0.2", "strip-json-comments": "^3.1.1", "supports-color": "^8.1.1", - "workerpool": "^6.5.1", + "workerpool": "^9.2.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1", "yargs-unparser": "^2.0.0" @@ -10049,16 +10216,6 @@ } } }, - "node_modules/mocha/node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/mocha/node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -10066,15 +10223,30 @@ "dev": true }, "node_modules/mocha/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, + "node_modules/mocha/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/mocha/node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -10124,9 +10296,9 @@ } }, "node_modules/mocha/node_modules/diff": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", - "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -10179,9 +10351,10 @@ } }, "node_modules/mocha/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -10199,22 +10372,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/mocha/node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/mocha/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -10252,16 +10409,19 @@ } }, "node_modules/mocha/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { - "node": ">=10" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/mocha/node_modules/ms": { @@ -10310,6 +10470,20 @@ "node": ">=8" } }, + "node_modules/mocha/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/mocha/node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -10758,9 +10932,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", - "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true }, "node_modules/node-stream-zip": { @@ -10907,10 +11081,13 @@ } }, "node_modules/object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "dev": true, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -11291,18 +11468,43 @@ } }, "node_modules/parse-asn1": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.6.tgz", - "integrity": "sha512-RnZRo1EPU6JBnra2vGHj0yhp6ebyjBZpmUCLHWiFhxlzvBCCpAuZ7elsBp1PVAbQN0/04VD/19rfzlBSwLstMw==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.9.tgz", + "integrity": "sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg==", "dev": true, + "license": "ISC", "dependencies": { - "asn1.js": "^5.2.0", - "browserify-aes": "^1.0.0", - "evp_bytestokey": "^1.0.0", - "pbkdf2": "^3.0.3", - "safe-buffer": "^5.1.1" + "asn1.js": "^4.10.1", + "browserify-aes": "^1.2.0", + "evp_bytestokey": "^1.0.3", + "pbkdf2": "^3.1.5", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" } }, + "node_modules/parse-asn1/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/parse-filepath": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz", @@ -11473,55 +11675,21 @@ } }, "node_modules/pbkdf2": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.3.tgz", - "integrity": "sha512-wfRLBZ0feWRhCIkoMB6ete7czJcnNnqRpcoWQBLqatqXXmelSRqfdDK4F3u9T2s2cXas/hQJcryI/4lAL+XTlA==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz", + "integrity": "sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==", "dev": true, "license": "MIT", "dependencies": { - "create-hash": "~1.1.3", + "create-hash": "^1.2.0", "create-hmac": "^1.1.7", - "ripemd160": "=2.0.1", + "ripemd160": "^2.0.3", "safe-buffer": "^5.2.1", - "sha.js": "^2.4.11", - "to-buffer": "^1.2.0" + "sha.js": "^2.4.12", + "to-buffer": "^1.2.1" }, "engines": { - "node": ">=0.12" - } - }, - "node_modules/pbkdf2/node_modules/create-hash": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.1.3.tgz", - "integrity": "sha512-snRpch/kwQhcdlnZKYanNF1m0RDlrCdSKQaH87w1FCFPVPNCQ/Il9QJKAX2jVBZddRdaHBMC+zXa9Gw9tmkNUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cipher-base": "^1.0.1", - "inherits": "^2.0.1", - "ripemd160": "^2.0.0", - "sha.js": "^2.4.0" - } - }, - "node_modules/pbkdf2/node_modules/hash-base": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-2.0.2.tgz", - "integrity": "sha512-0TROgQ1/SxE6KmxWSvXHvRj90/Xo1JvZShofnYF+f6ZsGtR4eES7WfrQzPalmyagfKZCXpVnitiRebZulWsbiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1" - } - }, - "node_modules/pbkdf2/node_modules/ripemd160": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.1.tgz", - "integrity": "sha512-J7f4wutN8mdbV08MJnXibYpCOPHR+yzy+iQ/AsjMv2j8cLavQ8VGagDFUwwTAdF8FmRKVeNpbTTEwNHCW1g94w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hash-base": "^2.0.0", - "inherits": "^2.0.1" + "node": ">= 0.10" } }, "node_modules/pbkdf2/node_modules/safe-buffer": { @@ -11559,9 +11727,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "engines": { "node": ">=8.6" @@ -11802,12 +11970,12 @@ } }, "node_modules/qs": { - "version": "6.12.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.1.tgz", - "integrity": "sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "dev": true, "dependencies": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -11940,10 +12108,11 @@ } }, "node_modules/readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, + "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -12083,6 +12252,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-in-the-middle": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.2.0.tgz", @@ -12225,15 +12403,56 @@ } }, "node_modules/ripemd160": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", - "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", + "integrity": "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==", "dev": true, + "license": "MIT", "dependencies": { - "hash-base": "^3.0.0", - "inherits": "^2.0.1" + "hash-base": "^3.1.2", + "inherits": "^2.0.4" + }, + "engines": { + "node": ">= 0.8" } }, + "node_modules/ripemd160/node_modules/hash-base": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz", + "integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ripemd160/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -12531,15 +12750,69 @@ } }, "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "dev": true, "dependencies": { - "call-bind": "^1.0.7", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -12649,10 +12922,11 @@ } }, "node_modules/sinon/node_modules/diff": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", - "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } @@ -13127,12 +13401,16 @@ } }, "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "dev": true, "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/tar-fs": { @@ -13237,13 +13515,13 @@ } }, "node_modules/terser": { - "version": "5.31.6", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.6.tgz", - "integrity": "sha512-PQ4DAriWzKj+qgehQ7LK5bQqCFNMmlhjR2PFFLuqGCpuCAauxemVBWwWOxo3UIwWQx8+Pr61Df++r76wDmkQBg==", + "version": "5.46.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "dev": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", + "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -13255,16 +13533,16 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.10", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", - "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "version": "5.3.17", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.17.tgz", + "integrity": "sha512-YR7PtUp6GMU91BgSJmlaX/rS2lGDbAF7D+Wtq7hRO+MiljNmodYvqslzCFiYVAgW+Qoaaia/QUIP4lGXufjdZw==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.20", + "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", - "schema-utils": "^3.1.1", - "serialize-javascript": "^6.0.1", - "terser": "^5.26.0" + "schema-utils": "^4.3.0", + "terser": "^5.31.1" }, "engines": { "node": ">= 10.13.0" @@ -13288,6 +13566,60 @@ } } }, + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -13303,10 +13635,11 @@ } }, "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -13407,9 +13740,9 @@ "dev": true }, "node_modules/to-buffer": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.1.tgz", - "integrity": "sha512-tB82LpAIWjhLYbqjx3X4zEeHN6M8CiuOEy2JY8SEQVdYRe3CCHOFaqrBW1doLDrfpWhplcW7BL+bO3/6S3pcDQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", "dev": true, "license": "MIT", "dependencies": { @@ -14033,10 +14366,11 @@ } }, "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" }, "node_modules/unicode": { "version": "14.0.0", @@ -14057,9 +14391,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", - "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -14076,8 +14410,8 @@ } ], "dependencies": { - "escalade": "^3.1.2", - "picocolors": "^1.0.1" + "escalade": "^3.2.0", + "picocolors": "^1.1.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -14162,11 +14496,16 @@ "dev": true }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { @@ -14389,20 +14728,20 @@ } }, "node_modules/vscode-languageclient/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/vscode-languageclient/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -14437,9 +14776,9 @@ } }, "node_modules/watchpack": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", - "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "dev": true, "dependencies": { "glob-to-regexp": "^0.4.1", @@ -14450,34 +14789,36 @@ } }, "node_modules/webpack": { - "version": "5.94.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", - "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", - "dev": true, - "dependencies": { - "@types/estree": "^1.0.5", - "@webassemblyjs/ast": "^1.12.1", - "@webassemblyjs/wasm-edit": "^1.12.1", - "@webassemblyjs/wasm-parser": "^1.12.1", - "acorn": "^8.7.1", - "acorn-import-attributes": "^1.9.5", - "browserslist": "^4.21.10", + "version": "5.105.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", + "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", + "dev": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.19.0", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", + "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^3.2.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.10", - "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.16", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" @@ -14708,14 +15049,68 @@ } }, "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", "dev": true, "engines": { "node": ">=10.13.0" } }, + "node_modules/webpack/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -14815,9 +15210,9 @@ } }, "node_modules/workerpool": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", - "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", + "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", "dev": true, "license": "Apache-2.0" }, @@ -15349,6 +15744,11 @@ "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" } } }, @@ -15480,6 +15880,12 @@ "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.12.0.tgz", "integrity": "sha512-IDDXmzfdwmDkv4SSmMEyAniJf6fDu3FJ7ncOjlxkDuT85uSnLEhZi3fGZpoR7T4XZpOMx9teM9GXBgrfJgyeBw==", "dev": true + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true } } }, @@ -15853,9 +16259,9 @@ } }, "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "requires": { "brace-expansion": "^1.1.7" @@ -15917,9 +16323,9 @@ } }, "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "requires": { "brace-expansion": "^1.1.7" @@ -16066,9 +16472,9 @@ "dev": true }, "@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "dev": true, "requires": { "@jridgewell/gen-mapping": "^0.3.5", @@ -16514,10 +16920,30 @@ "@types/node": "*" } }, + "@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "requires": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "requires": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, "@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, "@types/fs-extra": { @@ -16613,12 +17039,12 @@ "dev": true }, "@types/node": { - "version": "22.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.0.tgz", - "integrity": "sha512-DkFrJOe+rfdHTqqMg0bSNlGlQ85hSoh2TPzZyhHsXnMtligRWpxUySiyw8FY14ITt24HVCiQPWxS3KO/QlGmWg==", + "version": "22.19.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", + "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", "dev": true, "requires": { - "undici-types": "~6.19.2" + "undici-types": "~6.21.0" } }, "@types/semver": { @@ -16808,9 +17234,9 @@ }, "dependencies": { "brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "requires": { "balanced-match": "^1.0.0" @@ -16947,9 +17373,9 @@ } }, "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "requires": { "brace-expansion": "^1.1.7" @@ -17038,148 +17464,148 @@ "optional": true }, "@webassemblyjs/ast": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", - "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "dev": true, "requires": { - "@webassemblyjs/helper-numbers": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6" + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "@webassemblyjs/floating-point-hex-parser": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", - "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", "dev": true }, "@webassemblyjs/helper-api-error": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", - "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", "dev": true }, "@webassemblyjs/helper-buffer": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", - "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", "dev": true }, "@webassemblyjs/helper-numbers": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", - "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "dev": true, "requires": { - "@webassemblyjs/floating-point-hex-parser": "1.11.6", - "@webassemblyjs/helper-api-error": "1.11.6", + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "@webassemblyjs/helper-wasm-bytecode": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", - "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", "dev": true }, "@webassemblyjs/helper-wasm-section": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", - "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "dev": true, "requires": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/wasm-gen": "1.12.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" } }, "@webassemblyjs/ieee754": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", - "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "dev": true, "requires": { "@xtuc/ieee754": "^1.2.0" } }, "@webassemblyjs/leb128": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", - "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "dev": true, "requires": { "@xtuc/long": "4.2.2" } }, "@webassemblyjs/utf8": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", - "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", "dev": true }, "@webassemblyjs/wasm-edit": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", - "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "dev": true, "requires": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/helper-wasm-section": "1.12.1", - "@webassemblyjs/wasm-gen": "1.12.1", - "@webassemblyjs/wasm-opt": "1.12.1", - "@webassemblyjs/wasm-parser": "1.12.1", - "@webassemblyjs/wast-printer": "1.12.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" } }, "@webassemblyjs/wasm-gen": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", - "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "dev": true, "requires": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/ieee754": "1.11.6", - "@webassemblyjs/leb128": "1.11.6", - "@webassemblyjs/utf8": "1.11.6" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" } }, "@webassemblyjs/wasm-opt": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", - "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "dev": true, "requires": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/wasm-gen": "1.12.1", - "@webassemblyjs/wasm-parser": "1.12.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" } }, "@webassemblyjs/wasm-parser": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", - "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "dev": true, "requires": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-api-error": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/ieee754": "1.11.6", - "@webassemblyjs/leb128": "1.11.6", - "@webassemblyjs/utf8": "1.11.6" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" } }, "@webassemblyjs/wast-printer": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", - "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "dev": true, "requires": { - "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, @@ -17219,9 +17645,9 @@ "dev": true }, "acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==" + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==" }, "acorn-import-assertions": { "version": "1.9.0", @@ -17229,10 +17655,10 @@ "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", "requires": {} }, - "acorn-import-attributes": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "dev": true, "requires": {} }, @@ -17278,9 +17704,9 @@ } }, "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "requires": { "fast-deep-equal": "^3.1.1", @@ -17289,6 +17715,35 @@ "uri-js": "^4.2.2" } }, + "ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "requires": { + "ajv": "^8.0.0" + }, + "dependencies": { + "ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + } + } + }, "ajv-keywords": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", @@ -17534,15 +17989,14 @@ } }, "asn1.js": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", - "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", + "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", "dev": true, "requires": { "bn.js": "^4.0.0", "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0", - "safer-buffer": "^2.1.0" + "minimalistic-assert": "^1.0.0" } }, "assert": { @@ -17746,6 +18200,12 @@ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "dev": true }, + "baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true + }, "bent": { "version": "7.3.12", "resolved": "https://registry.npmjs.org/bent/-/bent-7.3.12.tgz", @@ -17788,9 +18248,9 @@ } }, "bn.js": { - "version": "4.11.8", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", - "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true }, "boolbase": { @@ -17800,9 +18260,9 @@ "dev": true }, "brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -17867,57 +18327,53 @@ } }, "browserify-rsa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.0.tgz", - "integrity": "sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.1.tgz", + "integrity": "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==", "dev": true, "requires": { - "bn.js": "^5.0.0", - "randombytes": "^2.0.1" + "bn.js": "^5.2.1", + "randombytes": "^2.1.0", + "safe-buffer": "^5.2.1" }, "dependencies": { "bn.js": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", + "dev": true + }, + "safe-buffer": { "version": "5.2.1", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz", - "integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "dev": true } } }, "browserify-sign": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.2.tgz", - "integrity": "sha512-1rudGyeYY42Dk6texmv7c4VcQ0EsvVbLwZkA+AQB7SxvXxmcD93jcHie8bzecJ+ChDlmAm2Qyu0+Ccg5uhZXCg==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.5.tgz", + "integrity": "sha512-C2AUdAJg6rlM2W5QMp2Q4KGQMVBwR1lIimTsUnutJ8bMpW5B52pGpR2gEnNBNwijumDo5FojQ0L9JrXA8m4YEw==", "dev": true, "requires": { - "bn.js": "^5.2.1", - "browserify-rsa": "^4.1.0", + "bn.js": "^5.2.2", + "browserify-rsa": "^4.1.1", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", - "elliptic": "^6.5.4", + "elliptic": "^6.6.1", "inherits": "^2.0.4", - "parse-asn1": "^5.1.6", - "readable-stream": "^3.6.2", + "parse-asn1": "^5.1.9", + "readable-stream": "^2.3.8", "safe-buffer": "^5.2.1" }, "dependencies": { "bn.js": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz", - "integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", "dev": true }, - "readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, "safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -17936,15 +18392,16 @@ } }, "browserslist": { - "version": "4.23.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", - "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "requires": { - "caniuse-lite": "^1.0.30001646", - "electron-to-chromium": "^1.5.4", - "node-releases": "^2.0.18", - "update-browserslist-db": "^1.1.0" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" } }, "buffer": { @@ -18100,9 +18557,9 @@ "dev": true }, "caniuse-lite": { - "version": "1.0.30001655", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz", - "integrity": "sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==", + "version": "1.0.30001768", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001768.tgz", + "integrity": "sha512-qY3aDRZC5nWPgHUgIB84WL+nySuo19wk0VJpp/XI9T34lrvkyhRvNVOFJOp2kxClQhiFBu+TaUSudf6oa3vkSA==", "dev": true }, "caseless": { @@ -18369,13 +18826,22 @@ } }, "cipher-base": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", - "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", + "integrity": "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==", "dev": true, "requires": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.2" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true + } } }, "circular-json": { @@ -18630,13 +19096,13 @@ "dev": true }, "create-ecdh": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz", - "integrity": "sha512-GbEHQPMOswGpKXM9kCWVrremUcBmjteUaQ01T9rkKCPDXfUHX0IoP9LpHYo2NPFampa4e+/pFDc3jQdxrxQLaw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz", + "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==", "dev": true, "requires": { "bn.js": "^4.1.0", - "elliptic": "^6.0.0" + "elliptic": "^6.5.3" } }, "create-hash": { @@ -18752,22 +19218,23 @@ "dev": true }, "crypto-browserify": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz", - "integrity": "sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==", + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.1.tgz", + "integrity": "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==", "dev": true, "requires": { - "browserify-cipher": "^1.0.0", - "browserify-sign": "^4.0.0", - "create-ecdh": "^4.0.0", - "create-hash": "^1.1.0", - "create-hmac": "^1.1.0", - "diffie-hellman": "^5.0.0", - "inherits": "^2.0.1", - "pbkdf2": "^3.0.3", - "public-encrypt": "^4.0.0", - "randombytes": "^2.0.0", - "randomfill": "^1.0.3" + "browserify-cipher": "^1.0.1", + "browserify-sign": "^4.2.3", + "create-ecdh": "^4.0.4", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "diffie-hellman": "^5.0.3", + "hash-base": "~3.0.4", + "inherits": "^2.0.4", + "pbkdf2": "^3.1.2", + "public-encrypt": "^4.0.3", + "randombytes": "^2.1.0", + "randomfill": "^1.0.4" } }, "damerau-levenshtein": { @@ -19109,9 +19576,9 @@ "requires": {} }, "diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true }, "diffie-hellman": { @@ -19254,9 +19721,9 @@ } }, "electron-to-chromium": { - "version": "1.5.13", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.13.tgz", - "integrity": "sha512-lbBcvtIJ4J6sS4tb5TLp1b4LyfCdMkwStzXPyAgVgTRAsep4bvrAGaBOP7ZJtQMNJpSQ9SqG4brWOroNaQtm7Q==", + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", "dev": true }, "elliptic": { @@ -19272,14 +19739,6 @@ "inherits": "^2.0.4", "minimalistic-assert": "^1.0.1", "minimalistic-crypto-utils": "^1.0.1" - }, - "dependencies": { - "bn.js": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", - "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", - "dev": true - } } }, "emitter-listener": { @@ -19312,13 +19771,13 @@ } }, "enhanced-resolve": { - "version": "5.17.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", - "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", "dev": true, "requires": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.0" } }, "entities": { @@ -19398,9 +19857,9 @@ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" }, "es-module-lexer": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", - "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true }, "es-object-atoms": { @@ -19657,9 +20116,9 @@ } }, "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "requires": { "brace-expansion": "^1.1.7" @@ -19807,9 +20266,9 @@ } }, "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "requires": { "brace-expansion": "^1.1.7" @@ -19860,9 +20319,9 @@ "dev": true }, "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "requires": { "brace-expansion": "^1.1.7" @@ -19905,9 +20364,9 @@ "dev": true }, "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "requires": { "brace-expansion": "^1.1.7" @@ -20207,6 +20666,12 @@ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, + "fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true + }, "fastest-levenshtein": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.12.tgz", @@ -20355,9 +20820,9 @@ } }, "flatted": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.4.tgz", - "integrity": "sha512-8/sOawo8tJ4QOBX8YlQBMxL8+RLZfxMQOif9o0KUKTNTjMYElWPE0r/m5VNFxTRd0NSw8qSy8dajrwX4RYI1Hw==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true }, "flush-write-stream": { @@ -20661,9 +21126,9 @@ }, "dependencies": { "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "requires": { "brace-expansion": "^1.1.7" } @@ -21919,6 +22384,12 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true } } }, @@ -22114,23 +22585,23 @@ }, "dependencies": { "jwa": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", - "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", "dev": true, "requires": { - "buffer-equal-constant-time": "1.0.1", + "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", "dev": true, "requires": { - "jwa": "^1.4.1", + "jwa": "^1.4.2", "safe-buffer": "^5.0.1" } } @@ -22165,23 +22636,23 @@ "dev": true }, "jwa": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", - "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "dev": true, "requires": { - "buffer-equal-constant-time": "1.0.1", + "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "jws": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", - "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "dev": true, "requires": { - "jwa": "^2.0.0", + "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, @@ -22308,9 +22779,9 @@ } }, "loader-runner": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.2.0.tgz", - "integrity": "sha512-92+huvxMvYlMzMt0iIOukcwYBFpkYJdpl2xsZ7LrlayO7E8SOv+JJUEK17B/dJIHAOLMfh2dZZ/Y18WgmGtYNw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "dev": true }, "loader-utils": { @@ -22334,9 +22805,9 @@ } }, "lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" }, "lodash.flattendeep": { "version": "4.4.0", @@ -22649,17 +23120,17 @@ "dev": true }, "minimatch": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.0.tgz", - "integrity": "sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==", + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.8.tgz", + "integrity": "sha512-7RN35vit8DeBclkofOVmBY0eDAZZQd1HzmukRdSyz95CRh8FT54eqnbj0krQr3mrHR6sfRyYkyhwBWjoV5uqlQ==", "requires": { "brace-expansion": "^2.0.1" }, "dependencies": { "brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "requires": { "balanced-match": "^1.0.0" } @@ -22695,39 +23166,34 @@ "optional": true }, "mocha": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.1.0.tgz", - "integrity": "sha512-8uJR5RTC2NgpY3GrYcgpZrsEd9zKbPDpob1RezyR2upGHRQtHWofmzTMzTMSV6dru3tj5Ukt0+Vnq1qhFEEwAg==", + "version": "11.7.5", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz", + "integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==", "dev": true, "requires": { - "ansi-colors": "^4.1.3", "browser-stdout": "^1.3.1", - "chokidar": "^3.5.3", + "chokidar": "^4.0.1", "debug": "^4.3.5", - "diff": "^5.2.0", + "diff": "^7.0.0", "escape-string-regexp": "^4.0.0", "find-up": "^5.0.0", "glob": "^10.4.5", "he": "^1.2.0", + "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "log-symbols": "^4.1.0", - "minimatch": "^5.1.6", + "minimatch": "^9.0.5", "ms": "^2.1.3", + "picocolors": "^1.1.1", "serialize-javascript": "^6.0.2", "strip-json-comments": "^3.1.1", "supports-color": "^8.1.1", - "workerpool": "^6.5.1", + "workerpool": "^9.2.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1", "yargs-unparser": "^2.0.0" }, "dependencies": { - "ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true - }, "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -22735,14 +23201,23 @@ "dev": true }, "brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "requires": { "balanced-match": "^1.0.0" } }, + "chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "requires": { + "readdirp": "^4.0.1" + } + }, "cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -22775,9 +23250,9 @@ } }, "diff": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", - "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", "dev": true }, "escape-string-regexp": { @@ -22807,9 +23282,9 @@ } }, "glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "requires": { "foreground-child": "^3.1.0", @@ -22818,17 +23293,6 @@ "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" - }, - "dependencies": { - "minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "requires": { - "brace-expansion": "^2.0.1" - } - } } }, "has-flag": { @@ -22856,12 +23320,12 @@ } }, "minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "requires": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" } }, "ms": { @@ -22894,6 +23358,12 @@ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true }, + "readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -23281,9 +23751,9 @@ } }, "node-releases": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", - "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true }, "node-stream-zip": { @@ -23396,9 +23866,9 @@ "dev": true }, "object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "dev": true }, "object-is": { @@ -23671,16 +24141,24 @@ } }, "parse-asn1": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.6.tgz", - "integrity": "sha512-RnZRo1EPU6JBnra2vGHj0yhp6ebyjBZpmUCLHWiFhxlzvBCCpAuZ7elsBp1PVAbQN0/04VD/19rfzlBSwLstMw==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.9.tgz", + "integrity": "sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg==", "dev": true, "requires": { - "asn1.js": "^5.2.0", - "browserify-aes": "^1.0.0", - "evp_bytestokey": "^1.0.0", - "pbkdf2": "^3.0.3", - "safe-buffer": "^5.1.1" + "asn1.js": "^4.10.1", + "browserify-aes": "^1.2.0", + "evp_bytestokey": "^1.0.3", + "pbkdf2": "^3.1.5", + "safe-buffer": "^5.2.1" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true + } } }, "parse-filepath": { @@ -23820,50 +24298,19 @@ "dev": true }, "pbkdf2": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.3.tgz", - "integrity": "sha512-wfRLBZ0feWRhCIkoMB6ete7czJcnNnqRpcoWQBLqatqXXmelSRqfdDK4F3u9T2s2cXas/hQJcryI/4lAL+XTlA==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz", + "integrity": "sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==", "dev": true, "requires": { - "create-hash": "~1.1.3", + "create-hash": "^1.2.0", "create-hmac": "^1.1.7", - "ripemd160": "=2.0.1", + "ripemd160": "^2.0.3", "safe-buffer": "^5.2.1", - "sha.js": "^2.4.11", - "to-buffer": "^1.2.0" + "sha.js": "^2.4.12", + "to-buffer": "^1.2.1" }, "dependencies": { - "create-hash": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.1.3.tgz", - "integrity": "sha512-snRpch/kwQhcdlnZKYanNF1m0RDlrCdSKQaH87w1FCFPVPNCQ/Il9QJKAX2jVBZddRdaHBMC+zXa9Gw9tmkNUA==", - "dev": true, - "requires": { - "cipher-base": "^1.0.1", - "inherits": "^2.0.1", - "ripemd160": "^2.0.0", - "sha.js": "^2.4.0" - } - }, - "hash-base": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-2.0.2.tgz", - "integrity": "sha512-0TROgQ1/SxE6KmxWSvXHvRj90/Xo1JvZShofnYF+f6ZsGtR4eES7WfrQzPalmyagfKZCXpVnitiRebZulWsbiw==", - "dev": true, - "requires": { - "inherits": "^2.0.1" - } - }, - "ripemd160": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.1.tgz", - "integrity": "sha512-J7f4wutN8mdbV08MJnXibYpCOPHR+yzy+iQ/AsjMv2j8cLavQ8VGagDFUwwTAdF8FmRKVeNpbTTEwNHCW1g94w==", - "dev": true, - "requires": { - "hash-base": "^2.0.0", - "inherits": "^2.0.1" - } - }, "safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -23885,9 +24332,9 @@ "dev": true }, "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true }, "pify": { @@ -24070,12 +24517,12 @@ "dev": true }, "qs": { - "version": "6.12.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.1.tgz", - "integrity": "sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "dev": true, "requires": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" } }, "query-string": { @@ -24170,9 +24617,9 @@ } }, "readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "requires": { "core-util-is": "~1.0.0", @@ -24284,6 +24731,12 @@ "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", "dev": true }, + "require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true + }, "require-in-the-middle": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.2.0.tgz", @@ -24387,13 +24840,33 @@ } }, "ripemd160": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", - "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", + "integrity": "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==", "dev": true, "requires": { - "hash-base": "^3.0.0", - "inherits": "^2.0.1" + "hash-base": "^3.1.2", + "inherits": "^2.0.4" + }, + "dependencies": { + "hash-base": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz", + "integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==", + "dev": true, + "requires": { + "inherits": "^2.0.4", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.1" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true + } } }, "run-parallel": { @@ -24607,15 +25080,51 @@ } }, "side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "dev": true, "requires": { - "call-bind": "^1.0.7", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + } + }, + "side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + } + }, + "side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + } + }, + "side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" } }, "signal-exit": { @@ -24677,9 +25186,9 @@ }, "dependencies": { "diff": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", - "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", "dev": true }, "has-flag": { @@ -25040,9 +25549,9 @@ } }, "tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "dev": true }, "tar-fs": { @@ -25139,28 +25648,68 @@ } }, "terser": { - "version": "5.31.6", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.6.tgz", - "integrity": "sha512-PQ4DAriWzKj+qgehQ7LK5bQqCFNMmlhjR2PFFLuqGCpuCAauxemVBWwWOxo3UIwWQx8+Pr61Df++r76wDmkQBg==", + "version": "5.46.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "dev": true, "requires": { "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", + "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" } }, "terser-webpack-plugin": { - "version": "5.3.10", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", - "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "version": "5.3.17", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.17.tgz", + "integrity": "sha512-YR7PtUp6GMU91BgSJmlaX/rS2lGDbAF7D+Wtq7hRO+MiljNmodYvqslzCFiYVAgW+Qoaaia/QUIP4lGXufjdZw==", "dev": true, "requires": { - "@jridgewell/trace-mapping": "^0.3.20", + "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", - "schema-utils": "^3.1.1", - "serialize-javascript": "^6.0.1", - "terser": "^5.26.0" + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "dependencies": { + "ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + } + }, + "ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.3" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + } + } } }, "test-exclude": { @@ -25175,9 +25724,9 @@ }, "dependencies": { "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "requires": { "brace-expansion": "^1.1.7" @@ -25263,9 +25812,9 @@ "dev": true }, "to-buffer": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.1.tgz", - "integrity": "sha512-tB82LpAIWjhLYbqjx3X4zEeHN6M8CiuOEy2JY8SEQVdYRe3CCHOFaqrBW1doLDrfpWhplcW7BL+bO3/6S3pcDQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", "dev": true, "requires": { "isarray": "^2.0.5", @@ -25723,9 +26272,9 @@ "dev": true }, "undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true }, "unicode": { @@ -25744,13 +26293,13 @@ } }, "update-browserslist-db": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", - "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "requires": { - "escalade": "^3.1.2", - "picocolors": "^1.0.1" + "escalade": "^3.2.0", + "picocolors": "^1.1.1" } }, "uri-js": { @@ -25825,9 +26374,10 @@ "dev": true }, "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "dev": true }, "v8-compile-cache-lib": { "version": "3.0.0", @@ -26002,19 +26552,19 @@ }, "dependencies": { "brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "requires": { "balanced-match": "^1.0.0" } }, "minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "requires": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" } } } @@ -26042,9 +26592,9 @@ } }, "watchpack": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", - "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "dev": true, "requires": { "glob-to-regexp": "^0.4.1", @@ -26052,34 +26602,77 @@ } }, "webpack": { - "version": "5.94.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", - "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", - "dev": true, - "requires": { - "@types/estree": "^1.0.5", - "@webassemblyjs/ast": "^1.12.1", - "@webassemblyjs/wasm-edit": "^1.12.1", - "@webassemblyjs/wasm-parser": "^1.12.1", - "acorn": "^8.7.1", - "acorn-import-attributes": "^1.9.5", - "browserslist": "^4.21.10", + "version": "5.105.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", + "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", + "dev": true, + "requires": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.19.0", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", + "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^3.2.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.10", - "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.16", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.3" + }, + "dependencies": { + "ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + } + }, + "ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.3" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + } + } } }, "webpack-bundle-analyzer": { @@ -26229,9 +26822,9 @@ "requires": {} }, "webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", "dev": true }, "which": { @@ -26307,9 +26900,9 @@ } }, "workerpool": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", - "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", + "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", "dev": true }, "wrap-ansi": { diff --git a/package.json b/package.json index 2d4e67425806..9f689b60ff34 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "python", "displayName": "Python", "description": "Python language support with extension access points for IntelliSense (Pylance), Debugging (Python Debugger), linting, formatting, refactoring, unit tests, and more.", - "version": "2025.19.0-dev", + "version": "2026.5.0-dev", "featureFlags": { "usingNewInterpreterStorage": true }, @@ -1509,7 +1509,7 @@ "name": "get_python_environment_details", "displayName": "Get Python Environment Info", "userDescription": "%python.languageModelTools.get_python_environment_details.userDescription%", - "modelDescription": "This tool will retrieve the details of the Python Environment for the specified file or workspace. The details returned include the 1. Type of Python Environment (conda, venv, etec), 2. Version of Python, 3. List of all installed Python packages with their versions. ALWAYS call configure_python_environment before using this tool.", + "modelDescription": "This tool will retrieve the details of the Python Environment for the specified file or workspace. The details returned include the 1. Type of Python Environment (conda, venv, etc), 2. Version of Python, 3. List of all installed Python packages with their versions. ALWAYS call configure_python_environment before using this tool. IMPORTANT: This tool is only for Python environments (venv, virtualenv, conda, pipenv, poetry, pyenv, pixi, or any other Python environment manager). Do not use this tool for npm packages, system packages, Ruby gems, or any other non-Python dependencies.", "toolReferenceName": "getPythonEnvironmentInfo", "tags": [ "python", @@ -1534,7 +1534,7 @@ "name": "get_python_executable_details", "displayName": "Get Python Executable", "userDescription": "%python.languageModelTools.get_python_executable_details.userDescription%", - "modelDescription": "This tool will retrieve the details of the Python Environment for the specified file or workspace. ALWAYS use this tool before executing any Python command in the terminal. This tool returns the details of how to construct the fully qualified path and or command including details such as arguments required to run Python in a terminal. Note: Instead of executing `python --version` or `python -c 'import sys; print(sys.executable)'`, use this tool to get the Python executable path to replace the `python` command. E.g. instead of using `python -c 'import sys; print(sys.executable)'`, use this tool to build the command `conda run -n -c 'import sys; print(sys.executable)'`. ALWAYS call configure_python_environment before using this tool.", + "modelDescription": "This tool will retrieve the details of the Python Environment for the specified file or workspace. ALWAYS use this tool before executing any Python command in the terminal. This tool returns the details of how to construct the fully qualified path and or command including details such as arguments required to run Python in a terminal. Note: Instead of executing `python --version` or `python -c 'import sys; print(sys.executable)'`, use this tool to get the Python executable path to replace the `python` command. E.g. instead of using `python -c 'import sys; print(sys.executable)'`, use this tool to build the command `conda run -n -c 'import sys; print(sys.executable)'`. ALWAYS call configure_python_environment before using this tool. IMPORTANT: This tool is only for Python environments (venv, virtualenv, conda, pipenv, poetry, pyenv, pixi, or any other Python environment manager). Do not use this tool for npm packages, system packages, Ruby gems, or any other non-Python dependencies.", "toolReferenceName": "getPythonExecutableCommand", "tags": [ "python", @@ -1559,7 +1559,7 @@ "name": "install_python_packages", "displayName": "Install Python Package", "userDescription": "%python.languageModelTools.install_python_packages.userDescription%", - "modelDescription": "Installs Python packages in the given workspace. Use this tool to install Python packages in the user's chosen Python environment. ALWAYS call configure_python_environment before using this tool.", + "modelDescription": "Installs Python packages in the given workspace. Use this tool to install Python packages in the user's chosen Python environment. ALWAYS call configure_python_environment before using this tool. IMPORTANT: This tool should only be used to install Python packages using package managers like pip or conda (works with any Python environment: venv, virtualenv, pipenv, poetry, pyenv, pixi, conda, etc.). Do not use this tool to install npm packages, system packages (apt/brew/yum), Ruby gems, or any other non-Python dependencies.", "toolReferenceName": "installPythonPackage", "tags": [ "python", @@ -1593,7 +1593,7 @@ { "name": "configure_python_environment", "displayName": "Configure Python Environment", - "modelDescription": "This tool configures a Python environment in the given workspace. ALWAYS Use this tool to set up the user's chosen environment and ALWAYS call this tool before using any other Python related tools or running any Python command in the terminal.", + "modelDescription": "This tool configures a Python environment in the given workspace. ALWAYS Use this tool to set up the user's chosen environment and ALWAYS call this tool before using any other Python related tools or running any Python command in the terminal. IMPORTANT: This tool is only for Python environments (venv, virtualenv, conda, pipenv, poetry, pyenv, pixi, or any other Python environment manager). Do not use this tool for npm packages, system packages, Ruby gems, or any other non-Python dependencies.", "userDescription": "%python.languageModelTools.configure_python_environment.userDescription%", "toolReferenceName": "configurePythonEnvironment", "tags": [ @@ -1713,8 +1713,8 @@ "iconv-lite": "^0.6.3", "inversify": "^6.0.2", "jsonc-parser": "^3.0.0", - "lodash": "^4.17.21", - "minimatch": "^5.0.1", + "lodash": "^4.18.1", + "minimatch": "^5.1.8", "named-js-regexp": "^1.3.3", "node-stream-zip": "^1.6.0", "reflect-metadata": "^0.2.2", @@ -1746,7 +1746,7 @@ "@types/glob": "^7.2.0", "@types/lodash": "^4.14.104", "@types/mocha": "^9.1.0", - "@types/node": "^22.5.0", + "@types/node": "^22.19.1", "@types/semver": "^5.5.0", "@types/shortid": "^0.0.29", "@types/sinon": "^17.0.3", @@ -1799,8 +1799,8 @@ "tsconfig-paths-webpack-plugin": "^3.2.0", "typemoq": "^2.1.0", "typescript": "~5.2", - "uuid": "^8.3.2", - "webpack": "^5.76.0", + "uuid": "^14.0.0", + "webpack": "^5.105.0", "webpack-bundle-analyzer": "^4.5.0", "webpack-cli": "^4.9.2", "webpack-fix-default-import-plugin": "^1.0.3", diff --git a/pythonExtensionApi/package.json b/pythonExtensionApi/package.json index e4e956ff6065..11e0445aa8da 100644 --- a/pythonExtensionApi/package.json +++ b/pythonExtensionApi/package.json @@ -13,7 +13,7 @@ "main": "./out/main.js", "types": "./out/main.d.ts", "engines": { - "node": ">=22.17.0", + "node": ">=22.21.1", "vscode": "^1.93.0" }, "license": "MIT", diff --git a/python_files/python_server.py b/python_files/python_server.py index 77b43c692dc3..e7ee92794a21 100644 --- a/python_files/python_server.py +++ b/python_files/python_server.py @@ -64,6 +64,9 @@ def custom_input(prompt=""): message_text = STDIN.buffer.read(content_length).decode() message_json = json.loads(message_text) return message_json["result"]["userInput"] + except EOFError: + # Input stream closed, exit gracefully + sys.exit(0) except Exception: print_log(traceback.format_exc()) @@ -74,7 +77,7 @@ def custom_input(prompt=""): def handle_response(request_id): - while not STDIN.closed: + while True: try: headers = get_headers() # Content-Length is the data size in bytes. @@ -88,8 +91,10 @@ def handle_response(request_id): send_response(our_user_input, message_json["id"]) elif message_json["method"] == "exit": sys.exit(0) - - except Exception: # noqa: PERF203 + except EOFError: # noqa: PERF203 + # Input stream closed, exit gracefully + sys.exit(0) + except Exception: print_log(traceback.format_exc()) @@ -164,7 +169,11 @@ def get_value(self) -> str: def get_headers(): headers = {} while True: - line = STDIN.buffer.readline().decode().strip() + raw = STDIN.buffer.readline() + # Detect EOF: readline() returns empty bytes when input stream is closed + if raw == b"": + raise EOFError("EOF reached while reading headers") + line = raw.decode().strip() if not line: break name, value = line.split(":", 1) @@ -183,7 +192,7 @@ def get_headers(): while "" in sys.path: sys.path.remove("") sys.path.insert(0, "") - while not STDIN.closed: + while True: try: headers = get_headers() # Content-Length is the data size in bytes. @@ -198,6 +207,8 @@ def get_headers(): check_valid_command(request_json) elif request_json["method"] == "exit": sys.exit(0) - - except Exception: # noqa: PERF203 + except EOFError: # noqa: PERF203 + # Input stream closed (VS Code terminated), exit gracefully + sys.exit(0) + except Exception: print_log(traceback.format_exc()) diff --git a/python_files/pythonrc.py b/python_files/pythonrc.py index 63c52bc009da..3042ffb7a309 100644 --- a/python_files/pythonrc.py +++ b/python_files/pythonrc.py @@ -75,6 +75,9 @@ def __str__(self): return result + def __repr__(self): + return "" + if sys.platform != "win32" and (not is_wsl): sys.ps1 = PS1() diff --git a/python_files/tests/pytestadapter/expected_discovery_test_output.py b/python_files/tests/pytestadapter/expected_discovery_test_output.py index b6f0779cf982..047f1c72ad17 100644 --- a/python_files/tests/pytestadapter/expected_discovery_test_output.py +++ b/python_files/tests/pytestadapter/expected_discovery_test_output.py @@ -1870,3 +1870,214 @@ ], "id_": TEST_DATA_PATH_STR, } + +# ===================================================================================== +# PROJECT_ROOT_PATH environment variable tests +# These test the project-based testing feature where PROJECT_ROOT_PATH changes +# the test tree root from cwd to the specified project path. +# ===================================================================================== + +# This is the expected output for unittest_folder when PROJECT_ROOT_PATH is set to unittest_folder. +# The root of the tree is unittest_folder (not .data), simulating project-based testing. +# +# **Project Configuration:** +# In the VS Code Python extension, projects are defined by the Python Environments extension. +# Each project has a root directory (identified by pyproject.toml, setup.py, etc.). +# When PROJECT_ROOT_PATH is set, pytest uses that path as the test tree root instead of cwd. +# +# **Test Tree Structure:** +# Without PROJECT_ROOT_PATH (legacy mode): +# └── .data (cwd = workspace root) +# └── unittest_folder +# └── test_add.py, test_subtract.py... +# +# With PROJECT_ROOT_PATH set to unittest_folder (project-based mode): +# └── unittest_folder (ROOT - set via PROJECT_ROOT_PATH env var) +# β”œβ”€β”€ test_add.py +# β”‚ └── TestAddFunction +# β”‚ β”œβ”€β”€ test_add_negative_numbers +# β”‚ └── test_add_positive_numbers +# β”‚ └── TestDuplicateFunction +# β”‚ └── test_dup_a +# └── test_subtract.py +# └── TestSubtractFunction +# β”œβ”€β”€ test_subtract_negative_numbers +# └── test_subtract_positive_numbers +# └── TestDuplicateFunction +# └── test_dup_s +# +# Note: This reuses the unittest_folder paths defined earlier in this file. +project_root_unittest_folder_expected_output = { + "name": "unittest_folder", + "path": os.fspath(unittest_folder_path), + "type_": "folder", + "children": [ + { + "name": "test_add.py", + "path": os.fspath(test_add_path), + "type_": "file", + "id_": os.fspath(test_add_path), + "children": [ + { + "name": "TestAddFunction", + "path": os.fspath(test_add_path), + "type_": "class", + "children": [ + { + "name": "test_add_negative_numbers", + "path": os.fspath(test_add_path), + "lineno": find_test_line_number( + "test_add_negative_numbers", + os.fspath(test_add_path), + ), + "type_": "test", + "id_": get_absolute_test_id( + "test_add.py::TestAddFunction::test_add_negative_numbers", + test_add_path, + ), + "runID": get_absolute_test_id( + "test_add.py::TestAddFunction::test_add_negative_numbers", + test_add_path, + ), + }, + { + "name": "test_add_positive_numbers", + "path": os.fspath(test_add_path), + "lineno": find_test_line_number( + "test_add_positive_numbers", + os.fspath(test_add_path), + ), + "type_": "test", + "id_": get_absolute_test_id( + "test_add.py::TestAddFunction::test_add_positive_numbers", + test_add_path, + ), + "runID": get_absolute_test_id( + "test_add.py::TestAddFunction::test_add_positive_numbers", + test_add_path, + ), + }, + ], + "id_": get_absolute_test_id( + "test_add.py::TestAddFunction", + test_add_path, + ), + "lineno": find_class_line_number("TestAddFunction", test_add_path), + }, + { + "name": "TestDuplicateFunction", + "path": os.fspath(test_add_path), + "type_": "class", + "children": [ + { + "name": "test_dup_a", + "path": os.fspath(test_add_path), + "lineno": find_test_line_number( + "test_dup_a", + os.fspath(test_add_path), + ), + "type_": "test", + "id_": get_absolute_test_id( + "test_add.py::TestDuplicateFunction::test_dup_a", + test_add_path, + ), + "runID": get_absolute_test_id( + "test_add.py::TestDuplicateFunction::test_dup_a", + test_add_path, + ), + }, + ], + "id_": get_absolute_test_id( + "test_add.py::TestDuplicateFunction", + test_add_path, + ), + "lineno": find_class_line_number("TestDuplicateFunction", test_add_path), + }, + ], + }, + { + "name": "test_subtract.py", + "path": os.fspath(test_subtract_path), + "type_": "file", + "id_": os.fspath(test_subtract_path), + "children": [ + { + "name": "TestSubtractFunction", + "path": os.fspath(test_subtract_path), + "type_": "class", + "children": [ + { + "name": "test_subtract_negative_numbers", + "path": os.fspath(test_subtract_path), + "lineno": find_test_line_number( + "test_subtract_negative_numbers", + os.fspath(test_subtract_path), + ), + "type_": "test", + "id_": get_absolute_test_id( + "test_subtract.py::TestSubtractFunction::test_subtract_negative_numbers", + test_subtract_path, + ), + "runID": get_absolute_test_id( + "test_subtract.py::TestSubtractFunction::test_subtract_negative_numbers", + test_subtract_path, + ), + }, + { + "name": "test_subtract_positive_numbers", + "path": os.fspath(test_subtract_path), + "lineno": find_test_line_number( + "test_subtract_positive_numbers", + os.fspath(test_subtract_path), + ), + "type_": "test", + "id_": get_absolute_test_id( + "test_subtract.py::TestSubtractFunction::test_subtract_positive_numbers", + test_subtract_path, + ), + "runID": get_absolute_test_id( + "test_subtract.py::TestSubtractFunction::test_subtract_positive_numbers", + test_subtract_path, + ), + }, + ], + "id_": get_absolute_test_id( + "test_subtract.py::TestSubtractFunction", + test_subtract_path, + ), + "lineno": find_class_line_number("TestSubtractFunction", test_subtract_path), + }, + { + "name": "TestDuplicateFunction", + "path": os.fspath(test_subtract_path), + "type_": "class", + "children": [ + { + "name": "test_dup_s", + "path": os.fspath(test_subtract_path), + "lineno": find_test_line_number( + "test_dup_s", + os.fspath(test_subtract_path), + ), + "type_": "test", + "id_": get_absolute_test_id( + "test_subtract.py::TestDuplicateFunction::test_dup_s", + test_subtract_path, + ), + "runID": get_absolute_test_id( + "test_subtract.py::TestDuplicateFunction::test_dup_s", + test_subtract_path, + ), + }, + ], + "id_": get_absolute_test_id( + "test_subtract.py::TestDuplicateFunction", + test_subtract_path, + ), + "lineno": find_class_line_number("TestDuplicateFunction", test_subtract_path), + }, + ], + }, + ], + "id_": os.fspath(unittest_folder_path), +} diff --git a/python_files/tests/pytestadapter/helpers.py b/python_files/tests/pytestadapter/helpers.py index 25e6187e2efa..03f1187149df 100644 --- a/python_files/tests/pytestadapter/helpers.py +++ b/python_files/tests/pytestadapter/helpers.py @@ -264,17 +264,34 @@ def runner_with_cwd_env( pipe_name = generate_random_pipe_name("pytest-discovery-test") if "COVERAGE_ENABLED" in env_add and "_TEST_VAR_UNITTEST" not in env_add: - process_args = [ - sys.executable, - "-m", - "pytest", - "-p", - "vscode_pytest", - "--cov=.", - "--cov-branch", - "-s", - *args, - ] + if "_PYTEST_MANUAL_PLUGIN_LOAD" in env_add: + # Test manual plugin loading scenario for issue #25590 + process_args = [ + sys.executable, + "-m", + "pytest", + "--disable-plugin-autoload", + "-p", + "pytest_cov.plugin", + "-p", + "vscode_pytest", + "--cov=.", + "--cov-branch", + "-s", + *args, + ] + else: + process_args = [ + sys.executable, + "-m", + "pytest", + "-p", + "vscode_pytest", + "--cov=.", + "--cov-branch", + "-s", + *args, + ] # Generate pipe name, pipe name specific per OS type. diff --git a/python_files/tests/pytestadapter/test_coverage.py b/python_files/tests/pytestadapter/test_coverage.py index d2d276172a8d..f2387527698f 100644 --- a/python_files/tests/pytestadapter/test_coverage.py +++ b/python_files/tests/pytestadapter/test_coverage.py @@ -142,3 +142,23 @@ def test_coverage_w_omit_config(): assert results # assert one file is reported and one file (as specified in pyproject.toml) is omitted assert len(results) == 1 + + +def test_pytest_cov_manual_plugin_loading(): + """ + Test that pytest-cov is detected when loaded manually via -p pytest_cov.plugin. + + This test verifies the fix for issue #25590, where pytest-cov detection failed + when using --disable-plugin-autoload with -p pytest_cov.plugin. The plugin is + registered under its module name (pytest_cov.plugin) instead of entry point name + (pytest_cov) in this scenario. + """ + args = ["--collect-only"] + env_add = {"COVERAGE_ENABLED": "True", "_PYTEST_MANUAL_PLUGIN_LOAD": "True"} + cov_folder_path = TEST_DATA_PATH / "coverage_gen" + + # Should NOT raise VSCodePytestError about pytest-cov not being installed + actual = runner_with_cwd_env(args, cov_folder_path, env_add) + assert actual is not None + # Verify discovery succeeded (status != "error") + assert actual[0].get("status") != "error" diff --git a/python_files/tests/pytestadapter/test_discovery.py b/python_files/tests/pytestadapter/test_discovery.py index 842ee3c7c707..cf777399fed9 100644 --- a/python_files/tests/pytestadapter/test_discovery.py +++ b/python_files/tests/pytestadapter/test_discovery.py @@ -386,3 +386,97 @@ def test_plugin_collect(file, expected_const, extra_arg): ), ( f"Tests tree does not match expected value. \n Expected: {json.dumps(expected_const, indent=4)}. \n Actual: {json.dumps(actual_item.get('tests'), indent=4)}" ) + + +def test_project_root_path_env_var(): + """Test pytest discovery with PROJECT_ROOT_PATH environment variable set. + + This simulates project-based testing where the test tree root should be + the project root (PROJECT_ROOT_PATH) rather than the workspace cwd. + + When PROJECT_ROOT_PATH is set: + - The test tree root (name, path, id_) should match PROJECT_ROOT_PATH + - The cwd in the response should match PROJECT_ROOT_PATH + - Test files should be direct children of the root (not nested under a subfolder) + """ + # Use unittest_folder as our "project" subdirectory + project_path = helpers.TEST_DATA_PATH / "unittest_folder" + + actual = helpers.runner_with_cwd_env( + [os.fspath(project_path), "--collect-only"], + helpers.TEST_DATA_PATH, # cwd is parent of project + {"PROJECT_ROOT_PATH": os.fspath(project_path)}, # Set project root + ) + + assert actual + actual_list: List[Dict[str, Any]] = actual + if actual_list is not None: + actual_item = actual_list.pop(0) + + assert all(item in actual_item for item in ("status", "cwd", "error")) + assert actual_item.get("status") == "success", ( + f"Status is not 'success', error is: {actual_item.get('error')}" + ) + # cwd in response should be PROJECT_ROOT_PATH + assert actual_item.get("cwd") == os.fspath(project_path), ( + f"Expected cwd '{os.fspath(project_path)}', got '{actual_item.get('cwd')}'" + ) + assert is_same_tree( + actual_item.get("tests"), + expected_discovery_test_output.project_root_unittest_folder_expected_output, + ["id_", "lineno", "name", "runID"], + ), ( + f"Tests tree does not match expected value. \n Expected: {json.dumps(expected_discovery_test_output.project_root_unittest_folder_expected_output, indent=4)}. \n Actual: {json.dumps(actual_item.get('tests'), indent=4)}" + ) + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="Symlinks require elevated privileges on Windows", +) +def test_symlink_with_project_root_path(): + """Test pytest discovery with both symlink and PROJECT_ROOT_PATH set. + + This tests the combination of: + 1. A symlinked test directory (--rootdir points to symlink) + 2. PROJECT_ROOT_PATH set to the symlink path + + This simulates project-based testing where the project root is a symlink, + ensuring test IDs and paths are correctly resolved through the symlink. + """ + with helpers.create_symlink(helpers.TEST_DATA_PATH, "root", "symlink_folder") as ( + source, + destination, + ): + assert destination.is_symlink() + + # Run pytest with: + # - cwd being the resolved symlink path (simulating subprocess from node) + # - PROJECT_ROOT_PATH set to the symlink destination + actual = helpers.runner_with_cwd_env( + ["--collect-only", f"--rootdir={os.fspath(destination)}"], + source, # cwd is the resolved (non-symlink) path + {"PROJECT_ROOT_PATH": os.fspath(destination)}, # Project root is the symlink + ) + + expected = expected_discovery_test_output.symlink_expected_discovery_output + assert actual + actual_list: List[Dict[str, Any]] = actual + if actual_list is not None: + actual_item = actual_list.pop(0) + try: + assert all(item in actual_item for item in ("status", "cwd", "error")), ( + "Required keys are missing" + ) + assert actual_item.get("status") == "success", ( + f"Status is not 'success', error is: {actual_item.get('error')}" + ) + # cwd should be the PROJECT_ROOT_PATH (the symlink destination) + assert actual_item.get("cwd") == os.fspath(destination), ( + f"CWD does not match symlink path: expected {os.fspath(destination)}, got {actual_item.get('cwd')}" + ) + assert actual_item.get("tests") == expected, "Tests do not match expected value" + except AssertionError as e: + # Print the actual_item in JSON format if an assertion fails + print(json.dumps(actual_item, indent=4)) + pytest.fail(str(e)) diff --git a/python_files/tests/pytestadapter/test_utils.py b/python_files/tests/pytestadapter/test_utils.py index ef0ed2daf4e9..70201db7d097 100644 --- a/python_files/tests/pytestadapter/test_utils.py +++ b/python_files/tests/pytestadapter/test_utils.py @@ -12,7 +12,7 @@ script_dir = pathlib.Path(__file__).parent.parent.parent sys.path.append(os.fspath(script_dir)) -from vscode_pytest import has_symlink_parent # noqa: E402 +from vscode_pytest import cached_fsdecode, has_symlink_parent # noqa: E402 def test_has_symlink_parent_with_symlink(): @@ -33,3 +33,25 @@ def test_has_symlink_parent_without_symlink(): folder_path = TEST_DATA_PATH / "unittest_folder" / "test_add.py" # Check that has_symlink_parent correctly identifies that there are no symbolic links assert not has_symlink_parent(folder_path) + + +def test_cached_fsdecode(): + """Test that cached_fsdecode correctly caches path-to-string conversions.""" + # Create a test path + test_path = TEST_DATA_PATH / "simple_pytest.py" + + # First call should compute and cache + result1 = cached_fsdecode(test_path) + assert result1 == os.fspath(test_path) + assert isinstance(result1, str) + + # Second call should return cached value (same object) + result2 = cached_fsdecode(test_path) + assert result2 == result1 + assert result2 is result1 # Should be the same object from cache + + # Different path should be cached independently + test_path2 = TEST_DATA_PATH / "parametrize_tests.py" + result3 = cached_fsdecode(test_path2) + assert result3 == os.fspath(test_path2) + assert result3 != result1 diff --git a/python_files/tests/test_python_server.py b/python_files/tests/test_python_server.py new file mode 100644 index 000000000000..ca542b8ea292 --- /dev/null +++ b/python_files/tests/test_python_server.py @@ -0,0 +1,162 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for python_server.py, specifically EOF handling to prevent infinite loops.""" + +import io +from unittest import mock + +import pytest + + +class TestGetHeaders: + """Tests for the get_headers function.""" + + def test_get_headers_normal(self): + """Test get_headers with valid headers.""" + # Arrange: Import the module + import python_server + + # Create a mock stdin with valid headers + mock_input = b"Content-Length: 100\r\nContent-Type: application/json\r\n\r\n" + mock_stdin = io.BytesIO(mock_input) + + # Act + with mock.patch.object(python_server, "STDIN", mock.Mock(buffer=mock_stdin)): + headers = python_server.get_headers() + + # Assert + assert headers == {"Content-Length": "100", "Content-Type": "application/json"} + + def test_get_headers_eof_raises_error(self): + """Test that get_headers raises EOFError when stdin is closed (EOF).""" + # Arrange: Import the module + import python_server + + # Create a mock stdin that returns empty bytes (EOF) + mock_stdin = io.BytesIO(b"") + + # Act & Assert + with mock.patch.object(python_server, "STDIN", mock.Mock(buffer=mock_stdin)), pytest.raises( + EOFError, match="EOF reached while reading headers" + ): + python_server.get_headers() + + def test_get_headers_eof_mid_headers_raises_error(self): + """Test that get_headers raises EOFError when EOF occurs mid-headers.""" + # Arrange: Import the module + import python_server + + # Create a mock stdin with partial headers then EOF + mock_input = b"Content-Length: 100\r\n" # No terminating empty line + mock_stdin = io.BytesIO(mock_input) + + # Act & Assert + with mock.patch.object(python_server, "STDIN", mock.Mock(buffer=mock_stdin)), pytest.raises( + EOFError, match="EOF reached while reading headers" + ): + python_server.get_headers() + + def test_get_headers_empty_line_terminates(self): + """Test that an empty line (not EOF) properly terminates header reading.""" + # Arrange: Import the module + import python_server + + # Create a mock stdin with headers followed by empty line + mock_input = b"Content-Length: 50\r\n\r\nsome body content" + mock_stdin = io.BytesIO(mock_input) + + # Act + with mock.patch.object(python_server, "STDIN", mock.Mock(buffer=mock_stdin)): + headers = python_server.get_headers() + + # Assert + assert headers == {"Content-Length": "50"} + + +class TestEOFHandling: + """Tests for EOF handling in various functions that use get_headers.""" + + def test_custom_input_exits_on_eof(self): + """Test that custom_input exits gracefully on EOF.""" + # Arrange: Import the module + import python_server + + # Create a mock stdin that returns empty bytes (EOF) + mock_stdin = io.BytesIO(b"") + mock_stdout = io.BytesIO() + + # Act & Assert + with mock.patch.object( + python_server, "STDIN", mock.Mock(buffer=mock_stdin) + ), mock.patch.object(python_server, "STDOUT", mock.Mock(buffer=mock_stdout)), pytest.raises( + SystemExit + ) as exc_info: + python_server.custom_input("prompt> ") + + # Should exit with code 0 (graceful exit) + assert exc_info.value.code == 0 + + def test_handle_response_exits_on_eof(self): + """Test that handle_response exits gracefully on EOF.""" + # Arrange: Import the module + import python_server + + # Create a mock stdin that returns empty bytes (EOF) + mock_stdin = io.BytesIO(b"") + + # Act & Assert + with mock.patch.object(python_server, "STDIN", mock.Mock(buffer=mock_stdin)), pytest.raises( + SystemExit + ) as exc_info: + python_server.handle_response("test-request-id") + + # Should exit with code 0 (graceful exit) + assert exc_info.value.code == 0 + + +class TestMainLoopEOFHandling: + """Tests that simulate the main loop EOF scenario.""" + + def test_main_loop_exits_on_eof(self): + """Test that the main loop pattern exits gracefully on EOF. + + This test verifies the fix for GitHub issue #25620 where the server + would spin at 100% CPU instead of exiting when VS Code closes. + """ + # Arrange: Import the module + import python_server + + # Create a mock stdin that returns empty bytes (EOF) + mock_stdin = io.BytesIO(b"") + + # Simulate what happens in the main loop + with mock.patch.object(python_server, "STDIN", mock.Mock(buffer=mock_stdin)): + try: + python_server.get_headers() + # If we get here without raising EOFError, the fix isn't working + pytest.fail("Expected EOFError to be raised on EOF") + except EOFError: + # This is the expected behavior - the fix is working + pass + + def test_readline_eof_vs_empty_line(self): + """Test that we correctly distinguish between EOF and empty line. + + EOF: readline() returns b'' (empty bytes) + Empty line: readline() returns b'\\r\\n' or b'\\n' (newline bytes) + """ + # Test EOF case + eof_stream = io.BytesIO(b"") + result = eof_stream.readline() + assert result == b"", "EOF should return empty bytes" + + # Test empty line case + empty_line_stream = io.BytesIO(b"\r\n") + result = empty_line_stream.readline() + assert result == b"\r\n", "Empty line should return newline bytes" + + # Test empty line with just newline + empty_line_stream2 = io.BytesIO(b"\n") + result = empty_line_stream2.readline() + assert result == b"\n", "Empty line should return newline bytes" diff --git a/python_files/tests/unittestadapter/.data/test_scenarios/tests/__init__.py b/python_files/tests/unittestadapter/.data/test_scenarios/tests/__init__.py index 6b8fbbc579ab..5b7f7a925cc0 100644 --- a/python_files/tests/unittestadapter/.data/test_scenarios/tests/__init__.py +++ b/python_files/tests/unittestadapter/.data/test_scenarios/tests/__init__.py @@ -1,15 +1,2 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. - -import os - -import testresources -from testscenarios import generate_scenarios - -def load_tests(loader, tests, pattern): - this_dir = os.path.dirname(__file__) - mytests = loader.discover(start_dir=this_dir, pattern=pattern) - result = testresources.OptimisingTestSuite() - result.addTests(generate_scenarios(mytests)) - result.addTests(generate_scenarios(tests)) - return result diff --git a/python_files/tests/unittestadapter/.data/test_scenarios/tests/test_scene.py b/python_files/tests/unittestadapter/.data/test_scenarios/tests/test_scene.py index 1f66cbde4ef7..35c1c7002319 100644 --- a/python_files/tests/unittestadapter/.data/test_scenarios/tests/test_scene.py +++ b/python_files/tests/unittestadapter/.data/test_scenarios/tests/test_scene.py @@ -1,13 +1,27 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from testscenarios import TestWithScenarios +import unittest + +from testscenarios import TestWithScenarios, generate_scenarios + + +def load_tests(loader, standard_tests, pattern): # noqa: ARG001 + # Pre-expand ``TestWithScenarios`` scenarios at load time so individual + # scenario-multiplied test IDs (e.g. ``test_operations(add)``) can be + # resolved by ``unittest.TestLoader.loadTestsFromName``. Without this, + # ``TestWithScenarios`` only multiplies scenarios at ``run()`` time and + # loading a specific scenario by name raises ``AttributeError``. + result = unittest.TestSuite() + result.addTests(generate_scenarios(standard_tests)) + return result + class TestMathOperations(TestWithScenarios): scenarios = [ ('add', {'test_id': 'test_add', 'a': 5, 'b': 3, 'expected': 8}), ('subtract', {'test_id': 'test_subtract', 'a': 5, 'b': 3, 'expected': 2}), - ('multiply', {'test_id': 'test_multiply', 'a': 5, 'b': 3, 'expected': 15}) + ('multiply', {'test_id': 'test_multiply', 'a': 5, 'b': 3, 'expected': 15}), ] a: int = 0 b: int = 0 diff --git a/python_files/tests/unittestadapter/test_discovery.py b/python_files/tests/unittestadapter/test_discovery.py index a10b5c406680..ab028ef176c3 100644 --- a/python_files/tests/unittestadapter/test_discovery.py +++ b/python_files/tests/unittestadapter/test_discovery.py @@ -325,3 +325,123 @@ def test_simple_django_collect(): assert ( len(actual_item["tests"]["children"][0]["children"][0]["children"][0]["children"]) == 3 ) + + +def test_project_root_path_with_cwd_override() -> None: + """Test unittest discovery with project_root_path parameter. + + This simulates project-based testing where the cwd in the payload should be + the project root (project_root_path) rather than the start_dir. + + When project_root_path is provided: + - The cwd in the response should match project_root_path + - The test tree root should still be built correctly based on top_level_dir + """ + # Use unittest_skip folder as our "project" directory + project_path = TEST_DATA_PATH / "unittest_skip" + start_dir = os.fsdecode(project_path) + pattern = "unittest_*" + + # Call discover_tests with project_root_path to simulate PROJECT_ROOT_PATH + actual = discover_tests(start_dir, pattern, None, project_root_path=start_dir) + + assert actual["status"] == "success" + # cwd in response should match the project_root_path (project root) + assert actual["cwd"] == os.fsdecode(project_path), ( + f"Expected cwd '{os.fsdecode(project_path)}', got '{actual['cwd']}'" + ) + assert "tests" in actual + # Verify the test tree structure matches expected output + assert is_same_tree( + actual.get("tests"), + expected_discovery_test_output.skip_unittest_folder_discovery_output, + ["id_", "lineno", "name"], + ) + assert "error" not in actual + + +def test_project_root_path_with_different_cwd_and_start_dir() -> None: + """Test unittest discovery where project_root_path differs from start_dir. + + This simulates the scenario where: + - start_dir points to a subfolder where tests are located + - project_root_path (PROJECT_ROOT_PATH) points to the project root + + The cwd in the response should be the project root, while discovery + still runs from the start_dir. + """ + # Use utils_complex_tree as our test case - discovery from a subfolder + project_path = TEST_DATA_PATH / "utils_complex_tree" + start_dir = os.fsdecode( + pathlib.PurePath( + TEST_DATA_PATH, + "utils_complex_tree", + "test_outer_folder", + "test_inner_folder", + ) + ) + pattern = "test_*.py" + top_level_dir = os.fsdecode(project_path) + + # Call discover_tests with project_root_path set to project root + actual = discover_tests(start_dir, pattern, top_level_dir, project_root_path=top_level_dir) + + assert actual["status"] == "success" + # cwd should be the project root (project_root_path), not the start_dir + assert actual["cwd"] == os.fsdecode(project_path), ( + f"Expected cwd '{os.fsdecode(project_path)}', got '{actual['cwd']}'" + ) + assert "error" not in actual + # Test tree should still be structured correctly with top_level_dir as root + assert is_same_tree( + actual.get("tests"), + expected_discovery_test_output.complex_tree_expected_output, + ["id_", "lineno", "name"], + ) + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="Symlinks require elevated privileges on Windows", +) +def test_symlink_with_project_root_path() -> None: + """Test unittest discovery with both symlink and PROJECT_ROOT_PATH set. + + This tests the combination of: + 1. A symlinked test directory + 2. project_root_path (PROJECT_ROOT_PATH) set to the symlink path + + This simulates project-based testing where the project root is a symlink, + ensuring test IDs and paths are correctly resolved through the symlink. + """ + with helpers.create_symlink(TEST_DATA_PATH, "unittest_skip", "symlink_unittest") as ( + _source, + destination, + ): + assert destination.is_symlink() + + # Run discovery with: + # - start_dir pointing to the symlink destination + # - project_root_path set to the symlink destination (simulating PROJECT_ROOT_PATH) + start_dir = os.fsdecode(destination) + pattern = "unittest_*" + + actual = discover_tests(start_dir, pattern, None, project_root_path=start_dir) + + assert actual["status"] == "success", ( + f"Status is not 'success', error is: {actual.get('error')}" + ) + # cwd should be the symlink path (project_root_path) + assert actual["cwd"] == os.fsdecode(destination), ( + f"CWD does not match symlink path: expected {os.fsdecode(destination)}, got {actual['cwd']}" + ) + assert "tests" in actual + assert actual["tests"] is not None + # The test tree root should be named after the symlink directory + assert actual["tests"]["name"] == "symlink_unittest", ( + f"Expected root name 'symlink_unittest', got '{actual['tests']['name']}'" + ) + # The test tree root path should use the symlink path + assert actual["tests"]["path"] == os.fsdecode(destination), ( + f"Expected root path to be symlink, got '{actual['tests']['path']}'" + ) diff --git a/python_files/tests/unittestadapter/test_execution.py b/python_files/tests/unittestadapter/test_execution.py index f369c6d770b0..cab03f0b5dc4 100644 --- a/python_files/tests/unittestadapter/test_execution.py +++ b/python_files/tests/unittestadapter/test_execution.py @@ -341,3 +341,134 @@ def test_basic_run_django(): assert id_result["outcome"] == "failure" else: assert id_result["outcome"] == "success" + + +def test_project_root_path_with_cwd_override(mock_send_run_data) -> None: # noqa: ARG001 + """Test unittest execution with project_root_path parameter. + + This simulates project-based testing where the cwd in the payload should be + the project root (project_root_path) rather than the start_dir. + + When project_root_path is provided: + - The cwd in the response should match project_root_path + - Test execution should still work correctly with start_dir + """ + # Use unittest_folder as our "project" directory + project_path = TEST_DATA_PATH / "unittest_folder" + start_dir = os.fsdecode(project_path) + pattern = "test_add*" + test_ids = [ + "test_add.TestAddFunction.test_add_positive_numbers", + ] + + os.environ["TEST_RUN_PIPE"] = "fake" + + # Call run_tests with project_root_path to simulate PROJECT_ROOT_PATH + actual = run_tests( + start_dir, + test_ids, + pattern, + None, + 1, + None, + project_root_path=start_dir, + ) + + assert actual["status"] == "success" + # cwd in response should match the project_root_path (project root) + assert actual["cwd"] == os.fsdecode(project_path), ( + f"Expected cwd '{os.fsdecode(project_path)}', got '{actual['cwd']}'" + ) + assert actual["result"] is not None + assert test_ids[0] in actual["result"] + assert actual["result"][test_ids[0]]["outcome"] == "success" + + +def test_project_root_path_with_different_cwd_and_start_dir(mock_send_run_data) -> None: # noqa: ARG001 + """Test unittest execution where project_root_path differs from start_dir. + + This simulates the scenario where: + - start_dir points to a subfolder where tests are located + - project_root_path (PROJECT_ROOT_PATH) points to the project root + + The cwd in the response should be the project root, while execution + still runs from the start_dir. + """ + # Use utils_nested_cases as our test case + project_path = TEST_DATA_PATH / "utils_nested_cases" + start_dir = os.fsdecode(project_path) + pattern = "*" + test_ids = [ + "file_one.CaseTwoFileOne.test_one", + ] + + os.environ["TEST_RUN_PIPE"] = "fake" + + # Call run_tests with project_root_path set to project root + actual = run_tests( + start_dir, + test_ids, + pattern, + None, + 1, + None, + project_root_path=os.fsdecode(project_path), + ) + + assert actual["status"] == "success" + # cwd should be the project root (project_root_path) + assert actual["cwd"] == os.fsdecode(project_path), ( + f"Expected cwd '{os.fsdecode(project_path)}', got '{actual['cwd']}'" + ) + assert actual["result"] is not None + assert test_ids[0] in actual["result"] + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="Symlinks require elevated privileges on Windows", +) +def test_symlink_with_project_root_path(mock_send_run_data) -> None: # noqa: ARG001 + """Test unittest execution with both symlink and project_root_path set. + + This tests the combination of: + 1. A symlinked test directory + 2. project_root_path (PROJECT_ROOT_PATH) set to the symlink path + + This simulates project-based testing where the project root is a symlink, + ensuring execution payloads correctly use the symlink path. + """ + with helpers.create_symlink(TEST_DATA_PATH, "unittest_folder", "symlink_unittest_exec") as ( + _source, + destination, + ): + assert destination.is_symlink() + + # Run execution with: + # - start_dir pointing to the symlink destination + # - project_root_path set to the symlink destination (simulating PROJECT_ROOT_PATH) + start_dir = os.fsdecode(destination) + pattern = "test_add*" + test_ids = [ + "test_add.TestAddFunction.test_add_positive_numbers", + ] + + os.environ["TEST_RUN_PIPE"] = "fake" + + actual = run_tests( + start_dir, + test_ids, + pattern, + None, + 1, + None, + project_root_path=start_dir, + ) + + assert actual["status"] == "success", ( + f"Status is not 'success', error is: {actual.get('error')}" + ) + # cwd should be the symlink path (project_root_path) + assert actual["cwd"] == os.fsdecode(destination), ( + f"CWD does not match symlink path: expected {os.fsdecode(destination)}, got {actual['cwd']}" + ) diff --git a/python_files/unittestadapter/discovery.py b/python_files/unittestadapter/discovery.py index ce8251218743..c864ac76916b 100644 --- a/python_files/unittestadapter/discovery.py +++ b/python_files/unittestadapter/discovery.py @@ -27,12 +27,13 @@ def discover_tests( start_dir: str, pattern: str, top_level_dir: Optional[str], + project_root_path: Optional[str] = None, ) -> DiscoveryPayloadDict: """Returns a dictionary containing details of the discovered tests. The returned dict has the following keys: - - cwd: Absolute path to the test start directory; + - cwd: Absolute path to the test start directory (or project_root_path if provided); - status: Test discovery status, can be "success" or "error"; - tests: Discoverered tests if any, not present otherwise. Note that the status can be "error" but the payload can still contain tests; - error: Discovery error if any, not present otherwise. @@ -56,8 +57,15 @@ def discover_tests( "": [list of errors] "status": "error", } + + Args: + start_dir: Directory where test discovery starts + pattern: Pattern to match test files (e.g., "test*.py") + top_level_dir: Top-level directory for the test tree hierarchy + project_root_path: Optional project root path for the cwd in the response payload + (used for project-based testing to root test tree at project) """ - cwd = os.path.abspath(start_dir) # noqa: PTH100 + cwd = os.path.abspath(project_root_path or start_dir) # noqa: PTH100 if "/" in start_dir: # is a subdir parent_dir = os.path.dirname(start_dir) # noqa: PTH120 sys.path.insert(0, parent_dir) @@ -133,7 +141,16 @@ def discover_tests( print(error_msg, file=sys.stderr) raise VSCodeUnittestError(error_msg) # noqa: B904 else: + # Check for PROJECT_ROOT_PATH environment variable (project-based testing). + # When set, this overrides top_level_dir to root the test tree at the project directory. + project_root_path = os.environ.get("PROJECT_ROOT_PATH") + if project_root_path: + top_level_dir = project_root_path + # Perform regular unittest test discovery. - payload = discover_tests(start_dir, pattern, top_level_dir) + # Pass project_root_path so the payload's cwd matches the project root. + payload = discover_tests( + start_dir, pattern, top_level_dir, project_root_path=project_root_path + ) # Post this discovery payload. send_post_request(payload, test_run_pipe) diff --git a/python_files/unittestadapter/execution.py b/python_files/unittestadapter/execution.py index a0a48c61470a..422f246d3476 100644 --- a/python_files/unittestadapter/execution.py +++ b/python_files/unittestadapter/execution.py @@ -36,6 +36,9 @@ ErrorType = Union[Tuple[Type[BaseException], BaseException, TracebackType], Tuple[None, None, None]] test_run_pipe = "" START_DIR = "" +# PROJECT_ROOT_PATH: Used for project-based testing to override cwd in payload +# When set, this should be used as the cwd in all execution payloads +PROJECT_ROOT_PATH = None # type: Optional[str] class TestOutcomeEnum(str, enum.Enum): @@ -191,8 +194,22 @@ def run_tests( verbosity: int, failfast: Optional[bool], # noqa: FBT001 locals_: Optional[bool] = None, # noqa: FBT001 + project_root_path: Optional[str] = None, ) -> ExecutionPayloadDict: - cwd = os.path.abspath(start_dir) # noqa: PTH100 + """Run unittests and return the execution payload. + + Args: + start_dir: Directory where test discovery starts + test_ids: List of test IDs to run + pattern: Pattern to match test files + top_level_dir: Top-level directory for test tree hierarchy + verbosity: Verbosity level for test output + failfast: Stop on first failure + locals_: Show local variables in tracebacks + project_root_path: Optional project root path for the cwd in the response payload + (used for project-based testing to root test tree at project) + """ + cwd = os.path.abspath(project_root_path or start_dir) # noqa: PTH100 if "/" in start_dir: # is a subdir parent_dir = os.path.dirname(start_dir) # noqa: PTH120 sys.path.insert(0, parent_dir) @@ -259,7 +276,8 @@ def run_tests( def send_run_data(raw_data, test_run_pipe): status = raw_data["outcome"] - cwd = os.path.abspath(START_DIR) # noqa: PTH100 + # Use PROJECT_ROOT_PATH if set (project-based testing), otherwise use START_DIR + cwd = os.path.abspath(PROJECT_ROOT_PATH or START_DIR) # noqa: PTH100 test_id = raw_data["subtest"] or raw_data["test"] test_dict = {} test_dict[test_id] = raw_data @@ -284,10 +302,10 @@ def send_run_data(raw_data, test_run_pipe): run_test_ids_pipe = os.environ.get("RUN_TEST_IDS_PIPE") test_run_pipe = os.getenv("TEST_RUN_PIPE") if not run_test_ids_pipe: - print("Error[vscode-unittest]: RUN_TEST_IDS_PIPE env var is not set.") + print("Error[vscode-unittest]: RUN_TEST_IDS_PIPE env var is not set.", file=sys.stderr) raise VSCodeUnittestError("Error[vscode-unittest]: RUN_TEST_IDS_PIPE env var is not set.") if not test_run_pipe: - print("Error[vscode-unittest]: TEST_RUN_PIPE env var is not set.") + print("Error[vscode-unittest]: TEST_RUN_PIPE env var is not set.", file=sys.stderr) raise VSCodeUnittestError("Error[vscode-unittest]: TEST_RUN_PIPE env var is not set.") test_ids = [] cwd = pathlib.Path(start_dir).absolute() @@ -295,11 +313,10 @@ def send_run_data(raw_data, test_run_pipe): # Read the test ids from the file, attempt to delete file afterwords. ids_path = pathlib.Path(run_test_ids_pipe) test_ids = ids_path.read_text(encoding="utf-8").splitlines() - print("Received test ids from temp file.") try: ids_path.unlink() except Exception as e: - print("Error[vscode-pytest]: unable to delete temp file" + str(e)) + print(f"Error[vscode-unittest]: unable to delete temp file: {e}", file=sys.stderr) except Exception as e: # No test ids received from buffer, return error payload @@ -318,10 +335,6 @@ def send_run_data(raw_data, test_run_pipe): is_coverage_run = os.environ.get("COVERAGE_ENABLED") is not None include_branches = False if is_coverage_run: - print( - "COVERAGE_ENABLED env var set, starting coverage. workspace_root used as parent dir:", - workspace_root, - ) import coverage # insert "python_files/lib/python" into the path so packaging can be imported @@ -350,11 +363,19 @@ def send_run_data(raw_data, test_run_pipe): # If no error occurred, we will have test ids to run. if manage_py_path := os.environ.get("MANAGE_PY_PATH"): - print("MANAGE_PY_PATH env var set, running Django test suite.") args = argv[index + 1 :] or [] django_execution_runner(manage_py_path, test_ids, args) else: + # Check for PROJECT_ROOT_PATH environment variable (project-based testing). + # When set, this overrides the cwd in the payload to match the project root. + project_root_path = os.environ.get("PROJECT_ROOT_PATH") + if project_root_path: + # Update the module-level variable for send_run_data to use + # pylint: disable=global-statement + globals()["PROJECT_ROOT_PATH"] = project_root_path + # Perform regular unittest execution. + # Pass project_root_path so the payload's cwd matches the project root. payload = run_tests( start_dir, test_ids, @@ -363,6 +384,7 @@ def send_run_data(raw_data, test_run_pipe): verbosity, failfast, locals_, + project_root_path=project_root_path, ) if is_coverage_run: diff --git a/python_files/vscode_pytest/__init__.py b/python_files/vscode_pytest/__init__.py index 91a81bff9a36..be4e3daaa843 100644 --- a/python_files/vscode_pytest/__init__.py +++ b/python_files/vscode_pytest/__init__.py @@ -22,10 +22,10 @@ ) import pytest -from typing_extensions import NotRequired if TYPE_CHECKING: from pluggy import Result + from typing_extensions import NotRequired USES_PYTEST_DESCRIBE = False @@ -75,14 +75,38 @@ def __init__(self, message): ERRORS = [] IS_DISCOVERY = False map_id_to_path = {} -collected_tests_so_far = [] +collected_tests_so_far = set() TEST_RUN_PIPE = os.getenv("TEST_RUN_PIPE") +PROJECT_ROOT_PATH = os.getenv( + "PROJECT_ROOT_PATH" +) # Path to project root for multi-project workspaces SYMLINK_PATH = None INCLUDE_BRANCHES = False +# Performance optimization caches for path resolution +_path_cache: dict[int, pathlib.Path] = {} # Cache node paths by object id +_path_to_str_cache: dict[pathlib.Path, str] = {} # Cache path-to-string conversions +_CACHED_CWD: pathlib.Path | None = None + + +def get_test_root_path() -> pathlib.Path: + """Get the root path for the test tree. + + For project-based testing, this returns PROJECT_ROOT_PATH (the project root). + For legacy mode, this returns the current working directory. + + Returns: + pathlib.Path: The root path to use for the test tree. + """ + if PROJECT_ROOT_PATH: + return pathlib.Path(PROJECT_ROOT_PATH) + return pathlib.Path.cwd() + def pytest_load_initial_conftests(early_config, parser, args): # noqa: ARG001 - has_pytest_cov = early_config.pluginmanager.hasplugin("pytest_cov") + has_pytest_cov = early_config.pluginmanager.hasplugin( + "pytest_cov" + ) or early_config.pluginmanager.hasplugin("pytest_cov.plugin") has_cov_arg = any("--cov" in arg for arg in args) if has_cov_arg and not has_pytest_cov: raise VSCodePytestError( @@ -170,7 +194,7 @@ def pytest_exception_interact(node, call, report): report_value = "failure" node_id = get_absolute_test_id(node.nodeid, get_node_path(node)) if node_id not in collected_tests_so_far: - collected_tests_so_far.append(node_id) + collected_tests_so_far.add(node_id) item_result = create_test_outcome( node_id, report_value, @@ -183,7 +207,7 @@ def pytest_exception_interact(node, call, report): send_execution_message( os.fsdecode(cwd), "success", - collected_test if collected_test else None, + collected_test or None, ) @@ -295,7 +319,7 @@ def pytest_report_teststatus(report, config): # noqa: ARG001 # Calculate the absolute test id and use this as the ID moving forward. absolute_node_id = get_absolute_test_id(report.nodeid, node_path) if absolute_node_id not in collected_tests_so_far: - collected_tests_so_far.append(absolute_node_id) + collected_tests_so_far.add(absolute_node_id) item_result = create_test_outcome( absolute_node_id, report_value, @@ -307,7 +331,7 @@ def pytest_report_teststatus(report, config): # noqa: ARG001 send_execution_message( os.fsdecode(cwd), "success", - collected_test if collected_test else None, + collected_test or None, ) yield @@ -329,7 +353,7 @@ def pytest_runtest_protocol(item, nextitem): # noqa: ARG001 report_value = "skipped" cwd = pathlib.Path.cwd() if absolute_node_id not in collected_tests_so_far: - collected_tests_so_far.append(absolute_node_id) + collected_tests_so_far.add(absolute_node_id) item_result = create_test_outcome( absolute_node_id, report_value, @@ -341,7 +365,7 @@ def pytest_runtest_protocol(item, nextitem): # noqa: ARG001 send_execution_message( os.fsdecode(cwd), "success", - collected_test if collected_test else None, + collected_test or None, ) yield @@ -402,21 +426,23 @@ def pytest_sessionfinish(session, exitstatus): Exit code 4: pytest command line usage error Exit code 5: No tests were collected """ - cwd = pathlib.Path.cwd() + # Get the root path for the test tree structure (not the CWD for test execution) + # This is PROJECT_ROOT_PATH in project-based mode, or cwd in legacy mode + test_root_path = get_test_root_path() if SYMLINK_PATH: - print("Plugin warning[vscode-pytest]: SYMLINK set, adjusting cwd.") - cwd = pathlib.Path(SYMLINK_PATH) + print("Plugin warning[vscode-pytest]: SYMLINK set, adjusting test root path.") + test_root_path = pathlib.Path(SYMLINK_PATH) if IS_DISCOVERY: if not (exitstatus == 0 or exitstatus == 1 or exitstatus == 5): error_node: TestNode = { "name": "", - "path": cwd, + "path": test_root_path, "type_": "error", "children": [], "id_": "", } - send_discovery_message(os.fsdecode(cwd), error_node) + send_discovery_message(os.fsdecode(test_root_path), error_node) try: session_node: TestNode | None = build_test_tree(session) if not session_node: @@ -424,19 +450,19 @@ def pytest_sessionfinish(session, exitstatus): "Something went wrong following pytest finish, \ no session node was created" ) - send_discovery_message(os.fsdecode(cwd), session_node) + send_discovery_message(os.fsdecode(test_root_path), session_node) except Exception as e: ERRORS.append( f"Error Occurred, traceback: {(traceback.format_exc() if e.__traceback__ else '')}" ) error_node: TestNode = { "name": "", - "path": cwd, + "path": test_root_path, "type_": "error", "children": [], "id_": "", } - send_discovery_message(os.fsdecode(cwd), error_node) + send_discovery_message(os.fsdecode(test_root_path), error_node) else: if exitstatus == 0 or exitstatus == 1: exitstatus_bool = "success" @@ -447,7 +473,7 @@ def pytest_sessionfinish(session, exitstatus): exitstatus_bool = "error" send_execution_message( - os.fsdecode(cwd), + os.fsdecode(test_root_path), exitstatus_bool, None, ) @@ -533,7 +559,7 @@ def pytest_sessionfinish(session, exitstatus): payload: CoveragePayloadDict = CoveragePayloadDict( coverage=True, - cwd=os.fspath(cwd), + cwd=os.fspath(test_root_path), result=file_coverage_map, error=None, ) @@ -619,11 +645,10 @@ def process_parameterized_test( class_and_method = second_split[1] + "::" # This has "::" separator at both ends # construct the parent id, so it is absolute path :: any class and method :: parent_part - parent_id = os.fspath(get_node_path(test_case)) + class_and_method + parent_part + parent_id = cached_fsdecode(get_node_path(test_case)) + class_and_method + parent_part try: function_name = test_case.originalname # type: ignore - function_test_node = function_nodes_dict[parent_id] except AttributeError: # actual error has occurred ERRORS.append( f"unable to find original name for {test_case.name} with parameterization detected." @@ -631,8 +656,10 @@ def process_parameterized_test( raise VSCodePytestError( "Unable to find original name for parameterized test case" ) from None - except KeyError: - function_test_node: TestNode = create_parameterized_function_node( + + function_test_node = function_nodes_dict.get(parent_id) + if function_test_node is None: + function_test_node = create_parameterized_function_node( function_name, get_node_path(test_case), parent_id ) function_nodes_dict[parent_id] = function_test_node @@ -644,11 +671,11 @@ def process_parameterized_test( if isinstance(test_case.parent, pytest.File): # calculate the parent path of the test case parent_path = get_node_path(test_case.parent) - try: - parent_test_case = file_nodes_dict[os.fspath(parent_path)] - except KeyError: + parent_path_key = cached_fsdecode(parent_path) + parent_test_case = file_nodes_dict.get(parent_path_key) + if parent_test_case is None: parent_test_case = create_file_node(parent_path) - file_nodes_dict[os.fspath(parent_path)] = parent_test_case + file_nodes_dict[parent_path_key] = parent_test_case if function_test_node not in parent_test_case["children"]: parent_test_case["children"].append(function_test_node) @@ -693,9 +720,8 @@ def build_test_tree(session: pytest.Session) -> TestNode: USES_PYTEST_DESCRIBE and isinstance(case_iter, DescribeBlock) ): # While the given node is a class, create a class and nest the previous node as a child. - try: - test_class_node = class_nodes_dict[case_iter.nodeid] - except KeyError: + test_class_node = class_nodes_dict.get(case_iter.nodeid) + if test_class_node is None: test_class_node = create_class_node(case_iter) class_nodes_dict[case_iter.nodeid] = test_class_node # Check if the class already has the child node. This will occur if the test is parameterized. @@ -712,11 +738,11 @@ def build_test_tree(session: pytest.Session) -> TestNode: break parent_path = get_node_path(parent_module) # Create a file node that has the last class as a child. - try: - test_file_node: TestNode = file_nodes_dict[os.fspath(parent_path)] - except KeyError: + parent_path_key = cached_fsdecode(parent_path) + test_file_node = file_nodes_dict.get(parent_path_key) + if test_file_node is None: test_file_node = create_file_node(parent_path) - file_nodes_dict[os.fspath(parent_path)] = test_file_node + file_nodes_dict[parent_path_key] = test_file_node # Check if the class is already a child of the file node. if test_class_node is not None and test_class_node not in test_file_node["children"]: test_file_node["children"].append(test_class_node) @@ -731,11 +757,11 @@ def build_test_tree(session: pytest.Session) -> TestNode: test_case.parent, ) ) - try: - parent_test_case = file_nodes_dict[os.fspath(parent_path)] - except KeyError: + parent_path_key = cached_fsdecode(parent_path) + parent_test_case = file_nodes_dict.get(parent_path_key) + if parent_test_case is None: parent_test_case = create_file_node(parent_path) - file_nodes_dict[os.fspath(parent_path)] = parent_test_case + file_nodes_dict[parent_path_key] = parent_test_case parent_test_case["children"].append(test_node) # Process all files and construct them into nested folders session_children_dict = construct_nested_folders( @@ -776,11 +802,11 @@ def build_nested_folders( max_iter = 100 while iterator_path != session_node_path: curr_folder_name = iterator_path.name - try: - curr_folder_node: TestNode = created_files_folders_dict[os.fspath(iterator_path)] - except KeyError: - curr_folder_node: TestNode = create_folder_node(curr_folder_name, iterator_path) - created_files_folders_dict[os.fspath(iterator_path)] = curr_folder_node + iterator_path_key = cached_fsdecode(iterator_path) + curr_folder_node = created_files_folders_dict.get(iterator_path_key) + if curr_folder_node is None: + curr_folder_node = create_folder_node(curr_folder_name, iterator_path) + created_files_folders_dict[iterator_path_key] = curr_folder_node if prev_folder_node not in curr_folder_node["children"]: curr_folder_node["children"].append(prev_folder_node) iterator_path = iterator_path.parent @@ -825,7 +851,8 @@ def create_session_node(session: pytest.Session) -> TestNode: Keyword arguments: session -- the pytest session. """ - node_path = get_node_path(session) + # Use PROJECT_ROOT_PATH if set (project-based testing), otherwise use session path (legacy) + node_path = pathlib.Path(PROJECT_ROOT_PATH) if PROJECT_ROOT_PATH else get_node_path(session) return { "name": node_path.name, "path": node_path, @@ -942,6 +969,23 @@ class CoveragePayloadDict(Dict): error: str | None # Currently unused need to check +def cached_fsdecode(path: pathlib.Path) -> str: + """Convert path to string with caching for performance. + + This function caches path-to-string conversions to avoid redundant + os.fsdecode() calls during test tree building. + + Parameters: + path: The pathlib.Path object to convert to string. + + Returns: + str: The string representation of the path. + """ + if path not in _path_to_str_cache: + _path_to_str_cache[path] = os.fspath(path) + return _path_to_str_cache[path] + + def get_node_path( node: pytest.Session | pytest.Item @@ -961,6 +1005,10 @@ def get_node_path( Returns: pathlib.Path: The resolved path for the node. """ + cache_key = id(node) + if cache_key in _path_cache: + return _path_cache[cache_key] + node_path = getattr(node, "path", None) if node_path is None: fspath = getattr(node, "fspath", None) @@ -982,19 +1030,28 @@ def get_node_path( common_path = os.path.commonpath([symlink_str, node_path_str]) if common_path == os.fsdecode(SYMLINK_PATH): # The node path is already relative to the SYMLINK_PATH root therefore return - return node_path + result = node_path else: # If the node path is not a symlink, then we need to calculate the equivalent symlink path # get the relative path between the cwd and the node path (as the node path is not a symlink). - rel_path = node_path.relative_to(pathlib.Path.cwd()) + # Use cached cwd to avoid repeated system calls + global _CACHED_CWD + if _CACHED_CWD is None: + _CACHED_CWD = pathlib.Path.cwd() + rel_path = node_path.relative_to(_CACHED_CWD) # combine the difference between the cwd and the node path with the symlink path - return pathlib.Path(SYMLINK_PATH, rel_path) + result = pathlib.Path(SYMLINK_PATH, rel_path) except Exception as e: raise VSCodePytestError( f"Error occurred while calculating symlink equivalent from node path: {e}" - f"\n SYMLINK_PATH: {SYMLINK_PATH}, \n node path: {node_path}, \n cwd: {pathlib.Path.cwd()}" + f"\n SYMLINK_PATH: {SYMLINK_PATH}, \n node path: {node_path}, \n cwd: {_CACHED_CWD or pathlib.Path.cwd()}" ) from e - return node_path + else: + result = node_path + + # Cache before returning + _path_cache[cache_key] = result + return result __writer = None diff --git a/python_files/vscode_pytest/run_pytest_script.py b/python_files/vscode_pytest/run_pytest_script.py index 8d30ba7e4399..50ab12a35423 100644 --- a/python_files/vscode_pytest/run_pytest_script.py +++ b/python_files/vscode_pytest/run_pytest_script.py @@ -55,12 +55,13 @@ def run_pytest(args): try: # Read the test ids from the file and run pytest. ids = ids_path.read_text(encoding="utf-8").splitlines() - arg_array = ["-p", "vscode_pytest", *args, *ids] - print("Running pytest with args: " + str(arg_array)) - pytest.main(arg_array) except Exception as e: print("Error[vscode-pytest]: unable to read testIds from temp file" + str(e)) run_pytest(args) + else: + arg_array = ["-p", "vscode_pytest", *args, *ids] + print("Running pytest with args: " + str(arg_array)) + pytest.main(arg_array) finally: # Delete the test ids temp file. try: diff --git a/requirements.txt b/requirements.txt index 1e5f673f43db..540590ed2ae7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,60 +1,65 @@ # This file was autogenerated by uv via the following command: # uv pip compile --generate-hashes requirements.in -o requirements.txt -importlib-metadata==8.7.0 \ - --hash=sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000 \ - --hash=sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd +importlib-metadata==9.0.0 \ + --hash=sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7 \ + --hash=sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc # via -r requirements.in microvenv==2025.0 \ --hash=sha256:568155ec18af01c89f270d35d123ab803b09672b480c3702d15fd69e9cc5bd1e \ --hash=sha256:8a2568a8390a4ffb5af2f05e7642454e03b887e582d192b6316326974eab5d0f # via -r requirements.in -packaging==25.0 \ - --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \ - --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f +packaging==26.2 \ + --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ + --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 # via -r requirements.in -tomli==2.3.0 \ - --hash=sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456 \ - --hash=sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845 \ - --hash=sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999 \ - --hash=sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0 \ - --hash=sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878 \ - --hash=sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf \ - --hash=sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3 \ - --hash=sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be \ - --hash=sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52 \ - --hash=sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b \ - --hash=sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67 \ - --hash=sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549 \ - --hash=sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba \ - --hash=sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22 \ - --hash=sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c \ - --hash=sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f \ - --hash=sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6 \ - --hash=sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba \ - --hash=sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45 \ - --hash=sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f \ - --hash=sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77 \ - --hash=sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606 \ - --hash=sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441 \ - --hash=sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0 \ - --hash=sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f \ - --hash=sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530 \ - --hash=sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05 \ - --hash=sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8 \ - --hash=sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005 \ - --hash=sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879 \ - --hash=sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae \ - --hash=sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc \ - --hash=sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b \ - --hash=sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b \ - --hash=sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e \ - --hash=sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf \ - --hash=sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac \ - --hash=sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8 \ - --hash=sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b \ - --hash=sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf \ - --hash=sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463 \ - --hash=sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876 +tomli==2.4.1 \ + --hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \ + --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \ + --hash=sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5 \ + --hash=sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d \ + --hash=sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd \ + --hash=sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26 \ + --hash=sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54 \ + --hash=sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6 \ + --hash=sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c \ + --hash=sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a \ + --hash=sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd \ + --hash=sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f \ + --hash=sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5 \ + --hash=sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9 \ + --hash=sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662 \ + --hash=sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9 \ + --hash=sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1 \ + --hash=sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585 \ + --hash=sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e \ + --hash=sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c \ + --hash=sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41 \ + --hash=sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f \ + --hash=sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085 \ + --hash=sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15 \ + --hash=sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7 \ + --hash=sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c \ + --hash=sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36 \ + --hash=sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076 \ + --hash=sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac \ + --hash=sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8 \ + --hash=sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232 \ + --hash=sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece \ + --hash=sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a \ + --hash=sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897 \ + --hash=sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d \ + --hash=sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4 \ + --hash=sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917 \ + --hash=sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396 \ + --hash=sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a \ + --hash=sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc \ + --hash=sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba \ + --hash=sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f \ + --hash=sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257 \ + --hash=sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30 \ + --hash=sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf \ + --hash=sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9 \ + --hash=sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049 # via -r requirements.in typing-extensions==4.15.0 \ --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ diff --git a/src/client/application/diagnostics/checks/pythonInterpreter.ts b/src/client/application/diagnostics/checks/pythonInterpreter.ts index 31da53e75357..9167e232a417 100644 --- a/src/client/application/diagnostics/checks/pythonInterpreter.ts +++ b/src/client/application/diagnostics/checks/pythonInterpreter.ts @@ -20,7 +20,7 @@ import { IDiagnosticHandlerService, IDiagnosticMessageOnCloseHandler, } from '../types'; -import { Common } from '../../../common/utils/localize'; +import { Common, Interpreters } from '../../../common/utils/localize'; import { Commands } from '../../../common/constants'; import { ICommandManager, IWorkspaceService } from '../../../common/application/types'; import { sendTelemetryEvent } from '../../../telemetry'; @@ -30,11 +30,12 @@ import { cache } from '../../../common/utils/decorators'; import { noop } from '../../../common/utils/misc'; import { getEnvironmentVariable, getOSType, OSType } from '../../../common/utils/platform'; import { IFileSystem } from '../../../common/platform/types'; -import { traceError } from '../../../logging'; +import { traceError, traceWarn } from '../../../logging'; import { getExecutable } from '../../../common/process/internal/python'; import { getSearchPathEnvVarNames } from '../../../common/utils/exec'; import { IProcessServiceFactory } from '../../../common/process/types'; import { normCasePath } from '../../../common/platform/fs-paths'; +import { useEnvExtension } from '../../../envExt/api.internal'; const messages = { [DiagnosticCodes.NoPythonInterpretersDiagnostic]: l10n.t( @@ -144,6 +145,9 @@ export class InvalidPythonInterpreterService extends BaseDiagnosticsService const isInterpreterSetToDefault = interpreterPathService.get(resource) === 'python'; if (!hasInterpreters && isInterpreterSetToDefault) { + if (useEnvExtension()) { + traceWarn(Interpreters.envExtDiscoveryNoEnvironments); + } return [ new InvalidPythonInterpreterDiagnostic( DiagnosticCodes.NoPythonInterpretersDiagnostic, @@ -156,6 +160,9 @@ export class InvalidPythonInterpreterService extends BaseDiagnosticsService const currentInterpreter = await interpreterService.getActiveInterpreter(resource); if (!currentInterpreter) { + if (useEnvExtension()) { + traceWarn(Interpreters.envExtNoActiveEnvironment); + } return [ new InvalidPythonInterpreterDiagnostic( DiagnosticCodes.InvalidPythonInterpreterDiagnostic, diff --git a/src/client/chat/baseTool.ts b/src/client/chat/baseTool.ts index 2eedbbe226e3..d8e2e1d60d42 100644 --- a/src/client/chat/baseTool.ts +++ b/src/client/chat/baseTool.ts @@ -16,8 +16,10 @@ import { IResourceReference, isCancellationError, resolveFilePath } from './util import { ErrorWithTelemetrySafeReason } from '../common/errors/errorUtils'; import { sendTelemetryEvent } from '../telemetry'; import { EventName } from '../telemetry/constants'; +import { StopWatch } from '../common/utils/stopWatch'; export abstract class BaseTool implements LanguageModelTool { + protected extraTelemetryProperties: Record = {}; constructor(private readonly toolName: string) {} async invoke( @@ -29,8 +31,10 @@ export abstract class BaseTool implements Language new LanguageModelTextPart('Cannot use this tool in an untrusted workspace.'), ]); } + this.extraTelemetryProperties = {}; let error: Error | undefined; const resource = resolveFilePath(options.input.resourcePath); + const stopWatch = new StopWatch(); try { return await this.invokeImpl(options, resource, token); } catch (ex) { @@ -46,10 +50,11 @@ export abstract class BaseTool implements Language ? error.telemetrySafeReason : 'error' : undefined; - sendTelemetryEvent(EventName.INVOKE_TOOL, undefined, { + sendTelemetryEvent(EventName.INVOKE_TOOL, stopWatch.elapsedTime, { toolName: this.toolName, failed, failureCategory, + ...this.extraTelemetryProperties, }); } } diff --git a/src/client/chat/configurePythonEnvTool.ts b/src/client/chat/configurePythonEnvTool.ts index 0634b9c9ac34..914a92f81c52 100644 --- a/src/client/chat/configurePythonEnvTool.ts +++ b/src/client/chat/configurePythonEnvTool.ts @@ -18,6 +18,7 @@ import { ICodeExecutionService } from '../terminals/types'; import { TerminalCodeExecutionProvider } from '../terminals/codeExecution/terminalCodeExecution'; import { getEnvDetailsForResponse, + getEnvTypeForTelemetry, getToolResponseIfNotebook, IResourceReference, isCancellationError, @@ -58,6 +59,7 @@ export class ConfigurePythonEnvTool extends BaseTool ): Promise { const notebookResponse = getToolResponseIfNotebook(resource); if (notebookResponse) { + this.extraTelemetryProperties.resolveOutcome = 'notebook'; return notebookResponse; } @@ -67,6 +69,8 @@ export class ConfigurePythonEnvTool extends BaseTool ); if (workspaceSpecificEnv) { + this.extraTelemetryProperties.resolveOutcome = 'existingWorkspaceEnv'; + this.extraTelemetryProperties.envType = getEnvTypeForTelemetry(workspaceSpecificEnv); return getEnvDetailsForResponse( workspaceSpecificEnv, this.api, @@ -79,7 +83,9 @@ export class ConfigurePythonEnvTool extends BaseTool if (await this.createEnvTool.shouldCreateNewVirtualEnv(resource, token)) { try { - return await lm.invokeTool(CreateVirtualEnvTool.toolName, options, token); + const result = await lm.invokeTool(CreateVirtualEnvTool.toolName, options, token); + this.extraTelemetryProperties.resolveOutcome = 'createdVirtualEnv'; + return result; } catch (ex) { if (isCancellationError(ex)) { const input: ISelectPythonEnvToolArguments = { @@ -87,6 +93,7 @@ export class ConfigurePythonEnvTool extends BaseTool reason: 'cancelled', }; // If the user cancelled the tool, then we should invoke the select env tool. + this.extraTelemetryProperties.resolveOutcome = 'selectedEnvAfterCancelledCreate'; return lm.invokeTool(SelectPythonEnvTool.toolName, { ...options, input }, token); } throw ex; @@ -95,6 +102,7 @@ export class ConfigurePythonEnvTool extends BaseTool const input: ISelectPythonEnvToolArguments = { ...options.input, }; + this.extraTelemetryProperties.resolveOutcome = 'selectedEnv'; return lm.invokeTool(SelectPythonEnvTool.toolName, { ...options, input }, token); } } diff --git a/src/client/chat/getExecutableTool.ts b/src/client/chat/getExecutableTool.ts index 746a540d14f8..38dabce644a7 100644 --- a/src/client/chat/getExecutableTool.ts +++ b/src/client/chat/getExecutableTool.ts @@ -19,6 +19,7 @@ import { TerminalCodeExecutionProvider } from '../terminals/codeExecution/termin import { getEnvDisplayName, getEnvironmentDetails, + getEnvTypeForTelemetry, getToolResponseIfNotebook, IResourceReference, raceCancellationError, @@ -53,6 +54,12 @@ export class GetExecutableTool extends BaseTool implements L return notebookResponse; } + const envPath = this.api.getActiveEnvironmentPath(resourcePath); + const environment = await raceCancellationError(this.api.resolveEnvironment(envPath), token); + if (environment) { + this.extraTelemetryProperties.envType = getEnvTypeForTelemetry(environment); + } + const message = await getEnvironmentDetails( resourcePath, this.api, diff --git a/src/client/chat/getPythonEnvTool.ts b/src/client/chat/getPythonEnvTool.ts index ed1dd0374424..d25d72baeba8 100644 --- a/src/client/chat/getPythonEnvTool.ts +++ b/src/client/chat/getPythonEnvTool.ts @@ -17,7 +17,13 @@ import { IServiceContainer } from '../ioc/types'; import { ICodeExecutionService } from '../terminals/types'; import { TerminalCodeExecutionProvider } from '../terminals/codeExecution/terminalCodeExecution'; import { IProcessServiceFactory, IPythonExecutionFactory } from '../common/process/types'; -import { getEnvironmentDetails, getToolResponseIfNotebook, IResourceReference, raceCancellationError } from './utils'; +import { + getEnvironmentDetails, + getEnvTypeForTelemetry, + getToolResponseIfNotebook, + IResourceReference, + raceCancellationError, +} from './utils'; import { getPythonPackagesResponse } from './listPackagesTool'; import { ITerminalHelper } from '../common/terminal/types'; import { getEnvExtApi, useEnvExtension } from '../envExt/api.internal'; @@ -64,13 +70,16 @@ export class GetEnvironmentInfoTool extends BaseTool 'noEnvFound', ); } + this.extraTelemetryProperties.envType = getEnvTypeForTelemetry(environment); let packages = ''; + let responsePackageCount = 0; if (useEnvExtension()) { const api = await getEnvExtApi(); const env = await api.getEnvironment(resourcePath); const pkgs = env ? await api.getPackages(env) : []; if (pkgs && pkgs.length > 0) { + responsePackageCount = pkgs.length; // Installed Python packages, each in the format or (). The version may be omitted if unknown. Returns an empty array if no packages are installed. const response = [ 'Below is a list of the Python packages, each in the format or (). The version may be omitted if unknown: ', @@ -90,7 +99,10 @@ export class GetEnvironmentInfoTool extends BaseTool resourcePath, token, ); + // Count lines starting with '- ' to get the number of packages + responsePackageCount = (packages.match(/^- /gm) || []).length; } + this.extraTelemetryProperties.responsePackageCount = String(responsePackageCount); const message = await getEnvironmentDetails( resourcePath, this.api, diff --git a/src/client/chat/installPackagesTool.ts b/src/client/chat/installPackagesTool.ts index f7795620cf13..5d3d456361f9 100644 --- a/src/client/chat/installPackagesTool.ts +++ b/src/client/chat/installPackagesTool.ts @@ -16,6 +16,7 @@ import { PythonExtension } from '../api/types'; import { IServiceContainer } from '../ioc/types'; import { getEnvDisplayName, + getEnvTypeForTelemetry, getToolResponseIfNotebook, IResourceReference, isCancellationError, @@ -51,6 +52,7 @@ export class InstallPackagesTool extends BaseTool ): Promise { const packageCount = options.input.packageList.length; const packagePlurality = packageCount === 1 ? 'package' : 'packages'; + this.extraTelemetryProperties.packageCount = String(packageCount); const notebookResponse = getToolResponseIfNotebook(resourcePath); if (notebookResponse) { return notebookResponse; @@ -84,9 +86,11 @@ export class InstallPackagesTool extends BaseTool 'noEnvFound', ); } + this.extraTelemetryProperties.envType = getEnvTypeForTelemetry(environment); const isConda = isCondaEnv(environment); const installers = this.serviceContainer.getAll(IModuleInstaller); const installerType = isConda ? ModuleInstallerType.Conda : ModuleInstallerType.Pip; + this.extraTelemetryProperties.installerType = isConda ? 'conda' : 'pip'; const installer = installers.find((i) => i.type === installerType); if (!installer) { throw new ErrorWithTelemetrySafeReason( diff --git a/src/client/chat/utils.ts b/src/client/chat/utils.ts index bddd26049668..2309316bcbdd 100644 --- a/src/client/chat/utils.ts +++ b/src/client/chat/utils.ts @@ -19,6 +19,7 @@ import { JUPYTER_EXTENSION_ID, NotebookCellScheme } from '../common/constants'; import { dirname, join } from 'path'; import { resolveEnvironment, useEnvExtension } from '../envExt/api.internal'; import { ErrorWithTelemetrySafeReason } from '../common/errors/errorUtils'; +import { getWorkspaceFolders } from '../common/vscodeApis/workspaceApis'; export interface IResourceReference { resourcePath?: string; @@ -26,14 +27,21 @@ export interface IResourceReference { export function resolveFilePath(filepath?: string): Uri | undefined { if (!filepath) { - return workspace.workspaceFolders ? workspace.workspaceFolders[0].uri : undefined; + const folders = getWorkspaceFolders() ?? []; + return folders.length > 0 ? folders[0].uri : undefined; } - // starts with a scheme - try { - return Uri.parse(filepath); - } catch (e) { - return Uri.file(filepath); + // Check if it's a URI with a scheme (contains "://") + // This handles schemes like "file://", "vscode-notebook://", etc. + // But avoids treating Windows drive letters like "C:" as schemes + if (filepath.includes('://')) { + try { + return Uri.parse(filepath); + } catch { + return Uri.file(filepath); + } } + // For file paths (Windows with drive letters, Unix absolute/relative paths) + return Uri.file(filepath); } /** @@ -68,6 +76,10 @@ export function isCondaEnv(env: ResolvedEnvironment) { return (env.environment?.type || '').toLowerCase() === 'conda'; } +export function getEnvTypeForTelemetry(env: ResolvedEnvironment): string { + return (env.environment?.type || 'unknown').toLowerCase(); +} + export async function getEnvironmentDetails( resourcePath: Uri | undefined, api: PythonExtension['environments'], diff --git a/src/client/common/application/commands.ts b/src/client/common/application/commands.ts index 402025ee38db..b43dc0a1e4a4 100644 --- a/src/client/common/application/commands.ts +++ b/src/client/common/application/commands.ts @@ -87,7 +87,7 @@ export interface ICommandNameArgumentTypeMapping extends ICommandNameWithoutArgu ['jupyter.opennotebook']: [undefined | Uri, undefined | CommandSource]; ['jupyter.runallcells']: [Uri]; ['extension.open']: [string]; - ['workbench.action.openIssueReporter']: [{ extensionId: string; issueBody: string }]; + ['workbench.action.openIssueReporter']: [{ extensionId: string; issueBody: string; extensionData?: string }]; [Commands.GetSelectedInterpreterPath]: [{ workspaceFolder: string } | string[]]; [Commands.TriggerEnvironmentSelection]: [undefined | Uri]; [Commands.Start_Native_REPL]: [undefined | Uri]; diff --git a/src/client/common/application/commands/reportIssueCommand.ts b/src/client/common/application/commands/reportIssueCommand.ts index e5633f4a4389..9ae099e44b4f 100644 --- a/src/client/common/application/commands/reportIssueCommand.ts +++ b/src/client/common/application/commands/reportIssueCommand.ts @@ -121,7 +121,7 @@ export class ReportIssueCommandHandler implements IExtensionSingleActivationServ await this.commandManager.executeCommand('workbench.action.openIssueReporter', { extensionId: 'ms-python.python', issueBody: template, - data: userTemplate.format( + extensionData: userTemplate.format( pythonVersion, virtualEnvKind, languageServer, diff --git a/src/client/common/application/terminalManager.ts b/src/client/common/application/terminalManager.ts index 9d0536e85243..dc2603e84a56 100644 --- a/src/client/common/application/terminalManager.ts +++ b/src/client/common/application/terminalManager.ts @@ -38,6 +38,9 @@ export class TerminalManager implements ITerminalManager { public onDidEndTerminalShellExecution(handler: (e: TerminalShellExecutionEndEvent) => void): Disposable { return window.onDidEndTerminalShellExecution(handler); } + public onDidChangeTerminalState(handler: (e: Terminal) => void): Disposable { + return window.onDidChangeTerminalState(handler); + } } /** diff --git a/src/client/common/application/types.ts b/src/client/common/application/types.ts index 65a8833a691c..34a95fb604f0 100644 --- a/src/client/common/application/types.ts +++ b/src/client/common/application/types.ts @@ -939,6 +939,8 @@ export interface ITerminalManager { onDidChangeTerminalShellIntegration(handler: (e: TerminalShellIntegrationChangeEvent) => void): Disposable; onDidEndTerminalShellExecution(handler: (e: TerminalShellExecutionEndEvent) => void): Disposable; + + onDidChangeTerminalState(handler: (e: Terminal) => void): Disposable; } export const IDebugService = Symbol('IDebugManager'); diff --git a/src/client/common/process/rawProcessApis.ts b/src/client/common/process/rawProcessApis.ts index 5e3641328b69..864191851c91 100644 --- a/src/client/common/process/rawProcessApis.ts +++ b/src/client/common/process/rawProcessApis.ts @@ -58,7 +58,8 @@ export function shellExec( const shellOptions = getDefaultOptions(options, defaultEnv); if (!options.doNotLog) { const processLogger = new ProcessLogger(new WorkspaceService()); - processLogger.logProcess(command, undefined, shellOptions); + const loggingOptions = { ...shellOptions, encoding: shellOptions.encoding ?? undefined }; + processLogger.logProcess(command, undefined, loggingOptions); } return new Promise((resolve, reject) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/client/common/terminal/activator/index.ts b/src/client/common/terminal/activator/index.ts index 24ffb5008364..cde04bdbf10d 100644 --- a/src/client/common/terminal/activator/index.ts +++ b/src/client/common/terminal/activator/index.ts @@ -9,7 +9,7 @@ import { IConfigurationService, IExperimentService } from '../../types'; import { ITerminalActivationHandler, ITerminalActivator, ITerminalHelper, TerminalActivationOptions } from '../types'; import { BaseTerminalActivator } from './base'; import { inTerminalEnvVarExperiment } from '../../experiments/helpers'; -import { useEnvExtension } from '../../../envExt/api.internal'; +import { shouldEnvExtHandleActivation } from '../../../envExt/api.internal'; import { EventName } from '../../../telemetry/constants'; import { sendTelemetryEvent } from '../../../telemetry'; @@ -44,8 +44,8 @@ export class TerminalActivator implements ITerminalActivator { const settings = this.configurationService.getSettings(options?.resource); const activateEnvironment = settings.terminal.activateEnvironment && !inTerminalEnvVarExperiment(this.experimentService); - if (!activateEnvironment || options?.hideFromUser || useEnvExtension()) { - if (useEnvExtension()) { + if (!activateEnvironment || options?.hideFromUser || shouldEnvExtHandleActivation()) { + if (shouldEnvExtHandleActivation()) { sendTelemetryEvent(EventName.PYTHON_INTERPRETER_ACTIVATION_FOR_TERMINAL); } return false; diff --git a/src/client/common/terminal/service.ts b/src/client/common/terminal/service.ts index e92fbd3d494f..0dffd5615ae1 100644 --- a/src/client/common/terminal/service.ts +++ b/src/client/common/terminal/service.ts @@ -9,7 +9,7 @@ import { IServiceContainer } from '../../ioc/types'; import { captureTelemetry } from '../../telemetry'; import { EventName } from '../../telemetry/constants'; import { ITerminalAutoActivation } from '../../terminals/types'; -import { ITerminalManager } from '../application/types'; +import { IApplicationShell, ITerminalManager } from '../application/types'; import { _SCRIPTS_DIR } from '../process/internal/scripts/constants'; import { IConfigurationService, IDisposableRegistry } from '../types'; import { @@ -20,12 +20,9 @@ import { TerminalShellType, } from './types'; import { traceVerbose } from '../../logging'; -import { getConfiguration } from '../vscodeApis/workspaceApis'; +import { sleep } from '../utils/async'; import { useEnvExtension } from '../../envExt/api.internal'; import { ensureTerminalLegacy } from '../../envExt/api.legacy'; -import { sleep } from '../utils/async'; -import { isWindows } from '../utils/platform'; -import { getPythonMinorVersion } from '../../repl/replUtils'; @injectable() export class TerminalService implements ITerminalService, Disposable { @@ -36,8 +33,13 @@ export class TerminalService implements ITerminalService, Disposable { private terminalHelper: ITerminalHelper; private terminalActivator: ITerminalActivator; private terminalAutoActivator: ITerminalAutoActivation; + private applicationShell: IApplicationShell; private readonly executeCommandListeners: Set = new Set(); private _terminalFirstLaunched: boolean = true; + private pythonReplCommandQueue: string[] = []; + private isReplReady: boolean = false; + private replPromptListener?: Disposable; + private replShellTypeListener?: Disposable; public get onDidCloseTerminal(): Event { return this.terminalClosed.event.bind(this.terminalClosed); } @@ -51,11 +53,13 @@ export class TerminalService implements ITerminalService, Disposable { this.terminalHelper = this.serviceContainer.get(ITerminalHelper); this.terminalManager = this.serviceContainer.get(ITerminalManager); this.terminalAutoActivator = this.serviceContainer.get(ITerminalAutoActivation); + this.applicationShell = this.serviceContainer.get(IApplicationShell); this.terminalManager.onDidCloseTerminal(this.terminalCloseHandler, this, disposableRegistry); this.terminalActivator = this.serviceContainer.get(ITerminalActivator); } public dispose() { this.terminal?.dispose(); + this.disposeReplListener(); if (this.executeCommandListeners && this.executeCommandListeners.size > 0) { this.executeCommandListeners.forEach((d) => { @@ -84,7 +88,86 @@ export class TerminalService implements ITerminalService, Disposable { commandLine: string, isPythonShell: boolean, ): Promise { - const terminal = this.terminal!; + if (isPythonShell) { + if (this.isReplReady) { + this.terminal?.sendText(commandLine); + traceVerbose(`Python REPL sendText: ${commandLine}`); + } else { + // Queue command to run once REPL is ready. + this.pythonReplCommandQueue.push(commandLine); + traceVerbose(`Python REPL queued command: ${commandLine}`); + this.startReplListener(); + } + return undefined; + } + + // Non-REPL code execution + return this.executeCommandInternal(commandLine); + } + + private startReplListener(): void { + if (this.replPromptListener || this.replShellTypeListener) { + return; + } + + this.replShellTypeListener = this.terminalManager.onDidChangeTerminalState((terminal) => { + if (this.terminal && terminal === this.terminal) { + if (terminal.state.shell == 'python') { + traceVerbose('Python REPL ready from terminal shell api'); + this.onReplReady(); + } + } + }); + + let terminalData = ''; + this.replPromptListener = this.applicationShell.onDidWriteTerminalData((e) => { + if (this.terminal && e.terminal === this.terminal) { + terminalData += e.data; + if (/>>>\s*$/.test(terminalData)) { + traceVerbose('Python REPL ready, from >>> prompt detection'); + this.onReplReady(); + } + } + }); + } + + private onReplReady(): void { + if (this.isReplReady) { + return; + } + this.isReplReady = true; + this.flushReplQueue(); + this.disposeReplListener(); + } + + private disposeReplListener(): void { + if (this.replPromptListener) { + this.replPromptListener.dispose(); + this.replPromptListener = undefined; + } + if (this.replShellTypeListener) { + this.replShellTypeListener.dispose(); + this.replShellTypeListener = undefined; + } + } + + private flushReplQueue(): void { + while (this.pythonReplCommandQueue.length > 0) { + const commandLine = this.pythonReplCommandQueue.shift(); + if (commandLine) { + traceVerbose(`Executing queued REPL command: ${commandLine}`); + this.terminal?.sendText(commandLine); + } + } + } + + private async executeCommandInternal(commandLine: string): Promise { + const terminal = this.terminal; + if (!terminal) { + traceVerbose('Terminal not available, cannot execute command'); + return undefined; + } + if (!this.options?.hideFromUser) { terminal.show(true); } @@ -108,21 +191,7 @@ export class TerminalService implements ITerminalService, Disposable { await promise; } - const config = getConfiguration('python'); - const pythonrcSetting = config.get('terminal.shellIntegration.enabled'); - - const minorVersion = this.options?.resource - ? await getPythonMinorVersion( - this.options.resource, - this.serviceContainer.get(IInterpreterService), - ) - : undefined; - - if ((isPythonShell && !pythonrcSetting) || (isPythonShell && isWindows()) || (minorVersion ?? 0) >= 13) { - // If user has explicitly disabled SI for Python, use sendText for inside Terminal REPL. - terminal.sendText(commandLine); - return undefined; - } else if (terminal.shellIntegration) { + if (terminal.shellIntegration) { const execution = terminal.shellIntegration.executeCommand(commandLine); traceVerbose(`Shell Integration is enabled, executeCommand: ${commandLine}`); return execution; @@ -151,6 +220,7 @@ export class TerminalService implements ITerminalService, Disposable { name: this.options?.title || 'Python', hideFromUser: this.options?.hideFromUser, }); + return; } else { this.terminalShellType = this.terminalHelper.identifyTerminalShell(this.terminal); this.terminal = this.terminalManager.createTerminal({ @@ -180,6 +250,9 @@ export class TerminalService implements ITerminalService, Disposable { if (terminal === this.terminal) { this.terminalClosed.fire(); this.terminal = undefined; + this.isReplReady = false; + this.disposeReplListener(); + this.pythonReplCommandQueue = []; } } diff --git a/src/client/common/utils/localize.ts b/src/client/common/utils/localize.ts index 067275ad732c..7b7560c74e05 100644 --- a/src/client/common/utils/localize.ts +++ b/src/client/common/utils/localize.ts @@ -191,6 +191,24 @@ export namespace Interpreters { export const installingPython = l10n.t('Installing Python into Environment...'); export const discovering = l10n.t('Discovering Python Interpreters'); export const refreshing = l10n.t('Refreshing Python Interpreters'); + export const envExtDiscoveryAttribution = l10n.t( + 'Environment discovery is managed by the Python Environments extension (ms-python.vscode-python-envs). Check the "Python Environments" output channel for environment-specific logs.', + ); + export const envExtDiscoveryFailed = l10n.t( + 'Environment discovery failed. Check the "Python Environments" output channel for details. The Python Environments extension (ms-python.vscode-python-envs) manages environment discovery.', + ); + export const envExtDiscoverySlow = l10n.t( + 'Environment discovery is taking longer than expected. Check the "Python Environments" output channel for progress. The Python Environments extension (ms-python.vscode-python-envs) manages environment discovery.', + ); + export const envExtActivationFailed = l10n.t( + 'Failed to activate the Python Environments extension (ms-python.vscode-python-envs), which is required for environment discovery. Please ensure it is installed and enabled.', + ); + export const envExtDiscoveryNoEnvironments = l10n.t( + 'Environment discovery completed but no Python environments were found. Check the "Python Environments" output channel for details.', + ); + export const envExtNoActiveEnvironment = l10n.t( + 'No Python environment is set for this resource. Check the "Python Environments" output channel for details, or select an interpreter.', + ); export const condaInheritEnvMessage = l10n.t( 'We noticed you\'re using a conda environment. If you are experiencing issues with this environment in the integrated terminal, we recommend that you let the Python extension change "terminal.integrated.inheritEnv" to false in your user settings. [Learn more](https://aka.ms/AA66i8f).', ); @@ -199,6 +217,12 @@ export namespace Interpreters { export const terminalEnvVarCollectionPrompt = l10n.t( '{0} environment was successfully activated, even though {1} indicator may not be present in the terminal prompt. [Learn more](https://aka.ms/vscodePythonTerminalActivation).', ); + export const shellIntegrationEnvVarCollectionDescription = l10n.t( + 'Enables `python.terminal.shellIntegration.enabled` by modifying `PYTHONSTARTUP` and `PYTHON_BASIC_REPL`', + ); + export const shellIntegrationDisabledEnvVarCollectionDescription = l10n.t( + 'Disables `python.terminal.shellIntegration.enabled` by unsetting `PYTHONSTARTUP` and `PYTHON_BASIC_REPL`', + ); export const terminalDeactivateProgress = l10n.t('Editing {0}...'); export const restartingTerminal = l10n.t('Restarting terminal and deactivating...'); export const terminalDeactivatePrompt = l10n.t( @@ -516,3 +540,15 @@ export namespace CreateEnv { ); } } + +export namespace PythonLocator { + export const startupFailedNotification = l10n.t( + 'Python Locator failed to start. Python environment discovery may not work correctly.', + ); + export const windowsRuntimeMissing = l10n.t( + 'Missing Windows runtime dependencies detected. The Python Locator requires the Microsoft Visual C++ Redistributable. This is often missing on clean Windows installations.', + ); + export const windowsStartupFailed = l10n.t( + 'Python Locator failed to start on Windows. This might be due to missing system dependencies such as the Microsoft Visual C++ Redistributable.', + ); +} diff --git a/src/client/common/vscodeApis/windowApis.ts b/src/client/common/vscodeApis/windowApis.ts index fade0a028487..90a06e7ed75a 100644 --- a/src/client/common/vscodeApis/windowApis.ts +++ b/src/client/common/vscodeApis/windowApis.ts @@ -25,6 +25,7 @@ import { NotebookDocument, NotebookEditor, NotebookDocumentShowOptions, + Terminal, } from 'vscode'; import { createDeferred, Deferred } from '../utils/async'; import { Resource } from '../types'; @@ -124,6 +125,10 @@ export function onDidStartTerminalShellExecution(handler: (e: TerminalShellExecu return window.onDidStartTerminalShellExecution(handler); } +export function onDidChangeTerminalState(handler: (e: Terminal) => void): Disposable { + return window.onDidChangeTerminalState(handler); +} + export enum MultiStepAction { Back = 'Back', Cancel = 'Cancel', diff --git a/src/client/envExt/api.internal.ts b/src/client/envExt/api.internal.ts index c4247f63a3c5..5edfb712072e 100644 --- a/src/client/envExt/api.internal.ts +++ b/src/client/envExt/api.internal.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { EventEmitter, Terminal, Uri, Disposable, ConfigurationTarget } from 'vscode'; +import { EventEmitter, Terminal, Uri, Disposable } from 'vscode'; import { getExtension } from '../common/vscodeApis/extensionsApi'; import { GetEnvironmentScope, @@ -13,17 +13,54 @@ import { DidChangeEnvironmentEventArgs, } from './types'; import { executeCommand } from '../common/vscodeApis/commandApis'; -import { IInterpreterPathService } from '../common/types'; -import { getConfiguration } from '../common/vscodeApis/workspaceApis'; +import { getConfiguration, getWorkspaceFolders } from '../common/vscodeApis/workspaceApis'; +import { traceError, traceLog } from '../logging'; +import { Interpreters } from '../common/utils/localize'; export const ENVS_EXTENSION_ID = 'ms-python.vscode-python-envs'; +export function isEnvExtensionInstalled(): boolean { + return !!getExtension(ENVS_EXTENSION_ID); +} + +/** + * Returns true if the Python Environments extension is installed and not explicitly + * disabled by the user. Mirrors the envs extension's own activation logic: it + * deactivates only when `python.useEnvironmentsExtension` is explicitly set to false + * at the global, workspace, or workspace-folder level. + */ +export function shouldEnvExtHandleActivation(): boolean { + if (!isEnvExtensionInstalled()) { + return false; + } + const config = getConfiguration('python'); + const inspection = config.inspect('useEnvironmentsExtension'); + if (inspection?.globalValue === false || inspection?.workspaceValue === false) { + return false; + } + // The envs extension also checks folder-scoped settings in multi-root workspaces. + // Any single folder with the setting set to false causes the envs extension to + // deactivate entirely (window-wide), so we must mirror that here. + const workspaceFolders = getWorkspaceFolders(); + if (workspaceFolders) { + for (const folder of workspaceFolders) { + const folderConfig = getConfiguration('python', folder.uri); + const folderInspection = folderConfig.inspect('useEnvironmentsExtension'); + if (folderInspection?.workspaceFolderValue === false) { + return false; + } + } + } + return true; +} + let _useExt: boolean | undefined; export function useEnvExtension(): boolean { if (_useExt !== undefined) { return _useExt; } - const inExpSetting = getConfiguration('python').get('useEnvironmentsExtension', false); + const config = getConfiguration('python'); + const inExpSetting = config?.get('useEnvironmentsExtension', false) ?? false; // If extension is installed and in experiment, then use it. _useExt = !!getExtension(ENVS_EXTENSION_ID) && inExpSetting; return _useExt; @@ -47,12 +84,20 @@ export async function getEnvExtApi(): Promise { } const extension = getExtension(ENVS_EXTENSION_ID); if (!extension) { + traceError(Interpreters.envExtActivationFailed); throw new Error('Python Environments extension not found.'); } if (!extension?.isActive) { - await extension.activate(); + try { + await extension.activate(); + } catch (ex) { + traceError(Interpreters.envExtActivationFailed, ex); + throw ex; + } } + traceLog(Interpreters.envExtDiscoveryAttribution); + _extApi = extension.exports as PythonEnvironmentApi; _extApi.onDidChangeEnvironment((e) => { onDidChangeEnvironmentEnvExtEmitter.fire(e); @@ -71,7 +116,11 @@ export async function runInBackground( export async function getEnvironment(scope: GetEnvironmentScope): Promise { const envExtApi = await getEnvExtApi(); - return envExtApi.getEnvironment(scope); + const env = await envExtApi.getEnvironment(scope); + if (!env) { + traceLog(Interpreters.envExtNoActiveEnvironment); + } + return env; } export async function resolveEnvironment(pythonPath: string): Promise { @@ -128,32 +177,3 @@ export async function clearCache(): Promise { await executeCommand('python-envs.clearCache'); } } - -export function registerEnvExtFeatures( - disposables: Disposable[], - interpreterPathService: IInterpreterPathService, -): void { - if (useEnvExtension()) { - disposables.push( - onDidChangeEnvironmentEnvExt(async (e: DidChangeEnvironmentEventArgs) => { - const previousPath = interpreterPathService.get(e.uri); - - if (previousPath !== e.new?.environmentPath.fsPath) { - if (e.uri) { - await interpreterPathService.update( - e.uri, - ConfigurationTarget.WorkspaceFolder, - e.new?.environmentPath.fsPath, - ); - } else { - await interpreterPathService.update( - undefined, - ConfigurationTarget.Global, - e.new?.environmentPath.fsPath, - ); - } - } - }), - ); - } -} diff --git a/src/client/envExt/api.legacy.ts b/src/client/envExt/api.legacy.ts index d1a9f404d541..6f2e60774033 100644 --- a/src/client/envExt/api.legacy.ts +++ b/src/client/envExt/api.legacy.ts @@ -148,5 +148,24 @@ export async function ensureTerminalLegacy( const terminal = await api.createTerminal(pythonEnv, fixedOptions); return terminal; } + traceError('ensureTerminalLegacy - Did not return terminal successfully.'); + traceError( + 'ensureTerminalLegacy - pythonEnv:', + pythonEnv + ? `id=${pythonEnv.envId.id}, managerId=${pythonEnv.envId.managerId}, name=${pythonEnv.name}, version=${pythonEnv.version}, executable=${pythonEnv.execInfo.run.executable}` + : 'undefined', + ); + traceError( + 'ensureTerminalLegacy - project:', + project ? `name=${project.name}, uri=${project.uri.toString()}` : 'undefined', + ); + traceError( + 'ensureTerminalLegacy - options:', + options + ? `name=${options.name}, cwd=${options.cwd?.toString()}, hideFromUser=${options.hideFromUser}` + : 'undefined', + ); + traceError('ensureTerminalLegacy - resource:', resource?.toString() || 'undefined'); + throw new Error('Invalid arguments to create terminal'); } diff --git a/src/client/envExt/envExtApi.ts b/src/client/envExt/envExtApi.ts index 598899b7d248..34f42f0d6954 100644 --- a/src/client/envExt/envExtApi.ts +++ b/src/client/envExt/envExtApi.ts @@ -17,7 +17,7 @@ import { PythonEnvCollectionChangedEvent } from '../pythonEnvironments/base/watc import { getEnvExtApi } from './api.internal'; import { createDeferred, Deferred } from '../common/utils/async'; import { StopWatch } from '../common/utils/stopWatch'; -import { traceLog } from '../logging'; +import { traceError, traceLog, traceWarn } from '../logging'; import { DidChangeEnvironmentsEventArgs, EnvironmentChangeKind, @@ -27,6 +27,7 @@ import { import { FileChangeType } from '../common/platform/fileSystemWatcher'; import { Architecture, isWindows } from '../common/utils/platform'; import { parseVersion } from '../pythonEnvironments/base/info/pythonVersion'; +import { Interpreters } from '../common/utils/localize'; function getKind(pythonEnv: PythonEnvironment): PythonEnvKind { if (pythonEnv.envId.managerId.toLowerCase().endsWith('system')) { @@ -242,13 +243,23 @@ class EnvExtApis implements IDiscoveryAPI, Disposable { this._onProgress.fire({ stage: this.refreshState }); this._refreshPromise = createDeferred(); + const SLOW_DISCOVERY_THRESHOLD_MS = 25_000; + const slowDiscoveryTimer = setTimeout(() => { + traceWarn(Interpreters.envExtDiscoverySlow); + }, SLOW_DISCOVERY_THRESHOLD_MS); + setImmediate(async () => { try { await this.envExtApi.refreshEnvironments(undefined); + if (this._envs.length === 0) { + traceWarn(Interpreters.envExtDiscoveryNoEnvironments); + } this._refreshPromise?.resolve(); } catch (error) { + traceError(Interpreters.envExtDiscoveryFailed, error); this._refreshPromise?.reject(error); } finally { + clearTimeout(slowDiscoveryTimer); traceLog(`Native locator: Refresh finished in ${stopwatch.elapsedTime} ms`); this.refreshState = ProgressReportStage.discoveryFinished; this._refreshPromise = undefined; @@ -297,9 +308,16 @@ class EnvExtApis implements IDiscoveryAPI, Disposable { if (envPath === undefined) { return undefined; } - const pythonEnv = await this.envExtApi.resolveEnvironment(Uri.file(envPath)); - if (pythonEnv) { - return this.addEnv(pythonEnv); + try { + const pythonEnv = await this.envExtApi.resolveEnvironment(Uri.file(envPath)); + if (pythonEnv) { + return this.addEnv(pythonEnv); + } + } catch (error) { + traceError( + `Failed to resolve environment "${envPath}" via the Python Environments extension (ms-python.vscode-python-envs). Check the "Python Environments" output channel for details.`, + error, + ); } return undefined; } diff --git a/src/client/extension.ts b/src/client/extension.ts index af26a5657330..c3fb2a3ab3b0 100644 --- a/src/client/extension.ts +++ b/src/client/extension.ts @@ -45,6 +45,8 @@ import { buildProposedApi } from './proposedApi'; import { GLOBAL_PERSISTENT_KEYS } from './common/persistentState'; import { registerTools } from './chat'; import { IRecommendedEnvironmentService } from './interpreter/configuration/types'; +import { registerTypes as unitTestsRegisterTypes } from './testing/serviceRegistry'; +import { registerTestCommands } from './testing/main'; durations.codeLoadingTime = stopWatch.elapsedTime; @@ -121,6 +123,11 @@ async function activateUnsafe( // Note standard utils especially experiment and platform code are fundamental to the extension // and should be available before we activate anything else.Hence register them first. initializeStandard(ext); + + // Register test services and commands early to prevent race conditions. + unitTestsRegisterTypes(ext.legacyIOC.serviceManager); + registerTestCommands(activatedServiceContainer); + // We need to activate experiments before initializing components as objects are created or not created based on experiments. const experimentService = activatedServiceContainer.get(IExperimentService); // This guarantees that all experiment information has loaded & all telemetry will contain experiment info. diff --git a/src/client/extensionActivation.ts b/src/client/extensionActivation.ts index 8330d5010f7a..57bcb8237eeb 100644 --- a/src/client/extensionActivation.ts +++ b/src/client/extensionActivation.ts @@ -13,14 +13,7 @@ import { IApplicationEnvironment, ICommandManager, IWorkspaceService } from './c import { Commands, PYTHON_LANGUAGE, UseProposedApi } from './common/constants'; import { registerTypes as installerRegisterTypes } from './common/installer/serviceRegistry'; import { IFileSystem } from './common/platform/types'; -import { - IConfigurationService, - IDisposableRegistry, - IExtensions, - IInterpreterPathService, - ILogOutputChannel, - IPathUtils, -} from './common/types'; +import { IConfigurationService, IDisposableRegistry, IExtensions, ILogOutputChannel, IPathUtils } from './common/types'; import { noop } from './common/utils/misc'; import { registerTypes as debugConfigurationRegisterTypes } from './debugger/extension/serviceRegistry'; import { IDebugConfigurationService } from './debugger/extension/types'; @@ -33,7 +26,6 @@ import { setExtensionInstallTelemetryProperties } from './telemetry/extensionIns import { registerTypes as tensorBoardRegisterTypes } from './tensorBoard/serviceRegistry'; import { registerTypes as commonRegisterTerminalTypes } from './terminals/serviceRegistry'; import { ICodeExecutionHelper, ICodeExecutionManager, ITerminalAutoActivation } from './terminals/types'; -import { registerTypes as unitTestsRegisterTypes } from './testing/serviceRegistry'; // components import * as pythonEnvironments from './pythonEnvironments'; @@ -56,7 +48,6 @@ import { registerTriggerForTerminalREPL } from './terminals/codeExecution/termin import { registerPythonStartup } from './terminals/pythonStartup'; import { registerPixiFeatures } from './pythonEnvironments/common/environmentManagers/pixi'; import { registerCustomTerminalLinkProvider } from './terminals/pythonStartupLinkProvider'; -import { registerEnvExtFeatures } from './envExt/api.internal'; export async function activateComponents( // `ext` is passed to any extra activation funcs. @@ -96,13 +87,9 @@ export function activateFeatures(ext: ExtensionState, _components: Components): const interpreterQuickPick: IInterpreterQuickPick = ext.legacyIOC.serviceContainer.get( IInterpreterQuickPick, ); - const interpreterPathService: IInterpreterPathService = ext.legacyIOC.serviceContainer.get( - IInterpreterPathService, - ); const interpreterService: IInterpreterService = ext.legacyIOC.serviceContainer.get( IInterpreterService, ); - registerEnvExtFeatures(ext.disposables, interpreterPathService); const pathUtils = ext.legacyIOC.serviceContainer.get(IPathUtils); registerPixiFeatures(ext.disposables); registerAllCreateEnvironmentFeatures( @@ -144,7 +131,6 @@ async function activateLegacy(ext: ExtensionState, startupStopWatch: StopWatch): const { enableProposedApi } = applicationEnv.packageJson; serviceManager.addSingletonInstance(UseProposedApi, enableProposedApi); // Feature specific registrations. - unitTestsRegisterTypes(serviceManager); installerRegisterTypes(serviceManager); commonRegisterTerminalTypes(serviceManager); debugConfigurationRegisterTypes(serviceManager); diff --git a/src/client/interpreter/configuration/recommededEnvironmentService.ts b/src/client/interpreter/configuration/recommededEnvironmentService.ts index c5356409fcee..39f4edfde1d6 100644 --- a/src/client/interpreter/configuration/recommededEnvironmentService.ts +++ b/src/client/interpreter/configuration/recommededEnvironmentService.ts @@ -17,6 +17,7 @@ const MEMENTO_KEY = 'userSelectedEnvPath'; @injectable() export class RecommendedEnvironmentService implements IRecommendedEnvironmentService, IExtensionActivationService { private api?: PythonExtension['environments']; + private hasRegisteredCommand = false; constructor(@inject(IExtensionContext) private readonly extensionContext: IExtensionContext) {} supportedWorkspaceTypes: { untrustedWorkspace: boolean; virtualWorkspace: boolean } = { untrustedWorkspace: true, @@ -24,6 +25,10 @@ export class RecommendedEnvironmentService implements IRecommendedEnvironmentSer }; async activate(_resource: Resource, _startupStopWatch?: StopWatch): Promise { + if (this.hasRegisteredCommand) { + return; + } + this.hasRegisteredCommand = true; this.extensionContext.subscriptions.push( commands.registerCommand('python.getRecommendedEnvironment', async (resource: Resource) => { return this.getRecommededEnvironment(resource); diff --git a/src/client/interpreter/display/index.ts b/src/client/interpreter/display/index.ts index ebe7fc359cac..3a602093d4f9 100644 --- a/src/client/interpreter/display/index.ts +++ b/src/client/interpreter/display/index.ts @@ -24,7 +24,7 @@ import { IInterpreterService, IInterpreterStatusbarVisibilityFilter, } from '../contracts'; -import { useEnvExtension } from '../../envExt/api.internal'; +import { shouldEnvExtHandleActivation } from '../../envExt/api.internal'; /** * Based on https://github.com/microsoft/vscode-python/issues/18040#issuecomment-992567670. @@ -68,7 +68,7 @@ export class InterpreterDisplay implements IInterpreterDisplay, IExtensionSingle } public async activate(): Promise { - if (useEnvExtension()) { + if (shouldEnvExtHandleActivation()) { return; } const application = this.serviceContainer.get(IApplicationShell); @@ -115,7 +115,7 @@ export class InterpreterDisplay implements IInterpreterDisplay, IExtensionSingle } } private async updateDisplay(workspaceFolder?: Uri) { - if (useEnvExtension()) { + if (shouldEnvExtHandleActivation()) { this.statusBar?.hide(); this.languageStatus?.dispose(); this.languageStatus = undefined; diff --git a/src/client/providers/terminalProvider.ts b/src/client/providers/terminalProvider.ts index 841f479269ac..f68f151110ec 100644 --- a/src/client/providers/terminalProvider.ts +++ b/src/client/providers/terminalProvider.ts @@ -11,7 +11,7 @@ import { swallowExceptions } from '../common/utils/decorators'; import { IServiceContainer } from '../ioc/types'; import { captureTelemetry, sendTelemetryEvent } from '../telemetry'; import { EventName } from '../telemetry/constants'; -import { useEnvExtension } from '../envExt/api.internal'; +import { useEnvExtension, shouldEnvExtHandleActivation } from '../envExt/api.internal'; export class TerminalProvider implements Disposable { private disposables: Disposable[] = []; @@ -33,7 +33,7 @@ export class TerminalProvider implements Disposable { currentTerminal && pythonSettings.terminal.activateEnvInCurrentTerminal && !inTerminalEnvVarExperiment(experimentService) && - !useEnvExtension() + !shouldEnvExtHandleActivation() ) { const hideFromUser = 'hideFromUser' in currentTerminal.creationOptions && currentTerminal.creationOptions.hideFromUser; diff --git a/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts b/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts index cb5ab63077c9..ea0d63cd7552 100644 --- a/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts +++ b/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts @@ -15,18 +15,24 @@ import { noop } from '../../../../common/utils/misc'; import { getConfiguration, getWorkspaceFolderPaths, isTrusted } from '../../../../common/vscodeApis/workspaceApis'; import { CONDAPATH_SETTING_KEY } from '../../../common/environmentManagers/conda'; import { VENVFOLDERS_SETTING_KEY, VENVPATH_SETTING_KEY } from '../lowLevel/customVirtualEnvLocator'; -import { createLogOutputChannel } from '../../../../common/vscodeApis/windowApis'; +import { createLogOutputChannel, showWarningMessage } from '../../../../common/vscodeApis/windowApis'; import { sendNativeTelemetry, NativePythonTelemetry } from './nativePythonTelemetry'; import { NativePythonEnvironmentKind } from './nativePythonUtils'; import type { IExtensionContext } from '../../../../common/types'; import { StopWatch } from '../../../../common/utils/stopWatch'; import { untildify } from '../../../../common/helpers'; import { traceError } from '../../../../logging'; +import { Common, PythonLocator } from '../../../../common/utils/localize'; +import { Commands } from '../../../../common/constants'; +import { executeCommand } from '../../../../common/vscodeApis/commandApis'; +import { getGlobalStorage, IPersistentStorage } from '../../../../common/persistentState'; const PYTHON_ENV_TOOLS_PATH = isWindows() ? path.join(EXTENSION_ROOT_DIR, 'python-env-tools', 'bin', 'pet.exe') : path.join(EXTENSION_ROOT_DIR, 'python-env-tools', 'bin', 'pet'); +const DONT_SHOW_SPAWN_ERROR_AGAIN = 'DONT_SHOW_NATIVE_FINDER_SPAWN_ERROR_AGAIN'; + export interface NativeEnvInfo { displayName?: string; name?: string; @@ -106,8 +112,13 @@ class NativePythonFinderImpl extends DisposableBase implements NativePythonFinde timeToRefresh: 0, }; - constructor(private readonly cacheDirectory?: Uri) { + private readonly suppressErrorNotification: IPersistentStorage; + + constructor(private readonly cacheDirectory?: Uri, private readonly context?: IExtensionContext) { super(); + this.suppressErrorNotification = this.context + ? getGlobalStorage(this.context, DONT_SHOW_SPAWN_ERROR_AGAIN, false) + : ({ get: () => false, set: async () => {} } as IPersistentStorage); this.connection = this.start(); void this.configure(); this.firstRefreshResults = this.refreshFirstTime(); @@ -212,6 +223,30 @@ class NativePythonFinderImpl extends DisposableBase implements NativePythonFinde proc.stderr.on('data', (data) => this.outputChannel.error(data.toString())); writable.pipe(proc.stdin, { end: false }); + // Handle spawn errors (e.g., missing DLLs on Windows) + proc.on('error', (error) => { + this.outputChannel.error(`Python Locator process error: ${error.message}`); + this.outputChannel.error(`Error details: ${JSON.stringify(error)}`); + this.handleSpawnError(error.message); + }); + + // Handle immediate exits with error codes + let hasStarted = false; + setTimeout(() => { + hasStarted = true; + }, 1000); + + proc.on('exit', (code, signal) => { + if (!hasStarted && code !== null && code !== 0) { + const errorMessage = `Python Locator process exited immediately with code ${code}`; + this.outputChannel.error(errorMessage); + if (signal) { + this.outputChannel.error(`Exit signal: ${signal}`); + } + this.handleSpawnError(errorMessage); + } + }); + disposables.push({ dispose: () => { try { @@ -397,6 +432,33 @@ class NativePythonFinderImpl extends DisposableBase implements NativePythonFinde async getCondaInfo(): Promise { return this.connection.sendRequest('condaInfo'); } + + private async handleSpawnError(errorMessage: string): Promise { + // Check if user has chosen to not see this error again + if (this.suppressErrorNotification.get()) { + return; + } + + // Check for Windows runtime DLL issues + if (isWindows() && errorMessage.toLowerCase().includes('vcruntime')) { + this.outputChannel.error(PythonLocator.windowsRuntimeMissing); + } else if (isWindows()) { + this.outputChannel.error(PythonLocator.windowsStartupFailed); + } + + // Show notification to user + const selection = await showWarningMessage( + PythonLocator.startupFailedNotification, + Common.openOutputPanel, + Common.doNotShowAgain, + ); + + if (selection === Common.openOutputPanel) { + await executeCommand(Commands.ViewOutput); + } else if (selection === Common.doNotShowAgain) { + await this.suppressErrorNotification.set(true); + } + } } type ConfigurationOptions = { @@ -461,7 +523,7 @@ export function getNativePythonFinder(context?: IExtensionContext): NativePython } if (!_finder) { const cacheDirectory = context ? getCacheDirectory(context) : undefined; - _finder = new NativePythonFinderImpl(cacheDirectory); + _finder = new NativePythonFinderImpl(cacheDirectory, context); if (context) { context.subscriptions.push(_finder); } diff --git a/src/client/repl/nativeRepl.ts b/src/client/repl/nativeRepl.ts index 62e314172da6..3f8a085da467 100644 --- a/src/client/repl/nativeRepl.ts +++ b/src/client/repl/nativeRepl.ts @@ -4,9 +4,10 @@ // Native Repl class that holds instance of pythonServer and replController import { NotebookController, NotebookDocument, QuickPickItem, TextEditor, Uri, WorkspaceFolder } from 'vscode'; +import * as path from 'path'; import { Disposable } from 'vscode-jsonrpc'; import { PVSC_EXTENSION_ID } from '../common/constants'; -import { showQuickPick } from '../common/vscodeApis/windowApis'; +import { showNotebookDocument, showQuickPick } from '../common/vscodeApis/windowApis'; import { getWorkspaceFolders, onDidCloseNotebookDocument } from '../common/vscodeApis/workspaceApis'; import { PythonEnvironment } from '../pythonEnvironments/info'; import { createPythonServer, PythonServer } from './pythonServer'; @@ -18,6 +19,8 @@ import { VariablesProvider } from './variables/variablesProvider'; import { VariableRequester } from './variables/variableRequester'; import { getTabNameForUri } from './replUtils'; import { getWorkspaceStateValue, updateWorkspaceStateValue } from '../common/persistentState'; +import { onDidChangeEnvironmentEnvExt, useEnvExtension } from '../envExt/api.internal'; +import { getActiveInterpreterLegacy } from '../envExt/api.legacy'; export const NATIVE_REPL_URI_MEMENTO = 'nativeReplUri'; let nativeRepl: NativeRepl | undefined; @@ -37,6 +40,10 @@ export class NativeRepl implements Disposable { public newReplSession: boolean | undefined = true; + private envChangeListenerRegistered = false; + + private pendingInterpreterChange?: { resource?: Uri }; + // TODO: In the future, could also have attribute of URI for file specific REPL. private constructor() { this.watchNotebookClosed(); @@ -48,7 +55,9 @@ export class NativeRepl implements Disposable { nativeRepl.interpreter = interpreter; await nativeRepl.setReplDirectory(); nativeRepl.pythonServer = createPythonServer([interpreter.path as string], nativeRepl.cwd); + nativeRepl.disposables.push(nativeRepl.pythonServer); nativeRepl.setReplController(); + nativeRepl.registerInterpreterChangeHandler(); return nativeRepl; } @@ -116,8 +125,8 @@ export class NativeRepl implements Disposable { /** * Function that check if NotebookController for REPL exists, and returns it in Singleton manner. */ - public setReplController(): NotebookController { - if (!this.replController) { + public setReplController(force: boolean = false): NotebookController { + if (!this.replController || force) { this.replController = createReplController(this.interpreter!.path, this.disposables, this.cwd); this.replController.variableProvider = new VariablesProvider( new VariableRequester(this.pythonServer), @@ -128,6 +137,64 @@ export class NativeRepl implements Disposable { return this.replController; } + private registerInterpreterChangeHandler(): void { + if (!useEnvExtension() || this.envChangeListenerRegistered) { + return; + } + this.envChangeListenerRegistered = true; + this.disposables.push( + onDidChangeEnvironmentEnvExt((event) => { + this.updateInterpreterForChange(event.uri).catch(() => undefined); + }), + ); + this.disposables.push( + this.pythonServer.onCodeExecuted(() => { + if (this.pendingInterpreterChange) { + const { resource } = this.pendingInterpreterChange; + this.pendingInterpreterChange = undefined; + this.updateInterpreterForChange(resource).catch(() => undefined); + } + }), + ); + } + + private async updateInterpreterForChange(resource?: Uri): Promise { + if (this.pythonServer?.isExecuting) { + this.pendingInterpreterChange = { resource }; + return; + } + if (!this.shouldApplyInterpreterChange(resource)) { + return; + } + const scope = resource ?? (this.cwd ? Uri.file(this.cwd) : undefined); + const interpreter = await getActiveInterpreterLegacy(scope); + if (!interpreter || interpreter.path === this.interpreter?.path) { + return; + } + + this.interpreter = interpreter; + this.pythonServer.dispose(); + this.pythonServer = createPythonServer([interpreter.path as string], this.cwd); + this.disposables.push(this.pythonServer); + if (this.replController) { + this.replController.dispose(); + } + this.setReplController(true); + + if (this.notebookDocument) { + const notebookEditor = await showNotebookDocument(this.notebookDocument, { preserveFocus: true }); + await selectNotebookKernel(notebookEditor, this.replController.id, PVSC_EXTENSION_ID); + } + } + + private shouldApplyInterpreterChange(resource?: Uri): boolean { + if (!resource || !this.cwd) { + return true; + } + const relative = path.relative(this.cwd, resource.fsPath); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); + } + /** * Function that checks if native REPL's text input box contains complete code. * @returns Promise - True if complete/Valid code is present, False otherwise. diff --git a/src/client/repl/pythonServer.ts b/src/client/repl/pythonServer.ts index 74e2d6ae7251..c4b1722b5079 100644 --- a/src/client/repl/pythonServer.ts +++ b/src/client/repl/pythonServer.ts @@ -16,6 +16,8 @@ export interface ExecutionResult { export interface PythonServer extends Disposable { onCodeExecuted: Event; + readonly isExecuting: boolean; + readonly isDisposed: boolean; execute(code: string): Promise; executeSilently(code: string): Promise; interrupt(): void; @@ -30,6 +32,18 @@ class PythonServerImpl implements PythonServer, Disposable { onCodeExecuted = this._onCodeExecuted.event; + private inFlightRequests = 0; + + private disposed = false; + + public get isExecuting(): boolean { + return this.inFlightRequests > 0; + } + + public get isDisposed(): boolean { + return this.disposed; + } + constructor(private connection: rpc.MessageConnection, private pythonServer: ch.ChildProcess) { this.initialize(); this.input(); @@ -41,6 +55,14 @@ class PythonServerImpl implements PythonServer, Disposable { traceLog('Log:', message); }), ); + this.pythonServer.on('exit', (code) => { + traceError(`Python server exited with code ${code}`); + this.markDisposed(); + }); + this.pythonServer.on('error', (err) => { + traceError(err); + this.markDisposed(); + }); this.connection.listen(); } @@ -75,12 +97,15 @@ class PythonServerImpl implements PythonServer, Disposable { } private async executeCode(code: string): Promise { + this.inFlightRequests += 1; try { const result = await this.connection.sendRequest('execute', code); return result as ExecutionResult; } catch (err) { const error = err as Error; traceError(`Error getting response from REPL server:`, error); + } finally { + this.inFlightRequests -= 1; } return undefined; } @@ -93,39 +118,47 @@ class PythonServerImpl implements PythonServer, Disposable { } public async checkValidCommand(code: string): Promise { - const completeCode: ExecutionResult = await this.connection.sendRequest('check_valid_command', code); - if (completeCode.output === 'True') { - return new Promise((resolve) => resolve(true)); + this.inFlightRequests += 1; + try { + const completeCode: ExecutionResult = await this.connection.sendRequest('check_valid_command', code); + return completeCode.output === 'True'; + } finally { + this.inFlightRequests -= 1; } - return new Promise((resolve) => resolve(false)); } public dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; this.connection.sendNotification('exit'); this.disposables.forEach((d) => d.dispose()); this.connection.dispose(); serverInstance = undefined; } + + private markDisposed(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.connection.dispose(); + serverInstance = undefined; + } } export function createPythonServer(interpreter: string[], cwd?: string): PythonServer { - if (serverInstance) { + if (serverInstance && !serverInstance.isDisposed) { return serverInstance; } const pythonServer = ch.spawn(interpreter[0], [...interpreter.slice(1), SERVER_PATH], { cwd, // Launch with correct workspace directory }); - pythonServer.stderr.on('data', (data) => { traceError(data.toString()); }); - pythonServer.on('exit', (code) => { - traceError(`Python server exited with code ${code}`); - }); - pythonServer.on('error', (err) => { - traceError(err); - }); const connection = rpc.createMessageConnection( new rpc.StreamMessageReader(pythonServer.stdout), new rpc.StreamMessageWriter(pythonServer.stdin), diff --git a/src/client/repl/replCommands.ts b/src/client/repl/replCommands.ts index 993a0cc91b19..1171e9466ee8 100644 --- a/src/client/repl/replCommands.ts +++ b/src/client/repl/replCommands.ts @@ -2,7 +2,6 @@ import { commands, Uri, window } from 'vscode'; import { Disposable } from 'vscode-jsonrpc'; import { ICommandManager } from '../common/application/types'; import { Commands } from '../common/constants'; -import { noop } from '../common/utils/misc'; import { IInterpreterService } from '../interpreter/contracts'; import { ICodeExecutionHelper } from '../terminals/types'; import { getNativeRepl } from './nativeRepl'; @@ -102,14 +101,13 @@ export async function registerReplExecuteOnEnter( } async function onInputEnter( - uri: Uri, + uri: Uri | undefined, commandName: string, interpreterService: IInterpreterService, disposables: Disposable[], ): Promise { - const interpreter = await interpreterService.getActiveInterpreter(uri); + const interpreter = await getActiveInterpreter(uri, interpreterService); if (!interpreter) { - commands.executeCommand(Commands.TriggerEnvironmentSelection, uri).then(noop, noop); return; } diff --git a/src/client/repl/replUtils.ts b/src/client/repl/replUtils.ts index 8e23218c2870..93ae6f2a4573 100644 --- a/src/client/repl/replUtils.ts +++ b/src/client/repl/replUtils.ts @@ -66,12 +66,13 @@ export function isMultiLineText(textEditor: TextEditor): boolean { * Function will also return undefined or active interpreter */ export async function getActiveInterpreter( - uri: Uri, + uri: Uri | undefined, interpreterService: IInterpreterService, ): Promise { - const interpreter = await interpreterService.getActiveInterpreter(uri); + const resource = uri ?? getActiveResource(); + const interpreter = await interpreterService.getActiveInterpreter(resource); if (!interpreter) { - commands.executeCommand(Commands.TriggerEnvironmentSelection, uri).then(noop, noop); + commands.executeCommand(Commands.TriggerEnvironmentSelection, resource).then(noop, noop); return undefined; } return interpreter; diff --git a/src/client/telemetry/index.ts b/src/client/telemetry/index.ts index 738c5f8a2776..763f7405aa0d 100644 --- a/src/client/telemetry/index.ts +++ b/src/client/telemetry/index.ts @@ -1988,11 +1988,16 @@ export interface IEventNamePropertyMapping { * Telemetry event sent when invoking a Tool */ /* __GDPR__ - "invokeTool" : { + "INVOKE_TOOL" : { "duration" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "donjayamanne" }, "toolName" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "donjayamanne" }, "failed": {"classification":"SystemMetaData","purpose":"FeatureInsight","comment":"Whether there was a failure. Common to most of the events.", "owner": "donjayamanne" }, - "failureCategory": {"classification":"SystemMetaData","purpose":"FeatureInsight","comment":"A reason that we generate (e.g. kerneldied, noipykernel, etc), more like a category of the error. Common to most of the events.", "owner": "donjayamanne" } + "failureCategory": {"classification":"SystemMetaData","purpose":"FeatureInsight","comment":"A reason that we generate (e.g. kerneldied, noipykernel, etc), more like a category of the error. Common to most of the events.", "owner": "donjayamanne" }, + "resolveOutcome": {"classification":"SystemMetaData","purpose":"FeatureInsight","comment":"Which code path resolved the environment in configure_python_environment.", "owner": "donjayamanne" }, + "envType": {"classification":"SystemMetaData","purpose":"FeatureInsight","comment":"The type of Python environment (e.g. venv, conda, system).", "owner": "donjayamanne" }, + "packageCount": {"classification":"SystemMetaData","purpose":"FeatureInsight","comment":"Number of packages requested for installation (install_python_packages only).", "owner": "donjayamanne" }, + "installerType": {"classification":"SystemMetaData","purpose":"FeatureInsight","comment":"Which installer was used: pip or conda (install_python_packages only).", "owner": "donjayamanne" }, + "responsePackageCount": {"classification":"SystemMetaData","purpose":"FeatureInsight","comment":"Number of packages in the environment response (get_python_environment_details only).", "owner": "donjayamanne" } } */ [EventName.INVOKE_TOOL]: { @@ -2009,6 +2014,26 @@ export interface IEventNamePropertyMapping { * A reason the error was thrown. */ failureCategory?: string; + /** + * Which code path resolved the environment (configure_python_environment only). + */ + resolveOutcome?: string; + /** + * The type of Python environment (e.g. venv, conda, system). + */ + envType?: string; + /** + * Number of packages requested for installation (install_python_packages only). + */ + packageCount?: string; + /** + * Which installer was used: pip or conda (install_python_packages only). + */ + installerType?: string; + /** + * Number of packages in the environment response (get_python_environment_details only). + */ + responsePackageCount?: string; }; /** * Telemetry event sent if and when user configure tests command. This command can be trigerred from multiple places in the extension. (Command palette, prompt etc.) diff --git a/src/client/telemetry/pylance.ts b/src/client/telemetry/pylance.ts index 3d1ba05779dd..63bd113893e2 100644 --- a/src/client/telemetry/pylance.ts +++ b/src/client/telemetry/pylance.ts @@ -467,6 +467,11 @@ "failed" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" } } */ +/* __GDPR__ + "language_server/copilot_hover" : { + "symbolName" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" } + } +*/ /** * Telemetry event sent when LSP server crashes */ diff --git a/src/client/terminals/activation.ts b/src/client/terminals/activation.ts index 143a2de14e5c..ed26916e3eaa 100644 --- a/src/client/terminals/activation.ts +++ b/src/client/terminals/activation.ts @@ -9,6 +9,7 @@ import { IActiveResourceService, ITerminalManager } from '../common/application/ import { ITerminalActivator } from '../common/terminal/types'; import { IDisposable, IDisposableRegistry } from '../common/types'; import { ITerminalAutoActivation } from './types'; +import { shouldEnvExtHandleActivation } from '../envExt/api.internal'; @injectable() export class TerminalAutoActivation implements ITerminalAutoActivation { @@ -49,6 +50,9 @@ export class TerminalAutoActivation implements ITerminalAutoActivation { if (this.terminalsNotToAutoActivate.has(terminal)) { return; } + if (shouldEnvExtHandleActivation()) { + return; + } if ('hideFromUser' in terminal.creationOptions && terminal.creationOptions.hideFromUser) { return; } diff --git a/src/client/terminals/pythonStartup.ts b/src/client/terminals/pythonStartup.ts index f0c3bf89c3b4..b6f68c860b46 100644 --- a/src/client/terminals/pythonStartup.ts +++ b/src/client/terminals/pythonStartup.ts @@ -1,10 +1,11 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { ExtensionContext, Uri } from 'vscode'; +import { ExtensionContext, MarkdownString, Uri } from 'vscode'; import * as path from 'path'; import { copy, createDirectory, getConfiguration, onDidChangeConfiguration } from '../common/vscodeApis/workspaceApis'; import { EXTENSION_ROOT_DIR } from '../constants'; +import { Interpreters } from '../common/utils/localize'; async function applyPythonStartupSetting(context: ExtensionContext): Promise { const config = getConfiguration('python'); @@ -21,11 +22,17 @@ async function applyPythonStartupSetting(context: ExtensionContext): Promise(IConfigurationService); } + /** + * Launches a debug session for test execution. + * Handles cancellation, multi-session support via unique markers, and cleanup. + */ public async launchDebugger( options: LaunchOptions, callback?: () => void, @@ -38,18 +50,35 @@ export class DebugLauncher implements ITestDebugLauncher { ): Promise { const deferred = createDeferred(); let hasCallbackBeenCalled = false; + + // Collect disposables for cleanup when debugging completes + const disposables: Disposable[] = []; + + // Ensure callback is only invoked once, even if multiple termination paths fire + const callCallbackOnce = () => { + if (!hasCallbackBeenCalled) { + hasCallbackBeenCalled = true; + callback?.(); + } + }; + + // Early exit if already cancelled before we start if (options.token && options.token.isCancellationRequested) { - hasCallbackBeenCalled = true; - return undefined; + callCallbackOnce(); deferred.resolve(); - callback?.(); + return deferred.promise; } - options.token?.onCancellationRequested(() => { - deferred.resolve(); - callback?.(); - hasCallbackBeenCalled = true; - }); + // Listen for cancellation from the test run (e.g., user clicks stop in Test Explorer) + // This allows the caller to clean up resources even if the debug session is still running + if (options.token) { + disposables.push( + options.token.onCancellationRequested(() => { + deferred.resolve(); + callCallbackOnce(); + }), + ); + } const workspaceFolder = DebugLauncher.resolveWorkspaceFolder(options.cwd); const launchArgs = await this.getLaunchArgs( @@ -59,23 +88,57 @@ export class DebugLauncher implements ITestDebugLauncher { ); const debugManager = this.serviceContainer.get(IDebugService); - let activatedDebugSession: DebugSession | undefined; - debugManager.startDebugging(workspaceFolder, launchArgs, sessionOptions).then(() => { - // Save the debug session after it is started so we can check if it is the one that was terminated. - activatedDebugSession = debugManager.activeDebugSession; - }); - debugManager.onDidTerminateDebugSession((session) => { - traceVerbose(`Debug session terminated. sessionId: ${session.id}`); - // Only resolve no callback has been made and the session is the one that was started. - if ( - !hasCallbackBeenCalled && - activatedDebugSession !== undefined && - session.id === activatedDebugSession?.id - ) { - deferred.resolve(); - callback?.(); - } + // Unique marker to identify this session among concurrent debug sessions + const sessionMarker = `test-${Date.now()}-${Math.random().toString(36).slice(2)}`; + launchArgs[TEST_SESSION_MARKER_KEY] = sessionMarker; + + let ourSession: DebugSession | undefined; + + // Capture our specific debug session when it starts by matching the marker. + // This fires for ALL debug sessions, so we filter to only our marker. + disposables.push( + debugManager.onDidStartDebugSession((session) => { + if (session.configuration[TEST_SESSION_MARKER_KEY] === sessionMarker) { + ourSession = session; + traceVerbose(`[test-debug] Debug session started: ${session.name} (${session.id})`); + } + }), + ); + + // Handle debug session termination (user stops debugging, or tests complete). + // Only react to OUR session terminating - other parallel sessions should + // continue running independently. + disposables.push( + debugManager.onDidTerminateDebugSession((session) => { + if (ourSession && session.id === ourSession.id) { + traceVerbose(`[test-debug] Debug session terminated: ${session.name} (${session.id})`); + deferred.resolve(); + callCallbackOnce(); + } + }), + ); + + // Clean up event subscriptions when debugging completes (success, failure, or cancellation) + deferred.promise.finally(() => { + disposables.forEach((d) => d.dispose()); }); + + // Start the debug session + let started = false; + try { + started = await debugManager.startDebugging(workspaceFolder, launchArgs, sessionOptions); + } catch (error) { + traceError('Error starting debug session', error); + deferred.reject(error); + callCallbackOnce(); + return deferred.promise; + } + if (!started) { + traceError('Failed to start debug session'); + deferred.resolve(); + callCallbackOnce(); + } + return deferred.promise; } @@ -108,6 +171,12 @@ export class DebugLauncher implements ITestDebugLauncher { subProcess: true, }; } + + // Use project name in debug session name if provided + if (options.project) { + debugConfig.name = `Debug Tests: ${options.project.name}`; + } + if (!debugConfig.rules) { debugConfig.rules = []; } @@ -116,7 +185,7 @@ export class DebugLauncher implements ITestDebugLauncher { include: false, }); - DebugLauncher.applyDefaults(debugConfig!, workspaceFolder, configSettings); + DebugLauncher.applyDefaults(debugConfig!, workspaceFolder, configSettings, options.cwd); return this.convertConfigToArgs(debugConfig!, workspaceFolder, options); } @@ -163,6 +232,7 @@ export class DebugLauncher implements ITestDebugLauncher { cfg: LaunchRequestArguments, workspaceFolder: WorkspaceFolder, configSettings: IPythonSettings, + optionsCwd?: string, ) { // cfg.pythonPath is handled by LaunchConfigurationResolver. @@ -170,7 +240,9 @@ export class DebugLauncher implements ITestDebugLauncher { cfg.console = 'internalConsole'; } if (!cfg.cwd) { - cfg.cwd = configSettings.testing.cwd || workspaceFolder.uri.fsPath; + // For project-based testing, use the project's cwd (optionsCwd) if provided. + // Otherwise fall back to settings.testing.cwd or the workspace folder. + cfg.cwd = optionsCwd || configSettings.testing.cwd || workspaceFolder.uri.fsPath; } if (!cfg.env) { cfg.env = {}; @@ -257,6 +329,23 @@ export class DebugLauncher implements ITestDebugLauncher { // run via F5 style debugging. launchArgs.purpose = []; + // For project-based execution, get the Python path from the project's environment. + // Fallback: if env API unavailable or fails, LaunchConfigurationResolver already set + // launchArgs.python from the active interpreter, so debugging still works. + if (options.project && envExtApi.useEnvExtension()) { + try { + const pythonEnv = await envExtApi.getEnvironment(options.project.uri); + if (pythonEnv?.execInfo?.run?.executable) { + launchArgs.python = pythonEnv.execInfo.run.executable; + traceVerbose( + `[test-by-project] Debug session using Python path from project: ${launchArgs.python}`, + ); + } + } catch (error) { + traceVerbose(`[test-by-project] Could not get environment for project, using default: ${error}`); + } + } + return launchArgs; } diff --git a/src/client/testing/common/types.ts b/src/client/testing/common/types.ts index 562005386633..e2fa2d6d2e5a 100644 --- a/src/client/testing/common/types.ts +++ b/src/client/testing/common/types.ts @@ -2,6 +2,7 @@ import { CancellationToken, DebugSessionOptions, OutputChannel, Uri } from 'vsco import { Product } from '../../common/types'; import { TestSettingsPropertyNames } from '../configuration/types'; import { TestProvider } from '../types'; +import { PythonProject } from '../../envExt/types'; export type UnitTestProduct = Product.pytest | Product.unittest; @@ -26,6 +27,8 @@ export type LaunchOptions = { pytestPort?: string; pytestUUID?: string; runTestIdsPort?: string; + /** Optional Python project for project-based execution. */ + project?: PythonProject; }; export enum TestFilter { diff --git a/src/client/testing/main.ts b/src/client/testing/main.ts index e794d5711f2a..eed4d70e852c 100644 --- a/src/client/testing/main.ts +++ b/src/client/testing/main.ts @@ -18,7 +18,7 @@ import { IDisposableRegistry, Product } from '../common/types'; import { IInterpreterService } from '../interpreter/contracts'; import { IServiceContainer } from '../ioc/types'; import { EventName } from '../telemetry/constants'; -import { captureTelemetry, sendTelemetryEvent } from '../telemetry/index'; +import { sendTelemetryEvent } from '../telemetry/index'; import { selectTestWorkspace } from './common/testUtils'; import { TestSettingsPropertyNames } from './configuration/types'; import { ITestConfigurationService, ITestsHelper } from './common/types'; @@ -42,6 +42,91 @@ export class TestingService implements ITestingService { } } +/** + * Registers command handlers but defers service resolution until the commands are actually invoked, + * allowing registration to happen before all services are fully initialized. + */ +export function registerTestCommands(serviceContainer: IServiceContainer): void { + // Resolve only the essential services needed for command registration itself + const disposableRegistry = serviceContainer.get(IDisposableRegistry); + const commandManager = serviceContainer.get(ICommandManager); + + // Helper function to configure tests - services are resolved when invoked, not at registration time + const configureTestsHandler = async (resource?: Uri) => { + sendTelemetryEvent(EventName.UNITTEST_CONFIGURE); + + // Resolve services lazily when the command is invoked + const workspaceService = serviceContainer.get(IWorkspaceService); + + let wkspace: Uri | undefined; + if (resource) { + const wkspaceFolder = workspaceService.getWorkspaceFolder(resource); + wkspace = wkspaceFolder ? wkspaceFolder.uri : undefined; + } else { + const appShell = serviceContainer.get(IApplicationShell); + wkspace = await selectTestWorkspace(appShell); + } + if (!wkspace) { + return; + } + const interpreterService = serviceContainer.get(IInterpreterService); + const cmdManager = serviceContainer.get(ICommandManager); + if (!(await interpreterService.getActiveInterpreter(wkspace))) { + cmdManager.executeCommand(constants.Commands.TriggerEnvironmentSelection, wkspace); + return; + } + const configurationService = serviceContainer.get(ITestConfigurationService); + await configurationService.promptToEnableAndConfigureTestFramework(wkspace); + }; + + disposableRegistry.push( + // Command: python.configureTests - prompts user to configure test framework + commandManager.registerCommand( + constants.Commands.Tests_Configure, + (_, _cmdSource: constants.CommandSource = constants.CommandSource.commandPalette, resource?: Uri) => { + // Invoke configuration handler (errors are ignored as this can be called from multiple places) + configureTestsHandler(resource).ignoreErrors(); + traceVerbose('Testing: Trigger refresh after config change'); + // Refresh test data if test controller is available (resolved lazily) + if (tests && !!tests.createTestController) { + const testController = serviceContainer.get(ITestController); + testController?.refreshTestData(resource, { forceRefresh: true }); + } + }, + ), + // Command: python.tests.copilotSetup - Copilot integration for test setup + commandManager.registerCommand(constants.Commands.Tests_CopilotSetup, (resource?: Uri): + | { message: string; command: Command } + | undefined => { + // Resolve services lazily when the command is invoked + const workspaceService = serviceContainer.get(IWorkspaceService); + const wkspaceFolder = + workspaceService.getWorkspaceFolder(resource) || workspaceService.workspaceFolders?.at(0); + if (!wkspaceFolder) { + return undefined; + } + + const configurationService = serviceContainer.get(ITestConfigurationService); + if (configurationService.hasConfiguredTests(wkspaceFolder.uri)) { + return undefined; + } + + return { + message: Testing.copilotSetupMessage, + command: { + title: Testing.configureTests, + command: constants.Commands.Tests_Configure, + arguments: [undefined, constants.CommandSource.ui, resource], + }, + }; + }), + // Command: python.copyTestId - copies test ID to clipboard + commandManager.registerCommand(constants.Commands.CopyTestId, async (testItem: TestItem) => { + writeTestIdToClipboard(testItem); + }), + ); +} + @injectable() export class UnitTestManagementService implements IExtensionActivationService { private activatedOnce: boolean = false; @@ -80,7 +165,6 @@ export class UnitTestManagementService implements IExtensionActivationService { this.activatedOnce = true; this.registerHandlers(); - this.registerCommands(); if (!!tests.testResults) { await this.updateTestUIButtons(); @@ -130,73 +214,6 @@ export class UnitTestManagementService implements IExtensionActivationService { await Promise.all(changedWorkspaces.map((u) => this.testController?.refreshTestData(u))); } - @captureTelemetry(EventName.UNITTEST_CONFIGURE, undefined, false) - private async configureTests(resource?: Uri) { - let wkspace: Uri | undefined; - if (resource) { - const wkspaceFolder = this.workspaceService.getWorkspaceFolder(resource); - wkspace = wkspaceFolder ? wkspaceFolder.uri : undefined; - } else { - const appShell = this.serviceContainer.get(IApplicationShell); - wkspace = await selectTestWorkspace(appShell); - } - if (!wkspace) { - return; - } - const interpreterService = this.serviceContainer.get(IInterpreterService); - const commandManager = this.serviceContainer.get(ICommandManager); - if (!(await interpreterService.getActiveInterpreter(wkspace))) { - commandManager.executeCommand(constants.Commands.TriggerEnvironmentSelection, wkspace); - return; - } - const configurationService = this.serviceContainer.get(ITestConfigurationService); - await configurationService.promptToEnableAndConfigureTestFramework(wkspace!); - } - - private registerCommands(): void { - const commandManager = this.serviceContainer.get(ICommandManager); - this.disposableRegistry.push( - commandManager.registerCommand( - constants.Commands.Tests_Configure, - (_, _cmdSource: constants.CommandSource = constants.CommandSource.commandPalette, resource?: Uri) => { - // Ignore the exceptions returned. - // This command will be invoked from other places of the extension. - this.configureTests(resource).ignoreErrors(); - traceVerbose('Testing: Trigger refresh after config change'); - this.testController?.refreshTestData(resource, { forceRefresh: true }); - }, - ), - commandManager.registerCommand(constants.Commands.Tests_CopilotSetup, (resource?: Uri): - | { message: string; command: Command } - | undefined => { - const wkspaceFolder = - this.workspaceService.getWorkspaceFolder(resource) || this.workspaceService.workspaceFolders?.at(0); - if (!wkspaceFolder) { - return undefined; - } - - const configurationService = this.serviceContainer.get( - ITestConfigurationService, - ); - if (configurationService.hasConfiguredTests(wkspaceFolder.uri)) { - return undefined; - } - - return { - message: Testing.copilotSetupMessage, - command: { - title: Testing.configureTests, - command: constants.Commands.Tests_Configure, - arguments: [undefined, constants.CommandSource.ui, resource], - }, - }; - }), - commandManager.registerCommand(constants.Commands.CopyTestId, async (testItem: TestItem) => { - writeTestIdToClipboard(testItem); - }), - ); - } - private registerHandlers() { const interpreterService = this.serviceContainer.get(IInterpreterService); this.disposableRegistry.push( diff --git a/src/client/testing/testController/common/discoveryHelpers.ts b/src/client/testing/testController/common/discoveryHelpers.ts new file mode 100644 index 000000000000..e170ad576ae8 --- /dev/null +++ b/src/client/testing/testController/common/discoveryHelpers.ts @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +import { CancellationToken, CancellationTokenSource, Disposable, Uri } from 'vscode'; +import { Deferred } from '../../../common/utils/async'; +import { traceError, traceInfo, traceVerbose } from '../../../logging'; +import { createDiscoveryErrorPayload, fixLogLinesNoTrailing, startDiscoveryNamedPipe } from './utils'; +import { DiscoveredTestPayload, ITestResultResolver } from './types'; + +/** + * Test provider type for logging purposes. + */ +export type TestProvider = 'pytest' | 'unittest'; + +/** + * Sets up the discovery named pipe and wires up cancellation. + * @param resultResolver The resolver to handle discovered test data + * @param token Optional cancellation token from the caller + * @param uri Workspace URI for logging + * @returns Object containing the pipe name, cancellation source, and disposable for the external token handler + */ +export async function setupDiscoveryPipe( + resultResolver: ITestResultResolver | undefined, + token: CancellationToken | undefined, + uri: Uri, +): Promise<{ pipeName: string; cancellation: CancellationTokenSource; tokenDisposable: Disposable | undefined }> { + const discoveryPipeCancellation = new CancellationTokenSource(); + + // Wire up cancellation from external token and store the disposable + const tokenDisposable = token?.onCancellationRequested(() => { + traceInfo(`Test discovery cancelled.`); + discoveryPipeCancellation.cancel(); + }); + + // Start the named pipe with the discovery listener + const discoveryPipeName = await startDiscoveryNamedPipe((data: DiscoveredTestPayload) => { + if (!token?.isCancellationRequested) { + resultResolver?.resolveDiscovery(data); + } + }, discoveryPipeCancellation.token); + + traceVerbose(`Created discovery pipe: ${discoveryPipeName} for workspace ${uri.fsPath}`); + + return { + pipeName: discoveryPipeName, + cancellation: discoveryPipeCancellation, + tokenDisposable, + }; +} + +/** + * Creates standard process event handlers for test discovery subprocess. + * Handles stdout/stderr logging and error reporting on process exit. + * + * @param testProvider - The test framework being used ('pytest' or 'unittest') + * @param uri - The workspace URI + * @param cwd - The current working directory + * @param resultResolver - Resolver for test discovery results + * @param deferredTillExecClose - Deferred to resolve when process closes + * @param allowedSuccessCodes - Additional exit codes to treat as success (e.g., pytest exit code 5 for no tests found) + */ +export function createProcessHandlers( + testProvider: TestProvider, + uri: Uri, + cwd: string, + resultResolver: ITestResultResolver | undefined, + deferredTillExecClose: Deferred, + allowedSuccessCodes: number[] = [], +): { + onStdout: (data: any) => void; + onStderr: (data: any) => void; + onExit: (code: number | null, signal: NodeJS.Signals | null) => void; + onClose: (code: number | null, signal: NodeJS.Signals | null) => void; +} { + const isSuccessCode = (code: number | null): boolean => { + return code === 0 || (code !== null && allowedSuccessCodes.includes(code)); + }; + + return { + onStdout: (data: any) => { + const out = fixLogLinesNoTrailing(data.toString()); + traceInfo(out); + }, + onStderr: (data: any) => { + const out = fixLogLinesNoTrailing(data.toString()); + traceError(out); + }, + onExit: (code: number | null, _signal: NodeJS.Signals | null) => { + // The 'exit' event fires when the process terminates, but streams may still be open. + // Only log verbose success message here; error handling happens in onClose. + if (isSuccessCode(code)) { + traceVerbose(`${testProvider} discovery subprocess exited successfully for workspace ${uri.fsPath}`); + } + }, + onClose: (code: number | null, signal: NodeJS.Signals | null) => { + // We resolve the deferred here to ensure all output has been captured. + if (!isSuccessCode(code)) { + traceError( + `${testProvider} discovery failed with exit code ${code} and signal ${signal} for workspace ${uri.fsPath}. Creating error payload.`, + ); + resultResolver?.resolveDiscovery(createDiscoveryErrorPayload(code, signal, cwd)); + } else { + traceVerbose(`${testProvider} discovery subprocess streams closed for workspace ${uri.fsPath}`); + } + deferredTillExecClose?.resolve(); + }, + }; +} + +/** + * Handles cleanup when test discovery is cancelled. + * Kills the subprocess (if running), resolves the completion deferred, and cancels the discovery pipe. + * + * @param testProvider - The test framework being used ('pytest' or 'unittest') + * @param proc - The process to kill + * @param processCompletion - Deferred to resolve + * @param pipeCancellation - Cancellation token source to cancel + * @param uri - The workspace URI + */ +export function cleanupOnCancellation( + testProvider: TestProvider, + proc: { kill: () => void } | undefined, + processCompletion: Deferred, + pipeCancellation: CancellationTokenSource, + uri: Uri, +): void { + traceInfo(`Test discovery cancelled, killing ${testProvider} subprocess for workspace ${uri.fsPath}`); + if (proc) { + traceVerbose(`Killing ${testProvider} subprocess for workspace ${uri.fsPath}`); + proc.kill(); + } else { + traceVerbose(`No ${testProvider} subprocess to kill for workspace ${uri.fsPath} (proc is undefined)`); + } + traceVerbose(`Resolving process completion deferred for ${testProvider} discovery in workspace ${uri.fsPath}`); + processCompletion.resolve(); + traceVerbose(`Cancelling discovery pipe for ${testProvider} discovery in workspace ${uri.fsPath}`); + pipeCancellation.cancel(); +} diff --git a/src/client/testing/testController/common/projectAdapter.ts b/src/client/testing/testController/common/projectAdapter.ts new file mode 100644 index 000000000000..cfffbf439ca6 --- /dev/null +++ b/src/client/testing/testController/common/projectAdapter.ts @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { TestItem, Uri } from 'vscode'; +import { TestProvider } from '../../types'; +import { ITestDiscoveryAdapter, ITestExecutionAdapter, ITestResultResolver } from './types'; +import { PythonEnvironment, PythonProject } from '../../../envExt/types'; + +/** + * Represents a single Python project with its own test infrastructure. + * A project is defined as a combination of a Python executable + URI (folder/file). + * Projects are uniquely identified by their projectUri (use projectUri.toString() for map keys). + */ +export interface ProjectAdapter { + // === IDENTITY === + /** + * Display name for the project (e.g., "alice (Python 3.11)"). + */ + projectName: string; + + /** + * URI of the project root folder or file. + * This is the unique identifier for the project. + */ + projectUri: Uri; + + /** + * Parent workspace URI containing this project. + */ + workspaceUri: Uri; + + // === API OBJECTS (from vscode-python-environments extension) === + /** + * The PythonProject object from the environment API. + */ + pythonProject: PythonProject; + + /** + * The resolved PythonEnvironment with execution details. + * Contains execInfo.run.executable for running tests. + */ + pythonEnvironment: PythonEnvironment; + + // === TEST INFRASTRUCTURE === + /** + * Test framework provider ('pytest' | 'unittest'). + */ + testProvider: TestProvider; + + /** + * Adapter for test discovery. + */ + discoveryAdapter: ITestDiscoveryAdapter; + + /** + * Adapter for test execution. + */ + executionAdapter: ITestExecutionAdapter; + + /** + * Result resolver for this project (maps test IDs and handles results). + */ + resultResolver: ITestResultResolver; + + /** + * Absolute paths of nested projects to ignore during discovery. + * Used to pass --ignore flags to pytest or exclusion filters to unittest. + * Only populated for parent projects that contain nested child projects. + */ + nestedProjectPathsToIgnore?: string[]; + + // === LIFECYCLE === + /** + * Whether discovery is currently running for this project. + */ + isDiscovering: boolean; + + /** + * Whether tests are currently executing for this project. + */ + isExecuting: boolean; + + /** + * Root TestItem for this project in the VS Code test tree. + * All project tests are children of this item. + */ + projectRootTestItem?: TestItem; +} diff --git a/src/client/testing/testController/common/projectTestExecution.ts b/src/client/testing/testController/common/projectTestExecution.ts new file mode 100644 index 000000000000..fe3b4f91491a --- /dev/null +++ b/src/client/testing/testController/common/projectTestExecution.ts @@ -0,0 +1,296 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { CancellationToken, FileCoverageDetail, TestItem, TestRun, TestRunProfileKind, TestRunRequest } from 'vscode'; +import { traceError, traceInfo, traceVerbose, traceWarn } from '../../../logging'; +import { sendTelemetryEvent } from '../../../telemetry'; +import { EventName } from '../../../telemetry/constants'; +import { IPythonExecutionFactory } from '../../../common/process/types'; +import { ITestDebugLauncher } from '../../common/types'; +import { ProjectAdapter } from './projectAdapter'; +import { TestProjectRegistry } from './testProjectRegistry'; +import { getProjectId } from './projectUtils'; +import { getEnvExtApi, useEnvExtension } from '../../../envExt/api.internal'; +import { isParentPath } from '../../../pythonEnvironments/common/externalDependencies'; + +/** Dependencies for project-based test execution. */ +export interface ProjectExecutionDependencies { + projectRegistry: TestProjectRegistry; + pythonExecFactory: IPythonExecutionFactory; + debugLauncher: ITestDebugLauncher; +} + +/** Executes tests for multiple projects, grouping by project and using each project's Python environment. */ +export async function executeTestsForProjects( + projects: ProjectAdapter[], + testItems: TestItem[], + runInstance: TestRun, + request: TestRunRequest, + token: CancellationToken, + deps: ProjectExecutionDependencies, +): Promise { + if (projects.length === 0) { + traceError(`[test-by-project] No projects provided for execution`); + return; + } + + // Early exit if already cancelled + if (token.isCancellationRequested) { + traceInfo(`[test-by-project] Execution cancelled before starting`); + return; + } + + // Group test items by project + const testsByProject = await groupTestItemsByProject(testItems, projects); + + const isDebugMode = request.profile?.kind === TestRunProfileKind.Debug; + traceInfo(`[test-by-project] Executing tests across ${testsByProject.size} project(s), debug=${isDebugMode}`); + + // Setup coverage once for all projects (single callback that routes by file path) + if (request.profile?.kind === TestRunProfileKind.Coverage) { + setupCoverageForProjects(request, projects); + } + + // Execute tests for each project in parallel + // For debug mode, multiple debug sessions will be launched in parallel + // Each execution respects cancellation via runInstance.token + const executions = Array.from(testsByProject.entries()).map(async ([_projectId, { project, items }]) => { + // Check for cancellation before starting each project + if (token.isCancellationRequested) { + traceInfo(`[test-by-project] Skipping ${project.projectName} - cancellation requested`); + return; + } + + if (items.length === 0) return; + + traceInfo(`[test-by-project] Executing ${items.length} test item(s) for project: ${project.projectName}`); + + sendTelemetryEvent(EventName.UNITTEST_RUN, undefined, { + tool: project.testProvider, + debugging: isDebugMode, + }); + + try { + await executeTestsForProject(project, items, runInstance, request, deps); + } catch (error) { + // Don't log cancellation as an error + if (!token.isCancellationRequested) { + traceError(`[test-by-project] Execution failed for project ${project.projectName}:`, error); + } + } + }); + + await Promise.all(executions); + + if (token.isCancellationRequested) { + traceInfo(`[test-by-project] Project executions cancelled`); + } else { + traceInfo(`[test-by-project] All project executions completed`); + } +} + +/** Lookup context for caching project lookups within a single test run. */ +interface ProjectLookupContext { + uriToAdapter: Map; + projectPathToAdapter: Map; +} + +/** Groups test items by owning project using env API or path-based matching as fallback. */ +export async function groupTestItemsByProject( + testItems: TestItem[], + projects: ProjectAdapter[], +): Promise> { + const result = new Map(); + + // Initialize entries for all projects + for (const project of projects) { + result.set(getProjectId(project.projectUri), { project, items: [] }); + } + + // Build lookup context for this run - O(p) one-time setup, enables O(1) lookups per item. + // When tests are from a single project, most lookups hit the cache after the first item. + const lookupContext: ProjectLookupContext = { + uriToAdapter: new Map(), + projectPathToAdapter: new Map(projects.map((p) => [p.projectUri.fsPath, p])), + }; + + // Assign each test item to its project + for (const item of testItems) { + const project = await findProjectForTestItem(item, projects, lookupContext); + if (project) { + const entry = result.get(getProjectId(project.projectUri)); + if (entry) { + entry.items.push(item); + } + } else { + // If no project matches, log it + traceWarn(`[test-by-project] Could not match test item ${item.id} to a project`); + } + } + + // Remove projects with no test items + for (const [projectId, entry] of result.entries()) { + if (entry.items.length === 0) { + result.delete(projectId); + } + } + + return result; +} + +/** Finds the project that owns a test item. */ +export async function findProjectForTestItem( + item: TestItem, + projects: ProjectAdapter[], + lookupContext?: ProjectLookupContext, +): Promise { + if (!item.uri) return undefined; + + const uriPath = item.uri.fsPath; + + // Check lookup context first - O(1) + if (lookupContext?.uriToAdapter.has(uriPath)) { + return lookupContext.uriToAdapter.get(uriPath); + } + + let result: ProjectAdapter | undefined; + + // Try using the Python Environment extension API first. + // Legacy path: when useEnvExtension() is false, this block is skipped and we go + // directly to findProjectByPath() below (path-based matching). + if (useEnvExtension()) { + try { + const envExtApi = await getEnvExtApi(); + const pythonProject = envExtApi.getPythonProject(item.uri); + if (pythonProject) { + // Use lookup context for O(1) adapter lookup instead of O(p) linear search + result = lookupContext?.projectPathToAdapter.get(pythonProject.uri.fsPath); + if (!result) { + // Fallback to linear search if lookup context not available + result = projects.find((p) => p.projectUri.fsPath === pythonProject.uri.fsPath); + } + } + } catch (error) { + traceVerbose(`[test-by-project] Failed to use env extension API, falling back to path matching: ${error}`); + } + } + + // Fallback: path-based matching when env API unavailable or didn't find a match. + // O(p) time complexity where p = number of projects. + if (!result) { + result = findProjectByPath(item, projects); + } + + // Store result for future lookups of same file within this run - O(1) + if (lookupContext) { + lookupContext.uriToAdapter.set(uriPath, result); + } + + return result; +} + +/** Fallback: finds project using path-based matching. */ +function findProjectByPath(item: TestItem, projects: ProjectAdapter[]): ProjectAdapter | undefined { + if (!item.uri) return undefined; + + const itemPath = item.uri.fsPath; + let bestMatch: ProjectAdapter | undefined; + let bestMatchLength = 0; + + for (const project of projects) { + const projectPath = project.projectUri.fsPath; + // Use isParentPath for safe path-boundary matching (handles separators and case normalization) + if (isParentPath(itemPath, projectPath) && projectPath.length > bestMatchLength) { + bestMatch = project; + bestMatchLength = projectPath.length; + } + } + + return bestMatch; +} + +/** Executes tests for a single project using the project's Python environment. */ +export async function executeTestsForProject( + project: ProjectAdapter, + testItems: TestItem[], + runInstance: TestRun, + request: TestRunRequest, + deps: ProjectExecutionDependencies, +): Promise { + const processedTestItemIds = new Set(); + const uniqueTestCaseIds = new Set(); + + // Mark items as started and collect test IDs (deduplicated to handle overlapping selections) + for (const item of testItems) { + const testCaseNodes = getTestCaseNodesRecursive(item); + for (const node of testCaseNodes) { + if (processedTestItemIds.has(node.id)) { + continue; + } + processedTestItemIds.add(node.id); + runInstance.started(node); + const runId = project.resultResolver.vsIdToRunId.get(node.id); + if (runId) { + uniqueTestCaseIds.add(runId); + } + } + } + + const testCaseIds = Array.from(uniqueTestCaseIds); + + if (testCaseIds.length === 0) { + traceVerbose(`[test-by-project] No test IDs found for project ${project.projectName}`); + return; + } + + traceInfo(`[test-by-project] Running ${testCaseIds.length} test(s) for project: ${project.projectName}`); + + // Execute tests using the project's execution adapter + await project.executionAdapter.runTests( + project.projectUri, + testCaseIds, + request.profile?.kind, + runInstance, + deps.pythonExecFactory, + deps.debugLauncher, + undefined, // interpreter not needed, project has its own environment + project, + ); +} + +/** Recursively gets all leaf test case nodes from a test item tree. */ +export function getTestCaseNodesRecursive(item: TestItem): TestItem[] { + const results: TestItem[] = []; + if (item.children.size === 0) { + // This is a leaf node (test case) + results.push(item); + } else { + // Recursively get children + item.children.forEach((child) => { + results.push(...getTestCaseNodesRecursive(child)); + }); + } + return results; +} + +/** Sets up detailed coverage loading that routes to the correct project by file path. */ +export function setupCoverageForProjects(request: TestRunRequest, projects: ProjectAdapter[]): void { + if (request.profile?.kind === TestRunProfileKind.Coverage) { + // Create a single callback that routes to the correct project's coverage map by file path + request.profile.loadDetailedCoverage = ( + _testRun: TestRun, + fileCoverage, + _token, + ): Thenable => { + const filePath = fileCoverage.uri.fsPath; + // Find the project that has coverage data for this file + for (const project of projects) { + const details = project.resultResolver.detailedCoverageMap.get(filePath); + if (details) { + return Promise.resolve(details); + } + } + return Promise.resolve([]); + }; + } +} diff --git a/src/client/testing/testController/common/projectUtils.ts b/src/client/testing/testController/common/projectUtils.ts new file mode 100644 index 000000000000..b104b7f6842d --- /dev/null +++ b/src/client/testing/testController/common/projectUtils.ts @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { Uri } from 'vscode'; +import { IConfigurationService } from '../../../common/types'; +import { IEnvironmentVariablesProvider } from '../../../common/variables/types'; +import { UNITTEST_PROVIDER } from '../../common/constants'; +import { TestProvider } from '../../types'; +import { ITestDiscoveryAdapter, ITestExecutionAdapter, ITestResultResolver } from './types'; +import { UnittestTestDiscoveryAdapter } from '../unittest/testDiscoveryAdapter'; +import { UnittestTestExecutionAdapter } from '../unittest/testExecutionAdapter'; +import { PytestTestDiscoveryAdapter } from '../pytest/pytestDiscoveryAdapter'; +import { PytestTestExecutionAdapter } from '../pytest/pytestExecutionAdapter'; + +/** + * Separator used to scope test IDs to a specific project. + * Format: {projectId}{SEPARATOR}{testPath} + * Example: "file:///workspace/project@@PROJECT@@test_file.py::test_name" + */ +export const PROJECT_ID_SEPARATOR = '@@vsc@@'; + +/** + * Gets the project ID from a project URI. + * The project ID is simply the string representation of the URI, matching how + * the Python Environments extension stores projects in Map. + * + * @param projectUri The project URI + * @returns The project ID (URI as string) + */ +export function getProjectId(projectUri: Uri): string { + return projectUri.toString(); +} + +/** + * Parses a project-scoped vsId back into its components. + * + * @param vsId The VS Code test item ID to parse + * @returns A tuple of [projectId, runId]. If the ID is not project-scoped, + * returns [undefined, vsId] (legacy format) + */ +export function parseVsId(vsId: string): [string | undefined, string] { + const separatorIndex = vsId.indexOf(PROJECT_ID_SEPARATOR); + if (separatorIndex === -1) { + return [undefined, vsId]; // Legacy ID without project scope + } + return [vsId.substring(0, separatorIndex), vsId.substring(separatorIndex + PROJECT_ID_SEPARATOR.length)]; +} + +/** + * Creates a display name for a project including Python version. + * Format: "{projectName} (Python {version})" + * + * @param projectName The name of the project + * @param pythonVersion The Python version string (e.g., "3.11.2") + * @returns Formatted display name + */ +export function createProjectDisplayName(projectName: string, pythonVersion: string): string { + // Extract major.minor version if full version provided + const versionMatch = pythonVersion.match(/^(\d+\.\d+)/); + const shortVersion = versionMatch ? versionMatch[1] : pythonVersion; + + return `${projectName} (Python ${shortVersion})`; +} + +/** + * Creates test adapters (discovery and execution) for a given test provider. + * + * @param testProvider The test framework provider ('pytest' | 'unittest') + * @param resultResolver The result resolver to use for test results + * @param configSettings The configuration service + * @param envVarsService The environment variables provider + * @returns An object containing the discovery and execution adapters + */ +export function createTestAdapters( + testProvider: TestProvider, + resultResolver: ITestResultResolver, + configSettings: IConfigurationService, + envVarsService: IEnvironmentVariablesProvider, +): { discoveryAdapter: ITestDiscoveryAdapter; executionAdapter: ITestExecutionAdapter } { + if (testProvider === UNITTEST_PROVIDER) { + return { + discoveryAdapter: new UnittestTestDiscoveryAdapter(configSettings, resultResolver, envVarsService), + executionAdapter: new UnittestTestExecutionAdapter(configSettings, resultResolver, envVarsService), + }; + } + + return { + discoveryAdapter: new PytestTestDiscoveryAdapter(configSettings, resultResolver, envVarsService), + executionAdapter: new PytestTestExecutionAdapter(configSettings, resultResolver, envVarsService), + }; +} diff --git a/src/client/testing/testController/common/resultResolver.ts b/src/client/testing/testController/common/resultResolver.ts index b92e7a870f20..c126d233de1b 100644 --- a/src/client/testing/testController/common/resultResolver.ts +++ b/src/client/testing/testController/common/resultResolver.ts @@ -1,470 +1,137 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { - CancellationToken, - TestController, - TestItem, - Uri, - TestMessage, - Location, - TestRun, - MarkdownString, - TestCoverageCount, - FileCoverage, - FileCoverageDetail, - StatementCoverage, - Range, -} from 'vscode'; -import * as util from 'util'; -import { - CoveragePayload, - DiscoveredTestPayload, - ExecutionTestPayload, - FileCoverageMetrics, - ITestResultResolver, -} from './types'; +import { CancellationToken, TestController, TestItem, Uri, TestRun, FileCoverageDetail } from 'vscode'; +import { CoveragePayload, DiscoveredTestPayload, ExecutionTestPayload, ITestResultResolver } from './types'; import { TestProvider } from '../../types'; -import { traceError, traceVerbose } from '../../../logging'; -import { Testing } from '../../../common/utils/localize'; -import { clearAllChildren, createErrorTestItem, getTestCaseNodes } from './testItemUtilities'; +import { traceInfo } from '../../../logging'; import { sendTelemetryEvent } from '../../../telemetry'; import { EventName } from '../../../telemetry/constants'; -import { splitLines } from '../../../common/stringUtils'; -import { buildErrorNodeOptions, populateTestTree, splitTestNameWithRegex } from './utils'; +import { TestItemIndex } from './testItemIndex'; +import { TestDiscoveryHandler } from './testDiscoveryHandler'; +import { TestExecutionHandler } from './testExecutionHandler'; +import { TestCoverageHandler } from './testCoverageHandler'; export class PythonResultResolver implements ITestResultResolver { testController: TestController; testProvider: TestProvider; - public runIdToTestItem: Map; + private testItemIndex: TestItemIndex; - public runIdToVSid: Map; - - public vsIdToRunId: Map; - - public subTestStats: Map = new Map(); + // Shared singleton handlers + private static discoveryHandler: TestDiscoveryHandler = new TestDiscoveryHandler(); + private static executionHandler: TestExecutionHandler = new TestExecutionHandler(); + private static coverageHandler: TestCoverageHandler = new TestCoverageHandler(); public detailedCoverageMap = new Map(); - constructor(testController: TestController, testProvider: TestProvider, private workspaceUri: Uri) { + /** + * Optional project ID for scoping test IDs. + * When set, all test IDs are prefixed with `{projectId}@@vsc@@` for project-based testing. + * When undefined, uses legacy workspace-level IDs for backward compatibility. + */ + private projectId?: string; + + /** + * Optional project display name for labeling the test tree root. + * When set, the root node label will be "project: {projectName}" instead of the folder name. + */ + private projectName?: string; + + constructor( + testController: TestController, + testProvider: TestProvider, + private workspaceUri: Uri, + projectId?: string, + projectName?: string, + ) { this.testController = testController; this.testProvider = testProvider; - - this.runIdToTestItem = new Map(); - this.runIdToVSid = new Map(); - this.vsIdToRunId = new Map(); + this.projectId = projectId; + this.projectName = projectName; + // Initialize a new TestItemIndex which will be used to track test items in this workspace/project + this.testItemIndex = new TestItemIndex(); } - public resolveDiscovery(payload: DiscoveredTestPayload, token?: CancellationToken): void { - if (!payload) { - // No test data is available - } else { - this._resolveDiscovery(payload as DiscoveredTestPayload, token); - } + // Expose for backward compatibility (WorkspaceTestAdapter accesses these) + public get runIdToTestItem(): Map { + return this.testItemIndex.runIdToTestItemMap; } - public _resolveDiscovery(payload: DiscoveredTestPayload, token?: CancellationToken): void { - const workspacePath = this.workspaceUri.fsPath; - const rawTestData = payload as DiscoveredTestPayload; - // Check if there were any errors in the discovery process. - if (rawTestData.status === 'error') { - const testingErrorConst = - this.testProvider === 'pytest' ? Testing.errorPytestDiscovery : Testing.errorUnittestDiscovery; - const { error } = rawTestData; - traceError(testingErrorConst, 'for workspace: ', workspacePath, '\r\n', error?.join('\r\n\r\n') ?? ''); - - let errorNode = this.testController.items.get(`DiscoveryError:${workspacePath}`); - const message = util.format( - `${testingErrorConst} ${Testing.seePythonOutput}\r\n`, - error?.join('\r\n\r\n') ?? '', - ); - - if (errorNode === undefined) { - const options = buildErrorNodeOptions(this.workspaceUri, message, this.testProvider); - errorNode = createErrorTestItem(this.testController, options); - this.testController.items.add(errorNode); - } - const errorNodeLabel: MarkdownString = new MarkdownString( - `[Show output](command:python.viewOutput) to view error logs`, - ); - errorNodeLabel.isTrusted = true; - errorNode.error = errorNodeLabel; - } else { - // remove error node only if no errors exist. - this.testController.items.delete(`DiscoveryError:${workspacePath}`); - } - if (rawTestData.tests || rawTestData.tests === null) { - // if any tests exist, they should be populated in the test tree, regardless of whether there were errors or not. - // parse and insert test data. + public get runIdToVSid(): Map { + return this.testItemIndex.runIdToVSidMap; + } - // Clear existing mappings before rebuilding test tree - this.runIdToTestItem.clear(); - this.runIdToVSid.clear(); - this.vsIdToRunId.clear(); + public get vsIdToRunId(): Map { + return this.testItemIndex.vsIdToRunIdMap; + } - // If the test root for this folder exists: Workspace refresh, update its children. - // Otherwise, it is a freshly discovered workspace, and we need to create a new test root and populate the test tree. - populateTestTree(this.testController, rawTestData.tests, undefined, this, token); - } + /** + * Gets the project ID for this resolver (if any). + * Used for project-scoped test ID generation. + */ + public getProjectId(): string | undefined { + return this.projectId; + } + public resolveDiscovery(payload: DiscoveredTestPayload, token?: CancellationToken): void { + PythonResultResolver.discoveryHandler.processDiscovery( + payload, + this.testController, + this.testItemIndex, + this.workspaceUri, + this.testProvider, + token, + this.projectId, + this.projectName, + ); sendTelemetryEvent(EventName.UNITTEST_DISCOVERY_DONE, undefined, { tool: this.testProvider, failed: false, }); } + public _resolveDiscovery(payload: DiscoveredTestPayload, token?: CancellationToken): void { + // Delegate to the public method for backward compatibility + this.resolveDiscovery(payload, token); + } + public resolveExecution(payload: ExecutionTestPayload | CoveragePayload, runInstance: TestRun): void { if ('coverage' in payload) { // coverage data is sent once per connection - traceVerbose('Coverage data received.'); - this._resolveCoverage(payload as CoveragePayload, runInstance); + traceInfo('Coverage data received, processing...'); + this.detailedCoverageMap = PythonResultResolver.coverageHandler.processCoverage( + payload as CoveragePayload, + runInstance, + ); + traceInfo('Coverage data processing complete.'); } else { - this._resolveExecution(payload as ExecutionTestPayload, runInstance); - } - } - - public _resolveCoverage(payload: CoveragePayload, runInstance: TestRun): void { - if (payload.result === undefined) { - return; - } - for (const [key, value] of Object.entries(payload.result)) { - const fileNameStr = key; - const fileCoverageMetrics: FileCoverageMetrics = value; - const linesCovered = fileCoverageMetrics.lines_covered ? fileCoverageMetrics.lines_covered : []; // undefined if no lines covered - const linesMissed = fileCoverageMetrics.lines_missed ? fileCoverageMetrics.lines_missed : []; // undefined if no lines missed - const executedBranches = fileCoverageMetrics.executed_branches; - const totalBranches = fileCoverageMetrics.total_branches; - - const lineCoverageCount = new TestCoverageCount( - linesCovered.length, - linesCovered.length + linesMissed.length, + PythonResultResolver.executionHandler.processExecution( + payload as ExecutionTestPayload, + runInstance, + this.testItemIndex, + this.testController, ); - let fileCoverage: FileCoverage; - const uri = Uri.file(fileNameStr); - if (totalBranches === -1) { - // branch coverage was not enabled and should not be displayed - fileCoverage = new FileCoverage(uri, lineCoverageCount); - } else { - const branchCoverageCount = new TestCoverageCount(executedBranches, totalBranches); - fileCoverage = new FileCoverage(uri, lineCoverageCount, branchCoverageCount); - } - runInstance.addCoverage(fileCoverage); - - // create detailed coverage array for each file (only line coverage on detailed, not branch) - const detailedCoverageArray: FileCoverageDetail[] = []; - // go through all covered lines, create new StatementCoverage, and add to detailedCoverageArray - for (const line of linesCovered) { - // line is 1-indexed, so we need to subtract 1 to get the 0-indexed line number - // true value means line is covered - const statementCoverage = new StatementCoverage( - true, - new Range(line - 1, 0, line - 1, Number.MAX_SAFE_INTEGER), - ); - detailedCoverageArray.push(statementCoverage); - } - for (const line of linesMissed) { - // line is 1-indexed, so we need to subtract 1 to get the 0-indexed line number - // false value means line is NOT covered - const statementCoverage = new StatementCoverage( - false, - new Range(line - 1, 0, line - 1, Number.MAX_SAFE_INTEGER), - ); - detailedCoverageArray.push(statementCoverage); - } - - this.detailedCoverageMap.set(uri.fsPath, detailedCoverageArray); } } - /** - * Collect all test case items from the test controller tree. - * Note: This performs full tree traversal - use cached lookups when possible. - */ - private collectAllTestCases(): TestItem[] { - const testCases: TestItem[] = []; - - this.testController.items.forEach((i) => { - const tempArr: TestItem[] = getTestCaseNodes(i); - testCases.push(...tempArr); - }); - - return testCases; - } - - /** - * Find a test item efficiently using cached maps with fallback strategies. - * Uses a three-tier approach: direct lookup, ID mapping, then tree search. - */ - private findTestItemByIdEfficient(keyTemp: string): TestItem | undefined { - // Try direct O(1) lookup first - const directItem = this.runIdToTestItem.get(keyTemp); - if (directItem) { - // Validate the item is still in the test tree - if (this.isTestItemValid(directItem)) { - return directItem; - } else { - // Clean up stale reference - this.runIdToTestItem.delete(keyTemp); - } - } - - // Try vsId mapping as fallback - const vsId = this.runIdToVSid.get(keyTemp); - if (vsId) { - // Search by VS Code ID in the controller - let foundItem: TestItem | undefined; - this.testController.items.forEach((item) => { - if (item.id === vsId) { - foundItem = item; - return; - } - if (!foundItem) { - item.children.forEach((child) => { - if (child.id === vsId) { - foundItem = child; - } - }); - } - }); - - if (foundItem) { - // Cache for future lookups - this.runIdToTestItem.set(keyTemp, foundItem); - return foundItem; - } else { - // Clean up stale mapping - this.runIdToVSid.delete(keyTemp); - this.vsIdToRunId.delete(vsId); - } - } - - // Last resort: full tree search - traceError(`Falling back to tree search for test: ${keyTemp}`); - const testCases = this.collectAllTestCases(); - return testCases.find((item) => item.id === vsId); + public _resolveExecution(payload: ExecutionTestPayload, runInstance: TestRun): void { + // Delegate to the public method for backward compatibility + this.resolveExecution(payload, runInstance); } - /** - * Check if a TestItem is still valid (exists in the TestController tree) - * - * Time Complexity: O(depth) where depth is the maximum nesting level of the test tree. - * In most cases this is O(1) to O(3) since test trees are typically shallow. - */ - private isTestItemValid(testItem: TestItem): boolean { - // Simple validation: check if the item's parent chain leads back to the controller - let current: TestItem | undefined = testItem; - while (current?.parent) { - current = current.parent; - } - - // If we reached a root item, check if it's in the controller - if (current) { - return this.testController.items.get(current.id) === current; - } - - // If no parent chain, check if it's directly in the controller - return this.testController.items.get(testItem.id) === testItem; + public _resolveCoverage(payload: CoveragePayload, runInstance: TestRun): void { + // Delegate to the public method for backward compatibility + this.resolveExecution(payload, runInstance); } /** * Clean up stale test item references from the cache maps. * Validates cached items and removes any that are no longer in the test tree. + * Delegates to TestItemIndex. */ public cleanupStaleReferences(): void { - const staleRunIds: string[] = []; - - // Check all runId->TestItem mappings - this.runIdToTestItem.forEach((testItem, runId) => { - if (!this.isTestItemValid(testItem)) { - staleRunIds.push(runId); - } - }); - - // Remove stale entries - staleRunIds.forEach((runId) => { - const vsId = this.runIdToVSid.get(runId); - this.runIdToTestItem.delete(runId); - this.runIdToVSid.delete(runId); - if (vsId) { - this.vsIdToRunId.delete(vsId); - } - }); - - if (staleRunIds.length > 0) { - traceVerbose(`Cleaned up ${staleRunIds.length} stale test item references`); - } - } - - /** - * Handle test items that errored during execution. - * Extracts error details, finds the corresponding TestItem, and reports the error to VS Code's Test Explorer. - */ - private handleTestError(keyTemp: string, testItem: any, runInstance: TestRun): void { - const rawTraceback = testItem.traceback ?? ''; - const traceback = splitLines(rawTraceback, { - trim: false, - removeEmptyEntries: true, - }).join('\r\n'); - const text = `${testItem.test} failed with error: ${testItem.message ?? testItem.outcome}\r\n${traceback}`; - const message = new TestMessage(text); - - const foundItem = this.findTestItemByIdEfficient(keyTemp); - - if (foundItem?.uri) { - if (foundItem.range) { - message.location = new Location(foundItem.uri, foundItem.range); - } - runInstance.errored(foundItem, message); - } - } - - /** - * Handle test items that failed during execution - */ - private handleTestFailure(keyTemp: string, testItem: any, runInstance: TestRun): void { - const rawTraceback = testItem.traceback ?? ''; - const traceback = splitLines(rawTraceback, { - trim: false, - removeEmptyEntries: true, - }).join('\r\n'); - - const text = `${testItem.test} failed: ${testItem.message ?? testItem.outcome}\r\n${traceback}`; - const message = new TestMessage(text); - - const foundItem = this.findTestItemByIdEfficient(keyTemp); - - if (foundItem?.uri) { - if (foundItem.range) { - message.location = new Location(foundItem.uri, foundItem.range); - } - runInstance.failed(foundItem, message); - } - } - - /** - * Handle test items that passed during execution - */ - private handleTestSuccess(keyTemp: string, runInstance: TestRun): void { - const grabTestItem = this.runIdToTestItem.get(keyTemp); - - if (grabTestItem !== undefined) { - const foundItem = this.findTestItemByIdEfficient(keyTemp); - if (foundItem?.uri) { - runInstance.passed(grabTestItem); - } - } - } - - /** - * Handle test items that were skipped during execution - */ - private handleTestSkipped(keyTemp: string, runInstance: TestRun): void { - const grabTestItem = this.runIdToTestItem.get(keyTemp); - - if (grabTestItem !== undefined) { - const foundItem = this.findTestItemByIdEfficient(keyTemp); - if (foundItem?.uri) { - runInstance.skipped(grabTestItem); - } - } - } - - /** - * Handle subtest failures - */ - private handleSubtestFailure(keyTemp: string, testItem: any, runInstance: TestRun): void { - const [parentTestCaseId, subtestId] = splitTestNameWithRegex(keyTemp); - const parentTestItem = this.runIdToTestItem.get(parentTestCaseId); - - if (parentTestItem) { - const subtestStats = this.subTestStats.get(parentTestCaseId); - if (subtestStats) { - subtestStats.failed += 1; - } else { - this.subTestStats.set(parentTestCaseId, { - failed: 1, - passed: 0, - }); - clearAllChildren(parentTestItem); - } - - const subTestItem = this.testController?.createTestItem(subtestId, subtestId, parentTestItem.uri); - - if (subTestItem) { - const traceback = testItem.traceback ?? ''; - const text = `${testItem.subtest} failed: ${testItem.message ?? testItem.outcome}\r\n${traceback}`; - parentTestItem.children.add(subTestItem); - runInstance.started(subTestItem); - const message = new TestMessage(text); - if (parentTestItem.uri && parentTestItem.range) { - message.location = new Location(parentTestItem.uri, parentTestItem.range); - } - runInstance.failed(subTestItem, message); - } else { - throw new Error('Unable to create new child node for subtest'); - } - } else { - throw new Error('Parent test item not found'); - } - } - - /** - * Handle subtest successes - */ - private handleSubtestSuccess(keyTemp: string, runInstance: TestRun): void { - const [parentTestCaseId, subtestId] = splitTestNameWithRegex(keyTemp); - const parentTestItem = this.runIdToTestItem.get(parentTestCaseId); - - if (parentTestItem) { - const subtestStats = this.subTestStats.get(parentTestCaseId); - if (subtestStats) { - subtestStats.passed += 1; - } else { - this.subTestStats.set(parentTestCaseId, { failed: 0, passed: 1 }); - clearAllChildren(parentTestItem); - } - - const subTestItem = this.testController?.createTestItem(subtestId, subtestId, parentTestItem.uri); - - if (subTestItem) { - parentTestItem.children.add(subTestItem); - runInstance.started(subTestItem); - runInstance.passed(subTestItem); - } else { - throw new Error('Unable to create new child node for subtest'); - } - } else { - throw new Error('Parent test item not found'); - } - } - - /** - * Process test execution results and update VS Code's Test Explorer with outcomes. - * Uses efficient lookup methods to handle large numbers of test results. - */ - public _resolveExecution(payload: ExecutionTestPayload, runInstance: TestRun): void { - const rawTestExecData = payload as ExecutionTestPayload; - if (rawTestExecData !== undefined && rawTestExecData.result !== undefined) { - for (const keyTemp of Object.keys(rawTestExecData.result)) { - const testItem = rawTestExecData.result[keyTemp]; - - // Delegate to specific outcome handlers using efficient lookups - if (testItem.outcome === 'error') { - this.handleTestError(keyTemp, testItem, runInstance); - } else if (testItem.outcome === 'failure' || testItem.outcome === 'passed-unexpected') { - this.handleTestFailure(keyTemp, testItem, runInstance); - } else if (testItem.outcome === 'success' || testItem.outcome === 'expected-failure') { - this.handleTestSuccess(keyTemp, runInstance); - } else if (testItem.outcome === 'skipped') { - this.handleTestSkipped(keyTemp, runInstance); - } else if (testItem.outcome === 'subtest-failure') { - this.handleSubtestFailure(keyTemp, testItem, runInstance); - } else if (testItem.outcome === 'subtest-success') { - this.handleSubtestSuccess(keyTemp, runInstance); - } - } - } + this.testItemIndex.cleanupStaleReferences(this.testController); } } diff --git a/src/client/testing/testController/common/testCoverageHandler.ts b/src/client/testing/testController/common/testCoverageHandler.ts new file mode 100644 index 000000000000..81ec80579730 --- /dev/null +++ b/src/client/testing/testController/common/testCoverageHandler.ts @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { TestRun, Uri, TestCoverageCount, FileCoverage, FileCoverageDetail, StatementCoverage, Range } from 'vscode'; +import { CoveragePayload, FileCoverageMetrics } from './types'; + +/** + * Stateless handler for processing coverage payloads and creating coverage objects. + * This handler is shared across all workspaces and contains no instance state. + */ +export class TestCoverageHandler { + /** + * Process coverage payload + * Pure function - returns coverage data without storing it + */ + public processCoverage(payload: CoveragePayload, runInstance: TestRun): Map { + const detailedCoverageMap = new Map(); + + if (payload.result === undefined) { + return detailedCoverageMap; + } + + for (const [key, value] of Object.entries(payload.result)) { + const fileNameStr = key; + const fileCoverageMetrics: FileCoverageMetrics = value; + + // Create FileCoverage object and add to run instance + const fileCoverage = this.createFileCoverage(Uri.file(fileNameStr), fileCoverageMetrics); + runInstance.addCoverage(fileCoverage); + + // Create detailed coverage array for this file + const detailedCoverage = this.createDetailedCoverage( + fileCoverageMetrics.lines_covered ?? [], + fileCoverageMetrics.lines_missed ?? [], + ); + detailedCoverageMap.set(Uri.file(fileNameStr).fsPath, detailedCoverage); + } + + return detailedCoverageMap; + } + + /** + * Create FileCoverage object from metrics + */ + private createFileCoverage(uri: Uri, metrics: FileCoverageMetrics): FileCoverage { + const linesCovered = metrics.lines_covered ?? []; + const linesMissed = metrics.lines_missed ?? []; + const executedBranches = metrics.executed_branches; + const totalBranches = metrics.total_branches; + + const lineCoverageCount = new TestCoverageCount(linesCovered.length, linesCovered.length + linesMissed.length); + + if (totalBranches === -1) { + // branch coverage was not enabled and should not be displayed + return new FileCoverage(uri, lineCoverageCount); + } else { + const branchCoverageCount = new TestCoverageCount(executedBranches, totalBranches); + return new FileCoverage(uri, lineCoverageCount, branchCoverageCount); + } + } + + /** + * Create detailed coverage array for a file + * Only line coverage on detailed, not branch coverage + */ + private createDetailedCoverage(linesCovered: number[], linesMissed: number[]): FileCoverageDetail[] { + const detailedCoverageArray: FileCoverageDetail[] = []; + + // Add covered lines + for (const line of linesCovered) { + // line is 1-indexed, so we need to subtract 1 to get the 0-indexed line number + // true value means line is covered + const statementCoverage = new StatementCoverage( + true, + new Range(line - 1, 0, line - 1, Number.MAX_SAFE_INTEGER), + ); + detailedCoverageArray.push(statementCoverage); + } + + // Add missed lines + for (const line of linesMissed) { + // line is 1-indexed, so we need to subtract 1 to get the 0-indexed line number + // false value means line is NOT covered + const statementCoverage = new StatementCoverage( + false, + new Range(line - 1, 0, line - 1, Number.MAX_SAFE_INTEGER), + ); + detailedCoverageArray.push(statementCoverage); + } + + return detailedCoverageArray; + } +} diff --git a/src/client/testing/testController/common/testDiscoveryHandler.ts b/src/client/testing/testController/common/testDiscoveryHandler.ts new file mode 100644 index 000000000000..3f70e6b68594 --- /dev/null +++ b/src/client/testing/testController/common/testDiscoveryHandler.ts @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { CancellationToken, TestController, Uri, MarkdownString } from 'vscode'; +import * as util from 'util'; +import { DiscoveredTestPayload } from './types'; +import { TestProvider } from '../../types'; +import { traceError, traceWarn } from '../../../logging'; +import { Testing } from '../../../common/utils/localize'; +import { createErrorTestItem } from './testItemUtilities'; +import { buildErrorNodeOptions, populateTestTree } from './utils'; +import { TestItemIndex } from './testItemIndex'; +import { PROJECT_ID_SEPARATOR } from './projectUtils'; + +/** + * Stateless handler for processing discovery payloads and building/updating the TestItem tree. + * This handler is shared across all workspaces and contains no instance state. + */ +export class TestDiscoveryHandler { + /** + * Process discovery payload and update test tree + * Pure function - no instance state used + */ + public processDiscovery( + payload: DiscoveredTestPayload, + testController: TestController, + testItemIndex: TestItemIndex, + workspaceUri: Uri, + testProvider: TestProvider, + token?: CancellationToken, + projectId?: string, + projectName?: string, + ): void { + if (!payload) { + // No test data is available + return; + } + + const workspacePath = workspaceUri.fsPath; + const rawTestData = payload as DiscoveredTestPayload; + + // Check if there were any errors in the discovery process. + if (rawTestData.status === 'error') { + this.createErrorNode(testController, workspaceUri, rawTestData.error, testProvider, projectId, projectName); + } else { + // remove error node only if no errors exist. + const errorNodeId = projectId + ? `${projectId}${PROJECT_ID_SEPARATOR}DiscoveryError:${workspacePath}` + : `DiscoveryError:${workspacePath}`; + testController.items.delete(errorNodeId); + } + + if (rawTestData.tests || rawTestData.tests === null) { + // if any tests exist, they should be populated in the test tree, regardless of whether there were errors or not. + // parse and insert test data. + + // Clear existing mappings before rebuilding test tree + testItemIndex.clear(); + + // If the test root for this folder exists: Workspace refresh, update its children. + // Otherwise, it is a freshly discovered workspace, and we need to create a new test root and populate the test tree. + // Note: populateTestTree will call testItemIndex.registerTestItem() for each discovered test + populateTestTree( + testController, + rawTestData.tests, + undefined, + { + runIdToTestItem: testItemIndex.runIdToTestItemMap, + runIdToVSid: testItemIndex.runIdToVSidMap, + vsIdToRunId: testItemIndex.vsIdToRunIdMap, + }, + token, + projectId, + projectName, + ); + } + } + + /** + * Create an error node for discovery failures + */ + public createErrorNode( + testController: TestController, + workspaceUri: Uri, + error: string[] | undefined, + testProvider: TestProvider, + projectId?: string, + projectName?: string, + ): void { + const workspacePath = workspaceUri.fsPath; + const testingErrorConst = + testProvider === 'pytest' ? Testing.errorPytestDiscovery : Testing.errorUnittestDiscovery; + + traceError(testingErrorConst, 'for workspace: ', workspacePath, '\r\n', error?.join('\r\n\r\n') ?? ''); + + // For unittest in project-based mode, check if the error might be caused by nested project imports + // This helps users understand that import errors from nested projects can be safely ignored + // if those tests are covered by a different project with the correct environment. + if (testProvider === 'unittest' && projectId) { + const errorText = error?.join(' ') ?? ''; + const isImportError = + errorText.includes('ModuleNotFoundError') || + errorText.includes('ImportError') || + errorText.includes('No module named'); + + if (isImportError) { + const warningMessage = + '--- ' + + `[test-by-project] Import error during unittest discovery for project at ${workspacePath}. ` + + 'This may be caused by test files in nested project directories that require different dependencies. ' + + 'If these tests are discovered successfully by their own project (with the correct Python environment), ' + + 'this error can be safely ignored. To avoid this, consider excluding nested project paths from parent project discovery. ' + + '---'; + traceWarn(warningMessage); + } + } + + const errorNodeId = projectId + ? `${projectId}${PROJECT_ID_SEPARATOR}DiscoveryError:${workspacePath}` + : `DiscoveryError:${workspacePath}`; + let errorNode = testController.items.get(errorNodeId); + const message = util.format( + `${testingErrorConst} ${Testing.seePythonOutput}\r\n`, + error?.join('\r\n\r\n') ?? '', + ); + + if (errorNode === undefined) { + const options = buildErrorNodeOptions(workspaceUri, message, testProvider, projectName); + // Update the error node ID to include project scope if applicable + options.id = errorNodeId; + errorNode = createErrorTestItem(testController, options); + testController.items.add(errorNode); + } + + const errorNodeLabel: MarkdownString = new MarkdownString( + `[Show output](command:python.viewOutput) to view error logs`, + ); + errorNodeLabel.isTrusted = true; + errorNode.error = errorNodeLabel; + } +} diff --git a/src/client/testing/testController/common/testExecutionHandler.ts b/src/client/testing/testController/common/testExecutionHandler.ts new file mode 100644 index 000000000000..127e6980ae46 --- /dev/null +++ b/src/client/testing/testController/common/testExecutionHandler.ts @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { TestController, TestRun, TestMessage, Location } from 'vscode'; +import { ExecutionTestPayload } from './types'; +import { TestItemIndex } from './testItemIndex'; +import { splitLines } from '../../../common/stringUtils'; +import { splitTestNameWithRegex } from './utils'; +import { clearAllChildren } from './testItemUtilities'; + +/** + * Stateless handler for processing execution payloads and updating TestRun instances. + * This handler is shared across all workspaces and contains no instance state. + */ +export class TestExecutionHandler { + /** + * Process execution payload and update test run + * Pure function - no instance state used + */ + public processExecution( + payload: ExecutionTestPayload, + runInstance: TestRun, + testItemIndex: TestItemIndex, + testController: TestController, + ): void { + const rawTestExecData = payload as ExecutionTestPayload; + + if (rawTestExecData !== undefined && rawTestExecData.result !== undefined) { + for (const keyTemp of Object.keys(rawTestExecData.result)) { + const testItem = rawTestExecData.result[keyTemp]; + + // Delegate to specific outcome handlers + this.handleTestOutcome(keyTemp, testItem, runInstance, testItemIndex, testController); + } + } + } + + /** + * Handle a single test result based on outcome + */ + private handleTestOutcome( + runId: string, + testItem: any, + runInstance: TestRun, + testItemIndex: TestItemIndex, + testController: TestController, + ): void { + if (testItem.outcome === 'error') { + this.handleTestError(runId, testItem, runInstance, testItemIndex, testController); + } else if (testItem.outcome === 'failure' || testItem.outcome === 'passed-unexpected') { + this.handleTestFailure(runId, testItem, runInstance, testItemIndex, testController); + } else if (testItem.outcome === 'success' || testItem.outcome === 'expected-failure') { + this.handleTestSuccess(runId, runInstance, testItemIndex, testController); + } else if (testItem.outcome === 'skipped') { + this.handleTestSkipped(runId, runInstance, testItemIndex, testController); + } else if (testItem.outcome === 'subtest-failure') { + this.handleSubtestFailure(runId, testItem, runInstance, testItemIndex, testController); + } else if (testItem.outcome === 'subtest-success') { + this.handleSubtestSuccess(runId, runInstance, testItemIndex, testController); + } + } + + /** + * Handle test items that errored during execution + */ + private handleTestError( + runId: string, + testItem: any, + runInstance: TestRun, + testItemIndex: TestItemIndex, + testController: TestController, + ): void { + const rawTraceback = testItem.traceback ?? ''; + const traceback = splitLines(rawTraceback, { + trim: false, + removeEmptyEntries: true, + }).join('\r\n'); + const text = `${testItem.test} failed with error: ${testItem.message ?? testItem.outcome}\r\n${traceback}`; + const message = new TestMessage(text); + + const foundItem = testItemIndex.getTestItem(runId, testController); + + if (foundItem?.uri) { + if (foundItem.range) { + message.location = new Location(foundItem.uri, foundItem.range); + } + runInstance.errored(foundItem, message); + } + } + + /** + * Handle test items that failed during execution + */ + private handleTestFailure( + runId: string, + testItem: any, + runInstance: TestRun, + testItemIndex: TestItemIndex, + testController: TestController, + ): void { + const rawTraceback = testItem.traceback ?? ''; + const traceback = splitLines(rawTraceback, { + trim: false, + removeEmptyEntries: true, + }).join('\r\n'); + + const text = `${testItem.test} failed: ${testItem.message ?? testItem.outcome}\r\n${traceback}`; + const message = new TestMessage(text); + + const foundItem = testItemIndex.getTestItem(runId, testController); + + if (foundItem?.uri) { + if (foundItem.range) { + message.location = new Location(foundItem.uri, foundItem.range); + } + runInstance.failed(foundItem, message); + } + } + + /** + * Handle test items that passed during execution + */ + private handleTestSuccess( + runId: string, + runInstance: TestRun, + testItemIndex: TestItemIndex, + testController: TestController, + ): void { + const foundItem = testItemIndex.getTestItem(runId, testController); + + if (foundItem !== undefined && foundItem.uri) { + runInstance.passed(foundItem); + } + } + + /** + * Handle test items that were skipped during execution + */ + private handleTestSkipped( + runId: string, + runInstance: TestRun, + testItemIndex: TestItemIndex, + testController: TestController, + ): void { + const foundItem = testItemIndex.getTestItem(runId, testController); + + if (foundItem !== undefined && foundItem.uri) { + runInstance.skipped(foundItem); + } + } + + /** + * Handle subtest failures + */ + private handleSubtestFailure( + runId: string, + testItem: any, + runInstance: TestRun, + testItemIndex: TestItemIndex, + testController: TestController, + ): void { + const [parentTestCaseId, subtestId] = splitTestNameWithRegex(runId); + const parentTestItem = testItemIndex.getTestItem(parentTestCaseId, testController); + + if (parentTestItem) { + const stats = testItemIndex.getSubtestStats(parentTestCaseId); + if (stats) { + stats.failed += 1; + } else { + testItemIndex.setSubtestStats(parentTestCaseId, { + failed: 1, + passed: 0, + }); + clearAllChildren(parentTestItem); + } + + const subTestItem = testController?.createTestItem(subtestId, subtestId, parentTestItem.uri); + + if (subTestItem) { + const traceback = testItem.traceback ?? ''; + const text = `${testItem.subtest} failed: ${testItem.message ?? testItem.outcome}\r\n${traceback}`; + parentTestItem.children.add(subTestItem); + runInstance.started(subTestItem); + const message = new TestMessage(text); + if (parentTestItem.uri && parentTestItem.range) { + message.location = new Location(parentTestItem.uri, parentTestItem.range); + } + runInstance.failed(subTestItem, message); + } else { + throw new Error('Unable to create new child node for subtest'); + } + } else { + throw new Error('Parent test item not found'); + } + } + + /** + * Handle subtest successes + */ + private handleSubtestSuccess( + runId: string, + runInstance: TestRun, + testItemIndex: TestItemIndex, + testController: TestController, + ): void { + const [parentTestCaseId, subtestId] = splitTestNameWithRegex(runId); + const parentTestItem = testItemIndex.getTestItem(parentTestCaseId, testController); + + if (parentTestItem) { + const stats = testItemIndex.getSubtestStats(parentTestCaseId); + if (stats) { + stats.passed += 1; + } else { + testItemIndex.setSubtestStats(parentTestCaseId, { failed: 0, passed: 1 }); + clearAllChildren(parentTestItem); + } + + const subTestItem = testController?.createTestItem(subtestId, subtestId, parentTestItem.uri); + + if (subTestItem) { + parentTestItem.children.add(subTestItem); + runInstance.started(subTestItem); + runInstance.passed(subTestItem); + } else { + throw new Error('Unable to create new child node for subtest'); + } + } else { + throw new Error('Parent test item not found'); + } + } +} diff --git a/src/client/testing/testController/common/testItemIndex.ts b/src/client/testing/testController/common/testItemIndex.ts new file mode 100644 index 000000000000..448903eae7d5 --- /dev/null +++ b/src/client/testing/testController/common/testItemIndex.ts @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { TestController, TestItem } from 'vscode'; +import { traceError, traceVerbose } from '../../../logging'; +import { getTestCaseNodes } from './testItemUtilities'; + +export interface SubtestStats { + passed: number; + failed: number; +} + +/** + * Maintains persistent ID mappings between Python test IDs and VS Code TestItems. + * This is a stateful component that bridges discovery and execution phases. + * + * Lifecycle: + * - Created: When PythonResultResolver is instantiated (during workspace activation) + * - Populated: During discovery - each discovered test registers its mappings + * - Queried: During execution - to look up TestItems by Python run ID + * - Cleared: When discovery runs again (fresh start) or workspace is disposed + * - Cleaned: Periodically to remove stale references to deleted tests + */ +export class TestItemIndex { + // THE STATE - these maps persist across discovery and execution + private runIdToTestItem: Map; + private runIdToVSid: Map; + private vsIdToRunId: Map; + private subtestStatsMap: Map; + + constructor() { + this.runIdToTestItem = new Map(); + this.runIdToVSid = new Map(); + this.vsIdToRunId = new Map(); + this.subtestStatsMap = new Map(); + } + + /** + * Register a test item with its Python run ID and VS Code ID + * Called during DISCOVERY to populate the index + */ + public registerTestItem(runId: string, vsId: string, testItem: TestItem): void { + this.runIdToTestItem.set(runId, testItem); + this.runIdToVSid.set(runId, vsId); + this.vsIdToRunId.set(vsId, runId); + } + + /** + * Get TestItem by Python run ID (with validation and fallback strategies) + * Called during EXECUTION to look up tests + * + * Uses a three-tier approach: + * 1. Direct O(1) lookup in runIdToTestItem map + * 2. If stale, try vsId mapping and search by VS Code ID + * 3. Last resort: full tree search + */ + public getTestItem(runId: string, testController: TestController): TestItem | undefined { + // Try direct O(1) lookup first + const directItem = this.runIdToTestItem.get(runId); + if (directItem) { + // Validate the item is still in the test tree + if (this.isTestItemValid(directItem, testController)) { + return directItem; + } else { + // Clean up stale reference + this.runIdToTestItem.delete(runId); + } + } + + // Try vsId mapping as fallback + const vsId = this.runIdToVSid.get(runId); + if (vsId) { + // Search by VS Code ID in the controller + let foundItem: TestItem | undefined; + testController.items.forEach((item) => { + if (item.id === vsId) { + foundItem = item; + return; + } + if (!foundItem) { + item.children.forEach((child) => { + if (child.id === vsId) { + foundItem = child; + } + }); + } + }); + + if (foundItem) { + // Cache for future lookups + this.runIdToTestItem.set(runId, foundItem); + return foundItem; + } else { + // Clean up stale mapping + this.runIdToVSid.delete(runId); + this.vsIdToRunId.delete(vsId); + } + } + + // Last resort: full tree search + traceError(`Falling back to tree search for test: ${runId}`); + const testCases = this.collectAllTestCases(testController); + return testCases.find((item) => item.id === vsId); + } + + /** + * Get Python run ID from VS Code ID + * Called by WorkspaceTestAdapter.executeTests() to convert selected tests to Python IDs + */ + public getRunId(vsId: string): string | undefined { + return this.vsIdToRunId.get(vsId); + } + + /** + * Get VS Code ID from Python run ID + */ + public getVSId(runId: string): string | undefined { + return this.runIdToVSid.get(runId); + } + + /** + * Check if a TestItem reference is still valid in the tree + * + * Time Complexity: O(depth) where depth is the maximum nesting level of the test tree. + * In most cases this is O(1) to O(3) since test trees are typically shallow. + */ + public isTestItemValid(testItem: TestItem, testController: TestController): boolean { + // Simple validation: check if the item's parent chain leads back to the controller + let current: TestItem | undefined = testItem; + while (current?.parent) { + current = current.parent; + } + + // If we reached a root item, check if it's in the controller + if (current) { + return testController.items.get(current.id) === current; + } + + // If no parent chain, check if it's directly in the controller + return testController.items.get(testItem.id) === testItem; + } + + /** + * Get subtest statistics for a parent test case + * Returns undefined if no stats exist yet for this parent + */ + public getSubtestStats(parentId: string): SubtestStats | undefined { + return this.subtestStatsMap.get(parentId); + } + + /** + * Set subtest statistics for a parent test case + */ + public setSubtestStats(parentId: string, stats: SubtestStats): void { + this.subtestStatsMap.set(parentId, stats); + } + + /** + * Remove all mappings + * Called at the start of discovery to ensure clean state + */ + public clear(): void { + this.runIdToTestItem.clear(); + this.runIdToVSid.clear(); + this.vsIdToRunId.clear(); + this.subtestStatsMap.clear(); + } + + /** + * Clean up stale references that no longer exist in the test tree + * Called after test tree modifications + */ + public cleanupStaleReferences(testController: TestController): void { + const staleRunIds: string[] = []; + + // Check all runId->TestItem mappings + this.runIdToTestItem.forEach((testItem, runId) => { + if (!this.isTestItemValid(testItem, testController)) { + staleRunIds.push(runId); + } + }); + + // Remove stale entries + staleRunIds.forEach((runId) => { + const vsId = this.runIdToVSid.get(runId); + this.runIdToTestItem.delete(runId); + this.runIdToVSid.delete(runId); + if (vsId) { + this.vsIdToRunId.delete(vsId); + } + }); + + if (staleRunIds.length > 0) { + traceVerbose(`Cleaned up ${staleRunIds.length} stale test item references`); + } + } + + /** + * Collect all test case items from the test controller tree. + * Note: This performs full tree traversal - use cached lookups when possible. + */ + private collectAllTestCases(testController: TestController): TestItem[] { + const testCases: TestItem[] = []; + + testController.items.forEach((i) => { + const tempArr: TestItem[] = getTestCaseNodes(i); + testCases.push(...tempArr); + }); + + return testCases; + } + + // Expose maps for backward compatibility (read-only access) + public get runIdToTestItemMap(): Map { + return this.runIdToTestItem; + } + + public get runIdToVSidMap(): Map { + return this.runIdToVSid; + } + + public get vsIdToRunIdMap(): Map { + return this.vsIdToRunId; + } +} diff --git a/src/client/testing/testController/common/testProjectRegistry.ts b/src/client/testing/testController/common/testProjectRegistry.ts new file mode 100644 index 000000000000..4f0702ad584c --- /dev/null +++ b/src/client/testing/testController/common/testProjectRegistry.ts @@ -0,0 +1,330 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as path from 'path'; +import { TestController, Uri } from 'vscode'; +import { isParentPath } from '../../../common/platform/fs-paths'; +import { IConfigurationService } from '../../../common/types'; +import { IInterpreterService } from '../../../interpreter/contracts'; +import { traceError, traceInfo } from '../../../logging'; +import { UNITTEST_PROVIDER } from '../../common/constants'; +import { TestProvider } from '../../types'; +import { IEnvironmentVariablesProvider } from '../../../common/variables/types'; +import { PythonProject, PythonEnvironment } from '../../../envExt/types'; +import { getEnvExtApi, useEnvExtension } from '../../../envExt/api.internal'; +import { ProjectAdapter } from './projectAdapter'; +import { getProjectId, createProjectDisplayName, createTestAdapters } from './projectUtils'; +import { PythonResultResolver } from './resultResolver'; + +/** + * Registry for Python test projects within workspaces. + * + * Manages the lifecycle of test projects including: + * - Discovering Python projects via Python Environments API + * - Creating and storing ProjectAdapter instances per workspace + * - Computing nested project relationships for ignore lists + * - Fallback to default "legacy" project when API unavailable + * + * **Key concepts:** + * - **Workspace:** A VS Code workspace folder (may contain multiple projects) + * - **Project:** A Python project within a workspace (identified by pyproject.toml, setup.py, etc.) + * - **ProjectUri:** The unique identifier for a project (the URI of the project root directory) + * - Each project gets its own test tree root, Python environment, and test adapters + * + * **Project identification:** + * Projects are identified and tracked by their URI (projectUri.toString()). This matches + * how the Python Environments extension stores projects in its Map. + */ +export class TestProjectRegistry { + /** + * Map of workspace URI -> Map of project URI string -> ProjectAdapter + * + * Projects are keyed by their URI string (projectUri.toString()) which matches how + * the Python Environments extension identifies projects. This enables O(1) lookups + * when given a project URI. + */ + private readonly workspaceProjects: Map> = new Map(); + + constructor( + private readonly testController: TestController, + private readonly configSettings: IConfigurationService, + private readonly interpreterService: IInterpreterService, + private readonly envVarsService: IEnvironmentVariablesProvider, + ) {} + + /** + * Gets the projects map for a workspace, if it exists. + */ + public getWorkspaceProjects(workspaceUri: Uri): Map | undefined { + return this.workspaceProjects.get(workspaceUri); + } + + /** + * Checks if a workspace has been initialized with projects. + */ + public hasProjects(workspaceUri: Uri): boolean { + return this.workspaceProjects.has(workspaceUri); + } + + /** + * Gets all projects for a workspace as an array. + */ + public getProjectsArray(workspaceUri: Uri): ProjectAdapter[] { + const projectsMap = this.workspaceProjects.get(workspaceUri); + return projectsMap ? Array.from(projectsMap.values()) : []; + } + + /** + * Discovers and registers all Python projects for a workspace. + * Returns the discovered projects for the caller to use. + */ + public async discoverAndRegisterProjects(workspaceUri: Uri): Promise { + traceInfo(`[test-by-project] Discovering projects for workspace: ${workspaceUri.fsPath}`); + + const projects = await this.discoverProjects(workspaceUri); + + // Create map for this workspace, keyed by project URI + const projectsMap = new Map(); + projects.forEach((project) => { + projectsMap.set(getProjectId(project.projectUri), project); + }); + + this.workspaceProjects.set(workspaceUri, projectsMap); + traceInfo(`[test-by-project] Registered ${projects.length} project(s) for ${workspaceUri.fsPath}`); + + return projects; + } + + /** + * Computes and populates nested project ignore lists for all projects in a workspace. + * Must be called before discovery to ensure parent projects ignore nested children. + */ + public configureNestedProjectIgnores(workspaceUri: Uri): void { + const projectIgnores = this.computeNestedProjectIgnores(workspaceUri); + const projects = this.getProjectsArray(workspaceUri); + + for (const project of projects) { + const ignorePaths = projectIgnores.get(getProjectId(project.projectUri)); + if (ignorePaths && ignorePaths.length > 0) { + project.nestedProjectPathsToIgnore = ignorePaths; + traceInfo(`[test-by-project] ${project.projectName} will ignore nested: ${ignorePaths.join(', ')}`); + } + } + } + + /** + * Clears all projects for a workspace. + */ + public clearWorkspace(workspaceUri: Uri): void { + this.workspaceProjects.delete(workspaceUri); + } + + // ====== Private Methods ====== + + /** + * Discovers Python projects in a workspace using the Python Environment API. + * Falls back to creating a single default project if API is unavailable. + */ + private async discoverProjects(workspaceUri: Uri): Promise { + try { + if (!useEnvExtension()) { + traceInfo('[test-by-project] Python Environments API not available, using default project'); + return [await this.createDefaultProject(workspaceUri)]; + } + + const envExtApi = await getEnvExtApi(); + const allProjects = envExtApi.getPythonProjects(); + traceInfo(`[test-by-project] Found ${allProjects.length} total Python projects from API`); + + // Filter to projects within this workspace + const workspaceProjects = allProjects.filter((project) => + isParentPath(project.uri.fsPath, workspaceUri.fsPath), + ); + traceInfo(`[test-by-project] Filtered to ${workspaceProjects.length} projects in workspace`); + + if (workspaceProjects.length === 0) { + traceInfo('[test-by-project] No projects found, creating default project'); + return [await this.createDefaultProject(workspaceUri)]; + } + + // Create ProjectAdapter for each discovered project + const adapters: ProjectAdapter[] = []; + for (const pythonProject of workspaceProjects) { + try { + const adapter = await this.createProjectAdapter(pythonProject, workspaceUri); + adapters.push(adapter); + } catch (error) { + traceError(`[test-by-project] Failed to create adapter for ${pythonProject.uri.fsPath}:`, error); + } + } + + if (adapters.length === 0) { + traceInfo('[test-by-project] All adapters failed, falling back to default project'); + return [await this.createDefaultProject(workspaceUri)]; + } + + return adapters; + } catch (error) { + traceError('[test-by-project] Discovery failed, using default project:', error); + return [await this.createDefaultProject(workspaceUri)]; + } + } + + /** + * Creates a ProjectAdapter from a PythonProject. + * + * Each project gets its own isolated test infrastructure: + * - **ResultResolver:** Handles mapping test IDs and processing results for this project + * - **DiscoveryAdapter:** Discovers tests scoped to this project's root directory + * - **ExecutionAdapter:** Runs tests for this project using its Python environment + * + */ + private async createProjectAdapter(pythonProject: PythonProject, workspaceUri: Uri): Promise { + const projectId = getProjectId(pythonProject.uri); + traceInfo(`[test-by-project] Creating adapter for: ${pythonProject.name} at ${projectId}`); + + // Resolve Python environment + const envExtApi = await getEnvExtApi(); + const pythonEnvironment = await envExtApi.getEnvironment(pythonProject.uri); + if (!pythonEnvironment) { + throw new Error(`No Python environment found for project ${projectId}`); + } + + // Create test infrastructure + const testProvider = this.getTestProvider(workspaceUri); + const projectDisplayName = createProjectDisplayName(pythonProject.name, pythonEnvironment.version); + const resultResolver = new PythonResultResolver( + this.testController, + testProvider, + workspaceUri, + projectId, + pythonProject.name, // Use simple project name for test tree label (without version) + ); + const { discoveryAdapter, executionAdapter } = createTestAdapters( + testProvider, + resultResolver, + this.configSettings, + this.envVarsService, + ); + + return { + projectName: projectDisplayName, + projectUri: pythonProject.uri, + workspaceUri, + pythonProject, + pythonEnvironment, + testProvider, + discoveryAdapter, + executionAdapter, + resultResolver, + isDiscovering: false, + isExecuting: false, + }; + } + + /** + * Creates a default project for legacy/fallback mode. + */ + private async createDefaultProject(workspaceUri: Uri): Promise { + traceInfo(`[test-by-project] Creating default project for: ${workspaceUri.fsPath}`); + + const testProvider = this.getTestProvider(workspaceUri); + const resultResolver = new PythonResultResolver(this.testController, testProvider, workspaceUri); + const { discoveryAdapter, executionAdapter } = createTestAdapters( + testProvider, + resultResolver, + this.configSettings, + this.envVarsService, + ); + + const interpreter = await this.interpreterService.getActiveInterpreter(workspaceUri); + + const pythonEnvironment: PythonEnvironment = { + name: 'default', + displayName: interpreter?.displayName || 'Python', + shortDisplayName: interpreter?.displayName || 'Python', + displayPath: interpreter?.path || 'python', + version: interpreter?.version?.raw || '3.x', + environmentPath: Uri.file(interpreter?.path || 'python'), + sysPrefix: interpreter?.sysPrefix || '', + execInfo: { run: { executable: interpreter?.path || 'python' } }, + envId: { id: 'default', managerId: 'default' }, + }; + + const pythonProject: PythonProject = { + name: path.basename(workspaceUri.fsPath) || 'workspace', + uri: workspaceUri, + }; + + return { + projectName: pythonProject.name, + projectUri: workspaceUri, + workspaceUri, + pythonProject, + pythonEnvironment, + testProvider, + discoveryAdapter, + executionAdapter, + resultResolver, + isDiscovering: false, + isExecuting: false, + }; + } + + /** + * Identifies nested projects and returns ignore paths for parent projects. + * + * **Time complexity:** O(nΒ²) where n is the number of projects in the workspace. + * For each project, checks all other projects to find nested relationships. + * + * Note: Uses path.normalize() to handle Windows path separator inconsistencies + * (e.g., paths from URI.fsPath may have mixed separators). + */ + private computeNestedProjectIgnores(workspaceUri: Uri): Map { + const ignoreMap = new Map(); + const projects = this.getProjectsArray(workspaceUri); + + if (projects.length === 0) return ignoreMap; + + for (const parent of projects) { + const nestedPaths: string[] = []; + + for (const child of projects) { + // Skip self-comparison using URI + if (parent.projectUri.toString() === child.projectUri.toString()) continue; + + // Normalize paths to handle Windows path separator inconsistencies + const parentNormalized = path.normalize(parent.projectUri.fsPath); + const childNormalized = path.normalize(child.projectUri.fsPath); + + // Add trailing separator to ensure we match directory boundaries + const parentWithSep = parentNormalized.endsWith(path.sep) + ? parentNormalized + : parentNormalized + path.sep; + const childWithSep = childNormalized.endsWith(path.sep) ? childNormalized : childNormalized + path.sep; + + // Check if child is inside parent (case-insensitive for Windows) + const childIsInsideParent = childWithSep.toLowerCase().startsWith(parentWithSep.toLowerCase()); + + if (childIsInsideParent) { + nestedPaths.push(child.projectUri.fsPath); + traceInfo(`[test-by-project] Nested: ${child.projectName} is inside ${parent.projectName}`); + } + } + + if (nestedPaths.length > 0) { + ignoreMap.set(getProjectId(parent.projectUri), nestedPaths); + } + } + + return ignoreMap; + } + + /** + * Determines the test provider based on workspace settings. + */ + private getTestProvider(workspaceUri: Uri): TestProvider { + const settings = this.configSettings.getSettings(workspaceUri); + return settings.testing.unittestEnabled ? UNITTEST_PROVIDER : 'pytest'; + } +} diff --git a/src/client/testing/testController/common/types.ts b/src/client/testing/testController/common/types.ts index 6121b3e24442..017c41cf3d97 100644 --- a/src/client/testing/testController/common/types.ts +++ b/src/client/testing/testController/common/types.ts @@ -16,6 +16,7 @@ import { import { ITestDebugLauncher } from '../../common/types'; import { IPythonExecutionFactory } from '../../../common/process/types'; import { PythonEnvironment } from '../../../pythonEnvironments/info'; +import { ProjectAdapter } from './projectAdapter'; export enum TestDataKinds { Workspace, @@ -142,10 +143,18 @@ export type TestCommandOptions = { // triggerRunDataReceivedEvent(data: DataReceivedEvent): void; // triggerDiscoveryDataReceivedEvent(data: DataReceivedEvent): void; // } -export interface ITestResultResolver { + +/** + * Test item mapping interface used by populateTestTree. + * Contains only the maps needed for building the test tree. + */ +export interface ITestItemMappings { runIdToVSid: Map; runIdToTestItem: Map; vsIdToRunId: Map; +} + +export interface ITestResultResolver extends ITestItemMappings { detailedCoverageMap: Map; resolveDiscovery(payload: DiscoveredTestPayload, token?: CancellationToken): void; @@ -160,6 +169,7 @@ export interface ITestDiscoveryAdapter { executionFactory: IPythonExecutionFactory, token?: CancellationToken, interpreter?: PythonEnvironment, + project?: ProjectAdapter, ): Promise; } @@ -173,6 +183,7 @@ export interface ITestExecutionAdapter { executionFactory: IPythonExecutionFactory, debugLauncher?: ITestDebugLauncher, interpreter?: PythonEnvironment, + project?: ProjectAdapter, ): Promise; } diff --git a/src/client/testing/testController/common/utils.ts b/src/client/testing/testController/common/utils.ts index 606865e5ad7e..9782487d940b 100644 --- a/src/client/testing/testController/common/utils.ts +++ b/src/client/testing/testController/common/utils.ts @@ -13,11 +13,12 @@ import { DiscoveredTestNode, DiscoveredTestPayload, ExecutionTestPayload, - ITestResultResolver, + ITestItemMappings, } from './types'; import { Deferred, createDeferred } from '../../../common/utils/async'; import { createReaderPipe, generateRandomPipeName } from '../../../common/pipes/namedPipes'; import { EXTENSION_ROOT_DIR } from '../../../constants'; +import { PROJECT_ID_SEPARATOR } from './projectUtils'; export function fixLogLinesNoTrailing(content: string): string { const lines = content.split(/\r?\n/g); @@ -175,32 +176,49 @@ export async function startDiscoveryNamedPipe( } /** - * Detects if an error message indicates that pytest is not installed. - * @param message The error message to check - * @returns True if the error indicates pytest is not installed + * Extracts the missing module name from a ModuleNotFoundError or ImportError message. + * @param message The error message to parse + * @returns The module name if found, undefined otherwise */ -function isPytestNotInstalledError(message: string): boolean { - return ( - (message.includes('ModuleNotFoundError') && message.includes('pytest')) || - (message.includes('No module named') && message.includes('pytest')) || - (message.includes('ImportError') && message.includes('pytest')) - ); +function extractMissingModuleName(message: string): string | undefined { + // Match patterns like: + // - No module named 'requests' + // - No module named "requests" + // - ModuleNotFoundError: No module named 'requests' + // - ImportError: No module named requests + const patterns = [/No module named ['"]([^'"]+)['"]/, /No module named (\S+)/]; + + for (const pattern of patterns) { + const match = message.match(pattern); + if (match) { + return match[1]; + } + } + return undefined; } -export function buildErrorNodeOptions(uri: Uri, message: string, testType: string): ErrorTestItemOptions { +export function buildErrorNodeOptions( + uri: Uri, + message: string, + testType: string, + projectName?: string, +): ErrorTestItemOptions { let labelText = testType === 'pytest' ? 'pytest Discovery Error' : 'Unittest Discovery Error'; let errorMessage = message; - // Provide more specific error message if pytest is not installed - if (testType === 'pytest' && isPytestNotInstalledError(message)) { - labelText = 'pytest Not Installed'; - errorMessage = - 'pytest is not installed in the selected Python environment. Please install pytest to enable test discovery and execution.'; + // Check for missing module errors and provide specific messaging + const missingModule = extractMissingModuleName(message); + if (missingModule) { + labelText = `Missing Module: ${missingModule}`; + errorMessage = `The module '${missingModule}' is not installed in the selected Python environment. Please install it to enable test discovery.`; } + // Use project name for label if available (project-based testing), otherwise use folder name + const displayName = projectName ?? path.basename(uri.fsPath); + return { id: `DiscoveryError:${uri.fsPath}`, - label: `${labelText} [${path.basename(uri.fsPath)}]`, + label: `${labelText} [${displayName}]`, error: errorMessage, }; } @@ -209,12 +227,18 @@ export function populateTestTree( testController: TestController, testTreeData: DiscoveredTestNode, testRoot: TestItem | undefined, - resultResolver: ITestResultResolver, + testItemMappings: ITestItemMappings, token?: CancellationToken, + projectId?: string, + projectName?: string, ): void { // If testRoot is undefined, use the info of the root item of testTreeData to create a test item, and append it to the test controller. if (!testRoot) { - testRoot = testController.createTestItem(testTreeData.path, testTreeData.name, Uri.file(testTreeData.path)); + // Create project-scoped ID if projectId is provided + const rootId = projectId ? `${projectId}${PROJECT_ID_SEPARATOR}${testTreeData.path}` : testTreeData.path; + // Use "Project: {name}" label for project-based testing, otherwise use folder name + const rootLabel = projectName ? `Project: ${projectName}` : testTreeData.name; + testRoot = testController.createTestItem(rootId, rootLabel, Uri.file(testTreeData.path)); testRoot.canResolveChildren = true; testRoot.tags = [RunTestTag, DebugTestTag]; @@ -226,7 +250,9 @@ export function populateTestTree( testTreeData.children.forEach((child) => { if (!token?.isCancellationRequested) { if (isTestItem(child)) { - const testItem = testController.createTestItem(child.id_, child.name, Uri.file(child.path)); + // Create project-scoped vsId + const vsId = projectId ? `${projectId}${PROJECT_ID_SEPARATOR}${child.id_}` : child.id_; + const testItem = testController.createTestItem(vsId, child.name, Uri.file(child.path)); testItem.tags = [RunTestTag, DebugTestTag]; let range: Range | undefined; @@ -245,15 +271,17 @@ export function populateTestTree( testItem.tags = [RunTestTag, DebugTestTag]; testRoot!.children.add(testItem); - // add to our map - resultResolver.runIdToTestItem.set(child.runID, testItem); - resultResolver.runIdToVSid.set(child.runID, child.id_); - resultResolver.vsIdToRunId.set(child.id_, child.runID); + // add to our map - use runID as key, vsId as value + testItemMappings.runIdToTestItem.set(child.runID, testItem); + testItemMappings.runIdToVSid.set(child.runID, vsId); + testItemMappings.vsIdToRunId.set(vsId, child.runID); } else { - let node = testController.items.get(child.path); + // Use project-scoped ID for non-test nodes and look up within the current root + const nodeId = projectId ? `${projectId}${PROJECT_ID_SEPARATOR}${child.id_}` : child.id_; + let node = testRoot!.children.get(nodeId); if (!node) { - node = testController.createTestItem(child.id_, child.name, Uri.file(child.path)); + node = testController.createTestItem(nodeId, child.name, Uri.file(child.path)); node.canResolveChildren = true; node.tags = [RunTestTag, DebugTestTag]; @@ -274,7 +302,7 @@ export function populateTestTree( testRoot!.children.add(node); } - populateTestTree(testController, child, node, resultResolver, token); + populateTestTree(testController, child, node, testItemMappings, token, projectId, projectName); } } }); diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index b38c9b0bcee1..04de209c171d 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -36,22 +36,18 @@ import { PYTEST_PROVIDER, UNITTEST_PROVIDER } from '../common/constants'; import { TestProvider } from '../types'; import { createErrorTestItem, DebugTestTag, getNodeByUri, RunTestTag } from './common/testItemUtilities'; import { buildErrorNodeOptions } from './common/utils'; -import { - ITestController, - ITestDiscoveryAdapter, - ITestFrameworkController, - TestRefreshOptions, - ITestExecutionAdapter, -} from './common/types'; -import { UnittestTestDiscoveryAdapter } from './unittest/testDiscoveryAdapter'; -import { UnittestTestExecutionAdapter } from './unittest/testExecutionAdapter'; -import { PytestTestDiscoveryAdapter } from './pytest/pytestDiscoveryAdapter'; -import { PytestTestExecutionAdapter } from './pytest/pytestExecutionAdapter'; +import { ITestController, ITestFrameworkController, TestRefreshOptions } from './common/types'; import { WorkspaceTestAdapter } from './workspaceTestAdapter'; import { ITestDebugLauncher } from '../common/types'; import { PythonResultResolver } from './common/resultResolver'; import { onDidSaveTextDocument } from '../../common/vscodeApis/workspaceApis'; import { IEnvironmentVariablesProvider } from '../../common/variables/types'; +import { ProjectAdapter } from './common/projectAdapter'; +import { TestProjectRegistry } from './common/testProjectRegistry'; +import { createTestAdapters, getProjectId } from './common/projectUtils'; +import { executeTestsForProjects } from './common/projectTestExecution'; +import { useEnvExtension, getEnvExtApi } from '../../envExt/api.internal'; +import { DidChangePythonProjectsEventArgs, PythonProject } from '../../envExt/types'; // Types gymnastics to make sure that sendTriggerTelemetry only accepts the correct types. type EventPropertyType = IEventNamePropertyMapping[EventName.UNITTEST_DISCOVERY_TRIGGER]; @@ -62,8 +58,12 @@ type TriggerType = EventPropertyType[TriggerKeyType]; export class PythonTestController implements ITestController, IExtensionSingleActivationService { public readonly supportedWorkspaceTypes = { untrustedWorkspace: false, virtualWorkspace: false }; + // Legacy: Single workspace test adapter per workspace (backward compatibility) private readonly testAdapters: Map = new Map(); + // Registry for multi-project testing (one registry instance manages all projects across workspaces) + private readonly projectRegistry: TestProjectRegistry; + private readonly triggerTypes: TriggerType[] = []; private readonly testController: TestController; @@ -105,6 +105,14 @@ export class PythonTestController implements ITestController, IExtensionSingleAc this.testController = tests.createTestController('python-tests', 'Python Tests'); this.disposables.push(this.testController); + // Initialize project registry for multi-project testing support + this.projectRegistry = new TestProjectRegistry( + this.testController, + this.configSettings, + this.interpreterService, + this.envVarsService, + ); + const delayTrigger = new DelayedTrigger( (uri: Uri, invalidate: boolean) => { this.refreshTestDataInternal(uri); @@ -160,60 +168,260 @@ export class PythonTestController implements ITestController, IExtensionSingleAc }; } + /** + * Determines the test provider (pytest or unittest) based on workspace settings. + */ + private getTestProvider(workspaceUri: Uri): TestProvider { + const settings = this.configSettings.getSettings(workspaceUri); + return settings.testing.unittestEnabled ? UNITTEST_PROVIDER : PYTEST_PROVIDER; + } + + /** + * Sets up file watchers for test discovery triggers. + */ + private setupFileWatchers(workspace: WorkspaceFolder): void { + const settings = this.configSettings.getSettings(workspace.uri); + if (settings.testing.autoTestDiscoverOnSaveEnabled) { + traceVerbose(`Testing: Setting up watcher for ${workspace.uri.fsPath}`); + this.watchForSettingsChanges(workspace); + this.watchForTestContentChangeOnSave(); + } + } + + /** + * Activates the test controller for all workspaces. + * + * Two activation modes: + * 1. **Project-based mode** (when Python Environments API available): + * 2. **Legacy mode** (fallback): + * + * Uses `Promise.allSettled` for resilient multi-workspace activation: + */ public async activate(): Promise { const workspaces: readonly WorkspaceFolder[] = this.workspaceService.workspaceFolders || []; + + // PROJECT-BASED MODE: Uses Python Environments API to discover projects + // Each project becomes its own test tree root with its own Python environment + if (useEnvExtension()) { + traceInfo('[test-by-project] Activating project-based testing mode'); + + // Discover projects in parallel across all workspaces + // Promise.allSettled ensures one workspace failure doesn't block others + const results = await Promise.allSettled( + Array.from(workspaces).map(async (workspace) => { + // Queries Python Environments API and creates ProjectAdapter instances + const projects = await this.projectRegistry.discoverAndRegisterProjects(workspace.uri); + return { workspace, projectCount: projects.length }; + }), + ); + + // Process results: successful workspaces get file watchers, failed ones fall back to legacy + results.forEach((result, index) => { + const workspace = workspaces[index]; + if (result.status === 'fulfilled') { + traceInfo( + `[test-by-project] Activated ${result.value.projectCount} project(s) for ${workspace.uri.fsPath}`, + ); + this.setupFileWatchers(workspace); + } else { + // Graceful degradation: if project discovery fails, use legacy single-adapter mode + traceError(`[test-by-project] Failed for ${workspace.uri.fsPath}:`, result.reason); + this.activateLegacyWorkspace(workspace); + } + }); + // Subscribe to project changes to update test tree when projects are added/removed + await this.subscribeToProjectChanges(); + return; + } + + // LEGACY MODE: Single WorkspaceTestAdapter per workspace (backward compatibility) workspaces.forEach((workspace) => { - const settings = this.configSettings.getSettings(workspace.uri); + this.activateLegacyWorkspace(workspace); + }); + } - let discoveryAdapter: ITestDiscoveryAdapter; - let executionAdapter: ITestExecutionAdapter; - let testProvider: TestProvider; - let resultResolver: PythonResultResolver; + /** + * Subscribes to Python project changes from the Python Environments API. + * When projects are added or removed, updates the test tree accordingly. + */ + private async subscribeToProjectChanges(): Promise { + try { + const envExtApi = await getEnvExtApi(); + this.disposables.push( + envExtApi.onDidChangePythonProjects((event: DidChangePythonProjectsEventArgs) => { + this.handleProjectChanges(event).catch((error) => { + traceError('[test-by-project] Error handling project changes:', error); + }); + }), + ); + traceInfo('[test-by-project] Subscribed to Python project changes'); + } catch (error) { + traceError('[test-by-project] Failed to subscribe to project changes:', error); + } + } - if (settings.testing.unittestEnabled) { - testProvider = UNITTEST_PROVIDER; - resultResolver = new PythonResultResolver(this.testController, testProvider, workspace.uri); - discoveryAdapter = new UnittestTestDiscoveryAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ); - executionAdapter = new UnittestTestExecutionAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ); - } else { - testProvider = PYTEST_PROVIDER; - resultResolver = new PythonResultResolver(this.testController, testProvider, workspace.uri); - discoveryAdapter = new PytestTestDiscoveryAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ); - executionAdapter = new PytestTestExecutionAdapter( - this.configSettings, - resultResolver, - this.envVarsService, - ); + /** + * Handles changes to Python projects (added or removed). + * Cleans up stale test items and re-discovers projects and tests for affected workspaces. + */ + private async handleProjectChanges(event: DidChangePythonProjectsEventArgs): Promise { + const { added, removed } = event; + + if (added.length === 0 && removed.length === 0) { + return; + } + + traceInfo(`[test-by-project] Project changes detected: ${added.length} added, ${removed.length} removed`); + + // Find all affected workspaces + const affectedWorkspaces = new Set(); + + const findWorkspace = (project: PythonProject): WorkspaceFolder | undefined => { + return this.workspaceService.getWorkspaceFolder(project.uri); + }; + + for (const project of [...added, ...removed]) { + const workspace = findWorkspace(project); + if (workspace) { + affectedWorkspaces.add(workspace); } + } - const workspaceTestAdapter = new WorkspaceTestAdapter( - testProvider, - discoveryAdapter, - executionAdapter, - workspace.uri, - resultResolver, - ); + // For each affected workspace, clean up and re-discover + for (const workspace of affectedWorkspaces) { + traceInfo(`[test-by-project] Re-discovering projects for workspace: ${workspace.uri.fsPath}`); + + // Get the current projects before clearing to know what to clean up + const existingProjects = this.projectRegistry.getProjectsArray(workspace.uri); + + // Remove ALL test items for the affected workspace's projects + // This ensures no stale items remain from deleted/changed projects + this.removeWorkspaceProjectTestItems(workspace.uri, existingProjects); + + // Also explicitly remove test items for removed projects (in case they weren't tracked) + for (const project of removed) { + const projectWorkspace = findWorkspace(project); + if (projectWorkspace?.uri.toString() === workspace.uri.toString()) { + this.removeProjectTestItems(project); + } + } + + // Re-discover all projects and tests for the workspace in a single pass. + // discoverAllProjectsInWorkspace is responsible for clearing/re-registering + // projects and performing test discovery for the workspace. + await this.discoverAllProjectsInWorkspace(workspace.uri); + } + } + + /** + * Removes all test items associated with projects in a workspace. + * Used to clean up stale items before re-discovery. + */ + private removeWorkspaceProjectTestItems(workspaceUri: Uri, projects: ProjectAdapter[]): void { + const idsToRemove: string[] = []; + + // Collect IDs of test items belonging to any project in this workspace + for (const project of projects) { + const projectIdPrefix = getProjectId(project.projectUri); + const projectFsPath = project.projectUri.fsPath; + + this.testController.items.forEach((item) => { + // Match by project ID prefix (e.g., "file:///path@@vsc@@...") + if (item.id.startsWith(projectIdPrefix)) { + idsToRemove.push(item.id); + } + // Match by fsPath in ID (legacy items might use path directly) + else if (item.id.includes(projectFsPath)) { + idsToRemove.push(item.id); + } + // Match by item URI being within project directory + else if (item.uri && item.uri.fsPath.startsWith(projectFsPath)) { + idsToRemove.push(item.id); + } + }); + } + + // Also remove any items whose URI is within the workspace (catch-all for edge cases) + this.testController.items.forEach((item) => { + if ( + item.uri && + this.workspaceService.getWorkspaceFolder(item.uri)?.uri.toString() === workspaceUri.toString() + ) { + if (!idsToRemove.includes(item.id)) { + idsToRemove.push(item.id); + } + } + }); - this.testAdapters.set(workspace.uri, workspaceTestAdapter); + // Remove all collected items + for (const id of idsToRemove) { + this.testController.items.delete(id); + } - if (settings.testing.autoTestDiscoverOnSaveEnabled) { - traceVerbose(`Testing: Setting up watcher for ${workspace.uri.fsPath}`); - this.watchForSettingsChanges(workspace); - this.watchForTestContentChangeOnSave(); + traceInfo( + `[test-by-project] Cleaned up ${idsToRemove.length} test items for workspace: ${workspaceUri.fsPath}`, + ); + } + + /** + * Removes test items associated with a specific project from the test controller. + * Matches items by project ID prefix, fsPath, or URI. + */ + private removeProjectTestItems(project: PythonProject): void { + const projectId = getProjectId(project.uri); + const projectFsPath = project.uri.fsPath; + const idsToRemove: string[] = []; + + // Find all root items that belong to this project + this.testController.items.forEach((item) => { + // Match by project ID prefix (e.g., "file:///path@@vsc@@...") + if (item.id.startsWith(projectId)) { + idsToRemove.push(item.id); + } + // Match by fsPath in ID (items might use path directly without URI prefix) + else if (item.id.startsWith(projectFsPath) || item.id.includes(projectFsPath)) { + idsToRemove.push(item.id); + } + // Match by item URI being within project directory + else if (item.uri && item.uri.fsPath.startsWith(projectFsPath)) { + idsToRemove.push(item.id); } }); + + for (const id of idsToRemove) { + this.testController.items.delete(id); + traceVerbose(`[test-by-project] Removed test item: ${id}`); + } + + if (idsToRemove.length > 0) { + traceInfo(`[test-by-project] Removed ${idsToRemove.length} test items for project: ${project.name}`); + } + } + + /** + * Activates testing for a workspace using the legacy single-adapter approach. + * Used for backward compatibility when project-based testing is disabled or unavailable. + */ + private activateLegacyWorkspace(workspace: WorkspaceFolder): void { + const testProvider = this.getTestProvider(workspace.uri); + const resultResolver = new PythonResultResolver(this.testController, testProvider, workspace.uri); + const { discoveryAdapter, executionAdapter } = createTestAdapters( + testProvider, + resultResolver, + this.configSettings, + this.envVarsService, + ); + + const workspaceTestAdapter = new WorkspaceTestAdapter( + testProvider, + discoveryAdapter, + executionAdapter, + workspace.uri, + resultResolver, + ); + + this.testAdapters.set(workspace.uri, workspaceTestAdapter); + this.setupFileWatchers(workspace); } public refreshTestData(uri?: Resource, options?: TestRefreshOptions): Promise { @@ -253,101 +461,228 @@ export class PythonTestController implements ITestController, IExtensionSingleAc private async refreshTestDataInternal(uri?: Resource): Promise { this.refreshingStartedEvent.fire(); - if (uri) { - const settings = this.configSettings.getSettings(uri); - const workspace = this.workspaceService.getWorkspaceFolder(uri); - traceInfo(`Discover tests for workspace name: ${workspace?.name} - uri: ${uri.fsPath}`); - // Ensure we send test telemetry if it gets disabled again - this.sendTestDisabledTelemetry = true; - // ** experiment to roll out NEW test discovery mechanism - if (settings.testing.pytestEnabled) { - if (workspace && workspace.uri) { - const testAdapter = this.testAdapters.get(workspace.uri); - if (testAdapter) { - const testProviderInAdapter = testAdapter.getTestProvider(); - if (testProviderInAdapter !== 'pytest') { - traceError('Test provider in adapter is not pytest. Please reload window.'); - this.surfaceErrorNode( - workspace.uri, - 'Test provider types are not aligned, please reload your VS Code window.', - 'pytest', - ); - return Promise.resolve(); - } - await testAdapter.discoverTests( - this.testController, - this.pythonExecFactory, - this.refreshCancellation.token, - await this.interpreterService.getActiveInterpreter(workspace.uri), - ); - } else { - traceError('Unable to find test adapter for workspace.'); - } - } else { - traceError('Unable to find workspace for given file'); - } - } else if (settings.testing.unittestEnabled) { - if (workspace && workspace.uri) { - const testAdapter = this.testAdapters.get(workspace.uri); - if (testAdapter) { - const testProviderInAdapter = testAdapter.getTestProvider(); - if (testProviderInAdapter !== 'unittest') { - traceError('Test provider in adapter is not unittest. Please reload window.'); - this.surfaceErrorNode( - workspace.uri, - 'Test provider types are not aligned, please reload your VS Code window.', - 'unittest', - ); - return Promise.resolve(); - } - await testAdapter.discoverTests( - this.testController, - this.pythonExecFactory, - this.refreshCancellation.token, - await this.interpreterService.getActiveInterpreter(workspace.uri), - ); - } else { - traceError('Unable to find test adapter for workspace.'); - } - } else { - traceError('Unable to find workspace for given file'); - } + try { + if (uri) { + await this.discoverTestsInWorkspace(uri); } else { - if (this.sendTestDisabledTelemetry) { - this.sendTestDisabledTelemetry = false; - sendTelemetryEvent(EventName.UNITTEST_DISABLED); - } - // If we are here we may have to remove an existing node from the tree - // This handles the case where user removes test settings. Which should remove the - // tests for that particular case from the tree view - if (workspace) { - const toDelete: string[] = []; - this.testController.items.forEach((i: TestItem) => { - const w = this.workspaceService.getWorkspaceFolder(i.uri); - if (w?.uri.fsPath === workspace.uri.fsPath) { - toDelete.push(i.id); - } - }); - toDelete.forEach((i) => this.testController.items.delete(i)); - } + await this.discoverTestsInAllWorkspaces(); } - } else { - traceVerbose('Testing: Refreshing all test data'); - const workspaces: readonly WorkspaceFolder[] = this.workspaceService.workspaceFolders || []; - await Promise.all( - workspaces.map(async (workspace) => { + } finally { + this.refreshingCompletedEvent.fire(); + } + } + + /** + * Discovers tests for a single workspace. + * + * **Discovery flow:** + * 1. If the workspace has registered projects (via Python Environments API), + * uses project-based discovery: each project is discovered independently + * with its own Python environment and test adapters. + * 2. Otherwise, falls back to legacy mode: a single WorkspaceTestAdapter + * discovers all tests in the workspace using the active interpreter. + * + * In project-based mode, the test tree will have separate roots for each project. + * In legacy mode, the workspace folder is the single test tree root. + */ + private async discoverTestsInWorkspace(uri: Uri): Promise { + const workspace = this.workspaceService.getWorkspaceFolder(uri); + if (!workspace?.uri) { + traceError('Unable to find workspace for given file'); + return; + } + + const settings = this.configSettings.getSettings(uri); + traceVerbose(`Discover tests for workspace name: ${workspace.name} - uri: ${uri.fsPath}`); + + // Ensure we send test telemetry if it gets disabled again + this.sendTestDisabledTelemetry = true; + + // Check if any test framework is enabled BEFORE project-based discovery + // This ensures the config screen stays visible when testing is disabled + if (!settings.testing.pytestEnabled && !settings.testing.unittestEnabled) { + await this.handleNoTestProviderEnabled(workspace); + return; + } + + // Use project-based discovery if applicable (only reached if testing is enabled) + if (this.projectRegistry.hasProjects(workspace.uri)) { + await this.discoverAllProjectsInWorkspace(workspace.uri); + return; + } + + // Legacy mode: Single workspace adapter + if (settings.testing.pytestEnabled) { + await this.discoverWorkspaceTestsLegacy(workspace.uri, 'pytest'); + } else if (settings.testing.unittestEnabled) { + await this.discoverWorkspaceTestsLegacy(workspace.uri, 'unittest'); + } + } + + /** + * Discovers tests for all projects within a workspace (project-based mode). + * Re-discovers projects from the Python Environments API before running test discovery. + * This ensures the test tree stays in sync with project changes. + */ + private async discoverAllProjectsInWorkspace(workspaceUri: Uri): Promise { + // Defensive check: ensure testing is enabled (should be checked by caller, but be safe) + const settings = this.configSettings.getSettings(workspaceUri); + if (!settings.testing.pytestEnabled && !settings.testing.unittestEnabled) { + traceVerbose('[test-by-project] Skipping discovery - no test framework enabled'); + return; + } + + // Get existing projects before re-discovery for cleanup + const existingProjects = this.projectRegistry.getProjectsArray(workspaceUri); + + // Clean up all existing test items for this workspace + // This ensures stale items from deleted/changed projects are removed + this.removeWorkspaceProjectTestItems(workspaceUri, existingProjects); + + // Re-discover projects from Python Environments API + // This picks up any added/removed projects since last discovery + this.projectRegistry.clearWorkspace(workspaceUri); + const projects = await this.projectRegistry.discoverAndRegisterProjects(workspaceUri); + + if (projects.length === 0) { + traceError(`[test-by-project] No projects found for workspace: ${workspaceUri.fsPath}`); + return; + } + + traceInfo(`[test-by-project] Starting discovery for ${projects.length} project(s) in workspace`); + + try { + // Configure nested project exclusions before discovery + this.projectRegistry.configureNestedProjectIgnores(workspaceUri); + + // Track completion for progress logging + const projectsCompleted = new Set(); + + // Run discovery for all projects in parallel + await Promise.all(projects.map((project) => this.discoverTestsForProject(project, projectsCompleted))); + + traceInfo( + `[test-by-project] Discovery complete: ${projectsCompleted.size}/${projects.length} projects completed`, + ); + } catch (error) { + traceError(`[test-by-project] Discovery failed for workspace ${workspaceUri.fsPath}:`, error); + } + } + + /** + * Discovers tests for a single project (project-based mode). + * Creates test tree items rooted at the project's directory. + */ + private async discoverTestsForProject(project: ProjectAdapter, projectsCompleted: Set): Promise { + try { + traceInfo(`[test-by-project] Discovering tests for project: ${project.projectName}`); + project.isDiscovering = true; + + // In project-based mode, the discovery adapter uses the Python Environments API + // to get the environment directly, so we don't need to pass the interpreter + await project.discoveryAdapter.discoverTests( + project.projectUri, + this.pythonExecFactory, + this.refreshCancellation.token, + undefined, // Interpreter not needed; adapter uses Python Environments API + project, + ); + + // Mark project as completed (use URI string as unique key) + projectsCompleted.add(project.projectUri.toString()); + traceInfo(`[test-by-project] Project ${project.projectName} discovery completed`); + } catch (error) { + traceError(`[test-by-project] Discovery failed for project ${project.projectName}:`, error); + // Individual project failures don't block others + projectsCompleted.add(project.projectUri.toString()); // Still mark as completed + } finally { + project.isDiscovering = false; + } + } + + /** + * Discovers tests across all workspace folders. + * Iterates each workspace and triggers discovery. + */ + private async discoverTestsInAllWorkspaces(): Promise { + traceVerbose('Testing: Refreshing all test data'); + const workspaces: readonly WorkspaceFolder[] = this.workspaceService.workspaceFolders || []; + + await Promise.all( + workspaces.map(async (workspace) => { + // In project-based mode, each project has its own environment, + // so we don't require a global active interpreter + if (!useEnvExtension()) { if (!(await this.interpreterService.getActiveInterpreter(workspace.uri))) { this.commandManager .executeCommand(constants.Commands.TriggerEnvironmentSelection, workspace.uri) .then(noop, noop); return; } - await this.refreshTestDataInternal(workspace.uri); - }), + } + await this.discoverTestsInWorkspace(workspace.uri); + }), + ); + } + + /** + * Discovers tests for a workspace using legacy single-adapter mode. + */ + private async discoverWorkspaceTestsLegacy(workspaceUri: Uri, expectedProvider: TestProvider): Promise { + const testAdapter = this.testAdapters.get(workspaceUri); + + if (!testAdapter) { + traceError('Unable to find test adapter for workspace.'); + return; + } + + const actualProvider = testAdapter.getTestProvider(); + if (actualProvider !== expectedProvider) { + traceError(`Test provider in adapter is not ${expectedProvider}. Please reload window.`); + this.surfaceErrorNode( + workspaceUri, + 'Test provider types are not aligned, please reload your VS Code window.', + expectedProvider, ); + return; } - this.refreshingCompletedEvent.fire(); - return Promise.resolve(); + + await testAdapter.discoverTests( + this.testController, + this.pythonExecFactory, + this.refreshCancellation.token, + await this.interpreterService.getActiveInterpreter(workspaceUri), + ); + } + + /** + * Handles the case when no test provider is enabled. + * Sends telemetry and removes test items for the workspace from the tree. + */ + private async handleNoTestProviderEnabled(workspace: WorkspaceFolder): Promise { + if (this.sendTestDisabledTelemetry) { + this.sendTestDisabledTelemetry = false; + sendTelemetryEvent(EventName.UNITTEST_DISABLED); + } + + this.removeTestItemsForWorkspace(workspace); + } + + /** + * Removes all test items belonging to a specific workspace from the test controller. + * This is used when test discovery is disabled for a workspace. + */ + private removeTestItemsForWorkspace(workspace: WorkspaceFolder): void { + const itemsToDelete: string[] = []; + + this.testController.items.forEach((testItem: TestItem) => { + const itemWorkspace = this.workspaceService.getWorkspaceFolder(testItem.uri); + if (itemWorkspace?.uri.fsPath === workspace.uri.fsPath) { + itemsToDelete.push(testItem.id); + } + }); + + itemsToDelete.forEach((id) => this.testController.items.delete(id)); } private async resolveChildren(item: TestItem | undefined): Promise { @@ -366,9 +701,13 @@ export class PythonTestController implements ITestController, IExtensionSingleAc const workspaces: readonly WorkspaceFolder[] = this.workspaceService.workspaceFolders || []; await Promise.all( workspaces.map(async (workspace) => { - if (!(await this.interpreterService.getActiveInterpreter(workspace.uri))) { - traceError('Cannot trigger test discovery as a valid interpreter is not selected'); - return; + // In project-based mode, each project has its own environment, + // so we don't require a global active interpreter + if (!useEnvExtension()) { + if (!(await this.interpreterService.getActiveInterpreter(workspace.uri))) { + traceError('Cannot trigger test discovery as a valid interpreter is not selected'); + return; + } } await this.refreshTestDataInternal(workspace.uri); }), @@ -378,21 +717,13 @@ export class PythonTestController implements ITestController, IExtensionSingleAc } private async runTests(request: TestRunRequest, token: CancellationToken): Promise { - const workspaces: WorkspaceFolder[] = []; - if (request.include) { - uniq(request.include.map((r) => this.workspaceService.getWorkspaceFolder(r.uri))).forEach((w) => { - if (w) { - workspaces.push(w); - } - }); - } else { - (this.workspaceService.workspaceFolders || []).forEach((w) => workspaces.push(w)); - } + const workspaces = this.getWorkspacesForTestRun(request); const runInstance = this.testController.createTestRun( request, `Running Tests for Workspace(s): ${workspaces.map((w) => w.uri.fsPath).join(';')}`, true, ); + const dispose = token.onCancellationRequested(() => { runInstance.appendOutput(`\nRun instance cancelled.\r\n`); runInstance.end(); @@ -402,87 +733,9 @@ export class PythonTestController implements ITestController, IExtensionSingleAc try { await Promise.all( - workspaces.map(async (workspace) => { - if (!(await this.interpreterService.getActiveInterpreter(workspace.uri))) { - this.commandManager - .executeCommand(constants.Commands.TriggerEnvironmentSelection, workspace.uri) - .then(noop, noop); - return undefined; - } - const testItems: TestItem[] = []; - // If the run request includes test items then collect only items that belong to - // `workspace`. If there are no items in the run request then just run the `workspace` - // root test node. Include will be `undefined` in the "run all" scenario. - (request.include ?? this.testController.items).forEach((i: TestItem) => { - const w = this.workspaceService.getWorkspaceFolder(i.uri); - if (w?.uri.fsPath === workspace.uri.fsPath) { - testItems.push(i); - } - }); - - const settings = this.configSettings.getSettings(workspace.uri); - if (testItems.length > 0) { - const testAdapter = - this.testAdapters.get(workspace.uri) || - (this.testAdapters.values().next().value as WorkspaceTestAdapter); - - // no profile will have TestRunProfileKind.Coverage if rewrite isn't enabled - if (request.profile?.kind && request.profile?.kind === TestRunProfileKind.Coverage) { - request.profile.loadDetailedCoverage = ( - _testRun: TestRun, - fileCoverage, - _token, - ): Thenable => { - const details = testAdapter.resultResolver.detailedCoverageMap.get( - fileCoverage.uri.fsPath, - ); - if (details === undefined) { - // given file has no detailed coverage data - return Promise.resolve([]); - } - return Promise.resolve(details); - }; - } - - if (settings.testing.pytestEnabled) { - sendTelemetryEvent(EventName.UNITTEST_RUN, undefined, { - tool: 'pytest', - debugging: request.profile?.kind === TestRunProfileKind.Debug, - }); - return testAdapter.executeTests( - this.testController, - runInstance, - testItems, - this.pythonExecFactory, - token, - request.profile?.kind, - this.debugLauncher, - await this.interpreterService.getActiveInterpreter(workspace.uri), - ); - } - if (settings.testing.unittestEnabled) { - sendTelemetryEvent(EventName.UNITTEST_RUN, undefined, { - tool: 'unittest', - debugging: request.profile?.kind === TestRunProfileKind.Debug, - }); - // ** experiment to roll out NEW test discovery mechanism - return testAdapter.executeTests( - this.testController, - runInstance, - testItems, - this.pythonExecFactory, - token, - request.profile?.kind, - this.debugLauncher, - await this.interpreterService.getActiveInterpreter(workspace.uri), - ); - } - } - if (!settings.testing.pytestEnabled && !settings.testing.unittestEnabled) { - unconfiguredWorkspaces.push(workspace); - } - return Promise.resolve(); - }), + workspaces.map((workspace) => + this.runTestsForWorkspace(workspace, request, runInstance, token, unconfiguredWorkspaces), + ), ); } finally { traceVerbose('Finished running tests, ending runInstance.'); @@ -495,6 +748,168 @@ export class PythonTestController implements ITestController, IExtensionSingleAc } } + /** + * Gets the list of workspaces to run tests for based on the test run request. + */ + private getWorkspacesForTestRun(request: TestRunRequest): WorkspaceFolder[] { + if (request.include) { + const workspaces: WorkspaceFolder[] = []; + uniq(request.include.map((r) => this.workspaceService.getWorkspaceFolder(r.uri))).forEach((w) => { + if (w) { + workspaces.push(w); + } + }); + return workspaces; + } + return Array.from(this.workspaceService.workspaceFolders || []); + } + + /** + * Runs tests for a single workspace. + */ + private async runTestsForWorkspace( + workspace: WorkspaceFolder, + request: TestRunRequest, + runInstance: TestRun, + token: CancellationToken, + unconfiguredWorkspaces: WorkspaceFolder[], + ): Promise { + if (!(await this.interpreterService.getActiveInterpreter(workspace.uri))) { + this.commandManager + .executeCommand(constants.Commands.TriggerEnvironmentSelection, workspace.uri) + .then(noop, noop); + return; + } + + const testItems = this.getTestItemsForWorkspace(workspace, request); + const settings = this.configSettings.getSettings(workspace.uri); + + if (testItems.length === 0) { + if (!settings.testing.pytestEnabled && !settings.testing.unittestEnabled) { + unconfiguredWorkspaces.push(workspace); + } + return; + } + + // Check if we're in project-based mode and should use project-specific execution + if (this.projectRegistry.hasProjects(workspace.uri)) { + const projects = this.projectRegistry.getProjectsArray(workspace.uri); + await executeTestsForProjects(projects, testItems, runInstance, request, token, { + projectRegistry: this.projectRegistry, + pythonExecFactory: this.pythonExecFactory, + debugLauncher: this.debugLauncher, + }); + return; + } + + // For unittest (or pytest when not in project mode), use the legacy WorkspaceTestAdapter. + // In project mode, legacy adapters may not be initialized, so create one on demand. + let testAdapter = this.testAdapters.get(workspace.uri); + if (!testAdapter) { + // Initialize legacy adapter on demand (needed for unittest in project mode) + this.activateLegacyWorkspace(workspace); + testAdapter = this.testAdapters.get(workspace.uri); + } + + if (!testAdapter) { + traceError(`[test] No test adapter available for workspace: ${workspace.uri.fsPath}`); + return; + } + + this.setupCoverageIfNeeded(request, testAdapter); + + if (settings.testing.pytestEnabled) { + await this.executeTestsForProvider( + workspace, + testAdapter, + testItems, + runInstance, + request, + token, + 'pytest', + ); + } else if (settings.testing.unittestEnabled) { + await this.executeTestsForProvider( + workspace, + testAdapter, + testItems, + runInstance, + request, + token, + 'unittest', + ); + } else { + unconfiguredWorkspaces.push(workspace); + } + } + + /** + * Gets test items that belong to a specific workspace from the run request. + */ + private getTestItemsForWorkspace(workspace: WorkspaceFolder, request: TestRunRequest): TestItem[] { + const testItems: TestItem[] = []; + // If the run request includes test items then collect only items that belong to + // `workspace`. If there are no items in the run request then just run the `workspace` + // root test node. Include will be `undefined` in the "run all" scenario. + (request.include ?? this.testController.items).forEach((i: TestItem) => { + const w = this.workspaceService.getWorkspaceFolder(i.uri); + if (w?.uri.fsPath === workspace.uri.fsPath) { + testItems.push(i); + } + }); + return testItems; + } + + /** + * Sets up detailed coverage loading if the run profile is for coverage. + */ + private setupCoverageIfNeeded(request: TestRunRequest, testAdapter: WorkspaceTestAdapter): void { + // no profile will have TestRunProfileKind.Coverage if rewrite isn't enabled + if (request.profile?.kind && request.profile?.kind === TestRunProfileKind.Coverage) { + request.profile.loadDetailedCoverage = ( + _testRun: TestRun, + fileCoverage, + _token, + ): Thenable => { + const details = testAdapter.resultResolver.detailedCoverageMap.get(fileCoverage.uri.fsPath); + if (details === undefined) { + // given file has no detailed coverage data + return Promise.resolve([]); + } + return Promise.resolve(details); + }; + } + } + + /** + * Executes tests using the test adapter for a specific test provider. + */ + private async executeTestsForProvider( + workspace: WorkspaceFolder, + testAdapter: WorkspaceTestAdapter, + testItems: TestItem[], + runInstance: TestRun, + request: TestRunRequest, + token: CancellationToken, + provider: TestProvider, + ): Promise { + sendTelemetryEvent(EventName.UNITTEST_RUN, undefined, { + tool: provider, + debugging: request.profile?.kind === TestRunProfileKind.Debug, + }); + + await testAdapter.executeTests( + this.testController, + runInstance, + testItems, + this.pythonExecFactory, + token, + request.profile?.kind, + this.debugLauncher, + await this.interpreterService.getActiveInterpreter(workspace.uri), + ); + } + private invalidateTests(uri: Uri) { this.testController.items.forEach((root) => { const item = getNodeByUri(root, uri); diff --git a/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts b/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts index 308c9ba1f9bc..16e27635e66c 100644 --- a/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts +++ b/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts @@ -1,8 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. import * as path from 'path'; -import { CancellationToken, CancellationTokenSource, Uri } from 'vscode'; -import * as fs from 'fs'; +import { CancellationToken, Disposable, Uri } from 'vscode'; import { ChildProcess } from 'child_process'; import { ExecutionFactoryCreateWithEnvironmentOptions, @@ -10,24 +9,38 @@ import { SpawnOptions, } from '../../../common/process/types'; import { IConfigurationService } from '../../../common/types'; -import { createDeferred, Deferred } from '../../../common/utils/async'; +import { Deferred } from '../../../common/utils/async'; import { EXTENSION_ROOT_DIR } from '../../../constants'; -import { traceError, traceInfo, traceVerbose, traceWarn } from '../../../logging'; -import { DiscoveredTestPayload, ITestDiscoveryAdapter, ITestResultResolver } from '../common/types'; -import { - createDiscoveryErrorPayload, - createTestingDeferred, - fixLogLinesNoTrailing, - startDiscoveryNamedPipe, - addValueIfKeyNotExist, - hasSymlinkParent, -} from '../common/utils'; +import { traceError, traceInfo, traceVerbose } from '../../../logging'; +import { ITestDiscoveryAdapter, ITestResultResolver } from '../common/types'; +import { createTestingDeferred } from '../common/utils'; import { IEnvironmentVariablesProvider } from '../../../common/variables/types'; import { PythonEnvironment } from '../../../pythonEnvironments/info'; import { useEnvExtension, getEnvironment, runInBackground } from '../../../envExt/api.internal'; +import { buildPytestEnv as configureSubprocessEnv, handleSymlinkAndRootDir } from './pytestHelpers'; +import { cleanupOnCancellation, createProcessHandlers, setupDiscoveryPipe } from '../common/discoveryHelpers'; +import { ProjectAdapter } from '../common/projectAdapter'; + +/** + * Configures the subprocess environment for pytest discovery. + * @param envVarsService Service to retrieve environment variables + * @param uri Workspace URI + * @param discoveryPipeName Name of the discovery pipe to pass to the subprocess + * @returns Configured environment variables for the subprocess + */ +async function configureDiscoveryEnv( + envVarsService: IEnvironmentVariablesProvider | undefined, + uri: Uri, + discoveryPipeName: string, +): Promise { + const fullPluginPath = path.join(EXTENSION_ROOT_DIR, 'python_files'); + const envVars = await envVarsService?.getEnvironmentVariables(uri); + const mutableEnv = configureSubprocessEnv(envVars, fullPluginPath, discoveryPipeName); + return mutableEnv; +} /** - * Wrapper class for unittest test discovery. This is where we call `runTestCommand`. #this seems incorrectly copied + * Wrapper class for pytest test discovery. This is where we call the pytest subprocess. */ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter { constructor( @@ -41,186 +54,172 @@ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter { executionFactory: IPythonExecutionFactory, token?: CancellationToken, interpreter?: PythonEnvironment, + project?: ProjectAdapter, ): Promise { - const cSource = new CancellationTokenSource(); - const deferredReturn = createDeferred(); - - token?.onCancellationRequested(() => { - traceInfo(`Test discovery cancelled.`); - cSource.cancel(); - deferredReturn.resolve(); - }); - - const name = await startDiscoveryNamedPipe((data: DiscoveredTestPayload) => { - // if the token is cancelled, we don't want process the data - if (!token?.isCancellationRequested) { - this.resultResolver?.resolveDiscovery(data); - } - }, cSource.token); + // Setup discovery pipe and cancellation + const { + pipeName: discoveryPipeName, + cancellation: discoveryPipeCancellation, + tokenDisposable, + } = await setupDiscoveryPipe(this.resultResolver, token, uri); + + // Setup process handlers deferred (used by both execution paths) + const deferredTillExecClose: Deferred = createTestingDeferred(); - this.runPytestDiscovery(uri, name, cSource, executionFactory, interpreter, token).then(() => { - deferredReturn.resolve(); - }); + // Collect all disposables related to discovery to handle cleanup in finally block + const disposables: Disposable[] = []; + if (tokenDisposable) { + disposables.push(tokenDisposable); + } - return deferredReturn.promise; - } + try { + // Build pytest command and arguments + const settings = this.configSettings.getSettings(uri); + let { pytestArgs } = settings.testing; + const cwd = settings.testing.cwd && settings.testing.cwd.length > 0 ? settings.testing.cwd : uri.fsPath; + pytestArgs = await handleSymlinkAndRootDir(cwd, pytestArgs); + + // Add --ignore flags for nested projects to prevent duplicate discovery + if (project?.nestedProjectPathsToIgnore?.length) { + const ignoreArgs = project.nestedProjectPathsToIgnore.map((nestedPath) => `--ignore=${nestedPath}`); + pytestArgs = [...pytestArgs, ...ignoreArgs]; + traceInfo( + `[test-by-project] Project ${project.projectName} ignoring nested project(s): ${ignoreArgs.join( + ' ', + )}`, + ); + } - async runPytestDiscovery( - uri: Uri, - discoveryPipeName: string, - cSource: CancellationTokenSource, - executionFactory: IPythonExecutionFactory, - interpreter?: PythonEnvironment, - token?: CancellationToken, - ): Promise { - const relativePathToPytest = 'python_files'; - const fullPluginPath = path.join(EXTENSION_ROOT_DIR, relativePathToPytest); - const settings = this.configSettings.getSettings(uri); - let { pytestArgs } = settings.testing; - const cwd = settings.testing.cwd && settings.testing.cwd.length > 0 ? settings.testing.cwd : uri.fsPath; - - // check for symbolic path - const stats = await fs.promises.lstat(cwd); - const resolvedPath = await fs.promises.realpath(cwd); - let isSymbolicLink = false; - if (stats.isSymbolicLink()) { - isSymbolicLink = true; - traceWarn('The cwd is a symbolic link.'); - } else if (resolvedPath !== cwd) { - traceWarn( - 'The cwd resolves to a different path, checking if it has a symbolic link somewhere in its path.', + const commandArgs = ['-m', 'pytest', '-p', 'vscode_pytest', '--collect-only'].concat(pytestArgs); + traceVerbose( + `Running pytest discovery with command: ${commandArgs.join(' ')} for workspace ${uri.fsPath}.`, ); - isSymbolicLink = await hasSymlinkParent(cwd); - } - if (isSymbolicLink) { - traceWarn("Symlink found, adding '--rootdir' to pytestArgs only if it doesn't already exist. cwd: ", cwd); - pytestArgs = addValueIfKeyNotExist(pytestArgs, '--rootdir', cwd); - } - // if user has provided `--rootdir` then use that, otherwise add `cwd` - // root dir is required so pytest can find the relative paths and for symlinks - addValueIfKeyNotExist(pytestArgs, '--rootdir', cwd); - - // get and edit env vars - const mutableEnv = { - ...(await this.envVarsService?.getEnvironmentVariables(uri)), - }; - // get python path from mutable env, it contains process.env as well - const pythonPathParts: string[] = mutableEnv.PYTHONPATH?.split(path.delimiter) ?? []; - const pythonPathCommand = [fullPluginPath, ...pythonPathParts].join(path.delimiter); - mutableEnv.PYTHONPATH = pythonPathCommand; - mutableEnv.TEST_RUN_PIPE = discoveryPipeName; - traceInfo( - `Environment variables set for pytest discovery: PYTHONPATH=${mutableEnv.PYTHONPATH}, TEST_RUN_PIPE=${mutableEnv.TEST_RUN_PIPE}`, - ); - - // delete UUID following entire discovery finishing. - const execArgs = ['-m', 'pytest', '-p', 'vscode_pytest', '--collect-only'].concat(pytestArgs); - traceVerbose(`Running pytest discovery with command: ${execArgs.join(' ')} for workspace ${uri.fsPath}.`); - - if (useEnvExtension()) { - const pythonEnv = await getEnvironment(uri); - if (pythonEnv) { - const deferredTillExecClose: Deferred = createTestingDeferred(); + + // Configure subprocess environment + const mutableEnv = await configureDiscoveryEnv(this.envVarsService, uri, discoveryPipeName); + + // Set PROJECT_ROOT_PATH for project-based testing (tells Python where to root the test tree) + if (project) { + mutableEnv.PROJECT_ROOT_PATH = project.projectUri.fsPath; + } + + // Setup process handlers (shared by both execution paths) + const handlers = createProcessHandlers('pytest', uri, cwd, this.resultResolver, deferredTillExecClose, [5]); + + // Execute using environment extension if available + if (useEnvExtension()) { + traceInfo(`Using environment extension for pytest discovery in workspace ${uri.fsPath}`); + const pythonEnv = project?.pythonEnvironment ?? (await getEnvironment(uri)); + if (!pythonEnv) { + traceError( + `Python environment not found for workspace ${uri.fsPath}. Cannot proceed with test discovery.`, + ); + deferredTillExecClose.resolve(); + return; + } + traceVerbose(`Using Python environment: ${JSON.stringify(pythonEnv)}`); const proc = await runInBackground(pythonEnv, { cwd, - args: execArgs, + args: commandArgs, env: (mutableEnv as unknown) as { [key: string]: string }, }); - token?.onCancellationRequested(() => { - traceInfo(`Test discovery cancelled, killing pytest subprocess for workspace ${uri.fsPath}`); - proc.kill(); - deferredTillExecClose.resolve(); - cSource.cancel(); - }); - proc.stdout.on('data', (data) => { - const out = fixLogLinesNoTrailing(data.toString()); - traceInfo(out); - }); - proc.stderr.on('data', (data) => { - const out = fixLogLinesNoTrailing(data.toString()); - traceError(out); + traceInfo(`Started pytest discovery subprocess (environment extension) for workspace ${uri.fsPath}`); + + // Wire up cancellation and process events + const envExtCancellationHandler = token?.onCancellationRequested(() => { + cleanupOnCancellation('pytest', proc, deferredTillExecClose, discoveryPipeCancellation, uri); }); + if (envExtCancellationHandler) { + disposables.push(envExtCancellationHandler); + } + proc.stdout.on('data', handlers.onStdout); + proc.stderr.on('data', handlers.onStderr); proc.onExit((code, signal) => { - if (code !== 0) { - traceError( - `Subprocess exited unsuccessfully with exit code ${code} and signal ${signal} on workspace ${uri.fsPath}`, - ); - this.resultResolver?.resolveDiscovery(createDiscoveryErrorPayload(code, signal, cwd)); - } - deferredTillExecClose.resolve(); + handlers.onExit(code, signal); + handlers.onClose(code, signal); }); + await deferredTillExecClose.promise; - } else { - traceError(`Python Environment not found for: ${uri.fsPath}`); + traceInfo(`Pytest discovery completed for workspace ${uri.fsPath}`); + return; } - return; - } - const spawnOptions: SpawnOptions = { - cwd, - throwOnStdErr: true, - env: mutableEnv, - token, - }; - - // Create the Python environment in which to execute the command. - const creationOptions: ExecutionFactoryCreateWithEnvironmentOptions = { - allowEnvironmentFetchExceptions: false, - resource: uri, - interpreter, - }; - const execService = await executionFactory.createActivatedEnvironment(creationOptions); + // Execute using execution factory (fallback path) + traceInfo(`Using execution factory for pytest discovery in workspace ${uri.fsPath}`); + const creationOptions: ExecutionFactoryCreateWithEnvironmentOptions = { + allowEnvironmentFetchExceptions: false, + resource: uri, + interpreter, + }; + const execService = await executionFactory.createActivatedEnvironment(creationOptions); + if (!execService) { + traceError( + `Failed to create execution service for workspace ${uri.fsPath}. Cannot proceed with test discovery.`, + ); + deferredTillExecClose.resolve(); + return; + } + const execInfo = await execService.getExecutablePath(); + traceVerbose(`Using Python executable: ${execInfo} for workspace ${uri.fsPath}`); - const execInfo = await execService?.getExecutablePath(); - traceVerbose(`Executable path for pytest discovery: ${execInfo}.`); + // Check for cancellation before spawning process + if (token?.isCancellationRequested) { + traceInfo(`Pytest discovery cancelled before spawning process for workspace ${uri.fsPath}`); + deferredTillExecClose.resolve(); + return; + } - const deferredTillExecClose: Deferred = createTestingDeferred(); + const spawnOptions: SpawnOptions = { + cwd, + throwOnStdErr: true, + env: mutableEnv, + token, + }; + + let resultProc: ChildProcess | undefined; + + // Set up cancellation handler after all early return checks + const cancellationHandler = token?.onCancellationRequested(() => { + traceInfo(`Cancellation requested during pytest discovery for workspace ${uri.fsPath}`); + cleanupOnCancellation('pytest', resultProc, deferredTillExecClose, discoveryPipeCancellation, uri); + }); + if (cancellationHandler) { + disposables.push(cancellationHandler); + } - let resultProc: ChildProcess | undefined; + try { + const result = execService.execObservable(commandArgs, spawnOptions); + resultProc = result?.proc; - token?.onCancellationRequested(() => { - traceInfo(`Test discovery cancelled, killing pytest subprocess for workspace ${uri.fsPath}`); - // if the resultProc exists just call kill on it which will handle resolving the ExecClose deferred, otherwise resolve the deferred here. - if (resultProc) { - resultProc?.kill(); - } else { + if (!resultProc) { + traceError(`Failed to spawn pytest discovery subprocess for workspace ${uri.fsPath}`); + deferredTillExecClose.resolve(); + return; + } + traceInfo(`Started pytest discovery subprocess (execution factory) for workspace ${uri.fsPath}`); + } catch (error) { + traceError(`Error spawning pytest discovery subprocess for workspace ${uri.fsPath}: ${error}`); deferredTillExecClose.resolve(); - cSource.cancel(); + throw error; } - }); - const result = execService?.execObservable(execArgs, spawnOptions); - resultProc = result?.proc; - - // Take all output from the subprocess and add it to the test output channel. This will be the pytest output. - // Displays output to user and ensure the subprocess doesn't run into buffer overflow. - - result?.proc?.stdout?.on('data', (data) => { - const out = fixLogLinesNoTrailing(data.toString()); - traceInfo(out); - }); - result?.proc?.stderr?.on('data', (data) => { - const out = fixLogLinesNoTrailing(data.toString()); - traceError(out); - }); - result?.proc?.on('exit', (code, signal) => { - if (code !== 0) { - traceError( - `Subprocess exited unsuccessfully with exit code ${code} and signal ${signal} on workspace ${uri.fsPath}.`, - ); - } - }); - result?.proc?.on('close', (code, signal) => { - // pytest exits with code of 5 when 0 tests are found- this is not a failure for discovery. - if (code !== 0 && code !== 5) { - traceError( - `Subprocess exited unsuccessfully with exit code ${code} and signal ${signal} on workspace ${uri.fsPath}. Creating and sending error discovery payload`, - ); - this.resultResolver?.resolveDiscovery(createDiscoveryErrorPayload(code, signal, cwd)); - } - // due to the sync reading of the output. - deferredTillExecClose?.resolve(); - }); - await deferredTillExecClose.promise; + resultProc.stdout?.on('data', handlers.onStdout); + resultProc.stderr?.on('data', handlers.onStderr); + resultProc.on('exit', handlers.onExit); + resultProc.on('close', handlers.onClose); + + traceVerbose(`Waiting for pytest discovery subprocess to complete for workspace ${uri.fsPath}`); + await deferredTillExecClose.promise; + traceInfo(`Pytest discovery completed for workspace ${uri.fsPath}`); + } catch (error) { + traceError(`Error during pytest discovery for workspace ${uri.fsPath}: ${error}`); + deferredTillExecClose.resolve(); + throw error; + } finally { + // Dispose all cancellation handlers and event subscriptions + disposables.forEach((d) => d.dispose()); + // Dispose the discovery pipe cancellation token + discoveryPipeCancellation.dispose(); + } } } diff --git a/src/client/testing/testController/pytest/pytestExecutionAdapter.ts b/src/client/testing/testController/pytest/pytestExecutionAdapter.ts index 3b2f9f7de33a..102841c2e2dd 100644 --- a/src/client/testing/testController/pytest/pytestExecutionAdapter.ts +++ b/src/client/testing/testController/pytest/pytestExecutionAdapter.ts @@ -21,6 +21,7 @@ import * as utils from '../common/utils'; import { IEnvironmentVariablesProvider } from '../../../common/variables/types'; import { PythonEnvironment } from '../../../pythonEnvironments/info'; import { getEnvironment, runInBackground, useEnvExtension } from '../../../envExt/api.internal'; +import { ProjectAdapter } from '../common/projectAdapter'; export class PytestTestExecutionAdapter implements ITestExecutionAdapter { constructor( @@ -37,6 +38,7 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { executionFactory: IPythonExecutionFactory, debugLauncher?: ITestDebugLauncher, interpreter?: PythonEnvironment, + project?: ProjectAdapter, ): Promise { const deferredTillServerClose: Deferred = utils.createTestingDeferred(); @@ -71,6 +73,7 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { executionFactory, debugLauncher, interpreter, + project, ); } finally { await deferredTillServerClose.promise; @@ -87,6 +90,7 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { executionFactory: IPythonExecutionFactory, debugLauncher?: ITestDebugLauncher, interpreter?: PythonEnvironment, + project?: ProjectAdapter, ): Promise { const relativePathToPytest = 'python_files'; const fullPluginPath = path.join(EXTENSION_ROOT_DIR, relativePathToPytest); @@ -102,9 +106,17 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { const pythonPathCommand = [fullPluginPath, ...pythonPathParts].join(path.delimiter); mutableEnv.PYTHONPATH = pythonPathCommand; mutableEnv.TEST_RUN_PIPE = resultNamedPipeName; + + // Set PROJECT_ROOT_PATH for project-based testing (tells Python where to root the test tree) + if (project) { + mutableEnv.PROJECT_ROOT_PATH = project.projectUri.fsPath; + traceInfo(`[test-by-project] Setting PROJECT_ROOT_PATH=${project.projectUri.fsPath} for pytest execution`); + } + if (profileKind && profileKind === TestRunProfileKind.Coverage) { mutableEnv.COVERAGE_ENABLED = 'True'; } + const debugBool = profileKind && profileKind === TestRunProfileKind.Debug; // Create the Python environment in which to execute the command. @@ -155,6 +167,8 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { testProvider: PYTEST_PROVIDER, runTestIdsPort: testIdsFileName, pytestPort: resultNamedPipeName, + // Pass project for project-based debugging (Python path and session name derived from this) + project: project?.pythonProject, }; const sessionOptions: DebugSessionOptions = { testRun: runInstance, @@ -168,7 +182,9 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { sessionOptions, ); } else if (useEnvExtension()) { - const pythonEnv = await getEnvironment(uri); + // For project-based execution, use the project's Python environment + // Otherwise, fall back to getting the environment from the URI + const pythonEnv = project?.pythonEnvironment ?? (await getEnvironment(uri)); if (pythonEnv) { const deferredTillExecClose: Deferred = utils.createTestingDeferred(); diff --git a/src/client/testing/testController/pytest/pytestHelpers.ts b/src/client/testing/testController/pytest/pytestHelpers.ts new file mode 100644 index 000000000000..c6e748fb85a7 --- /dev/null +++ b/src/client/testing/testController/pytest/pytestHelpers.ts @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +import * as path from 'path'; +import * as fs from 'fs'; +import { traceInfo, traceWarn } from '../../../logging'; +import { addValueIfKeyNotExist, hasSymlinkParent } from '../common/utils'; + +/** + * Checks if the current working directory contains a symlink and ensures --rootdir is set in pytest args. + * This is required for pytest to correctly resolve relative paths in symlinked directories. + */ +export async function handleSymlinkAndRootDir(cwd: string, pytestArgs: string[]): Promise { + const stats = await fs.promises.lstat(cwd); + const resolvedPath = await fs.promises.realpath(cwd); + let isSymbolicLink = false; + if (stats.isSymbolicLink()) { + isSymbolicLink = true; + traceWarn(`Working directory is a symbolic link: ${cwd} -> ${resolvedPath}`); + } else if (resolvedPath !== cwd) { + traceWarn( + `Working directory resolves to different path: ${cwd} -> ${resolvedPath}. Checking for symlinks in parent directories.`, + ); + isSymbolicLink = await hasSymlinkParent(cwd); + } + if (isSymbolicLink) { + traceWarn( + `Symlink detected in path. Adding '--rootdir=${cwd}' to pytest args to ensure correct path resolution.`, + ); + pytestArgs = addValueIfKeyNotExist(pytestArgs, '--rootdir', cwd); + } + // if user has provided `--rootdir` then use that, otherwise add `cwd` + // root dir is required so pytest can find the relative paths and for symlinks + pytestArgs = addValueIfKeyNotExist(pytestArgs, '--rootdir', cwd); + return pytestArgs; +} + +/** + * Builds the environment variables required for pytest discovery. + * Sets PYTHONPATH to include the plugin path and TEST_RUN_PIPE for communication. + */ +export function buildPytestEnv( + envVars: { [key: string]: string | undefined } | undefined, + fullPluginPath: string, + discoveryPipeName: string, +): { [key: string]: string | undefined } { + const mutableEnv = { + ...envVars, + }; + // get python path from mutable env, it contains process.env as well + const pythonPathParts: string[] = mutableEnv.PYTHONPATH?.split(path.delimiter) ?? []; + const pythonPathCommand = [fullPluginPath, ...pythonPathParts].join(path.delimiter); + mutableEnv.PYTHONPATH = pythonPathCommand; + mutableEnv.TEST_RUN_PIPE = discoveryPipeName; + traceInfo( + `Environment variables set for pytest discovery: PYTHONPATH=${mutableEnv.PYTHONPATH}, TEST_RUN_PIPE=${mutableEnv.TEST_RUN_PIPE}`, + ); + return mutableEnv; +} diff --git a/src/client/testing/testController/unittest/testDiscoveryAdapter.ts b/src/client/testing/testController/unittest/testDiscoveryAdapter.ts index a40e25153fbc..558e01f3514d 100644 --- a/src/client/testing/testController/unittest/testDiscoveryAdapter.ts +++ b/src/client/testing/testController/unittest/testDiscoveryAdapter.ts @@ -1,33 +1,44 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import * as path from 'path'; -import { CancellationTokenSource, Uri } from 'vscode'; -import { CancellationToken } from 'vscode-jsonrpc'; +import { CancellationToken, Disposable, Uri } from 'vscode'; import { ChildProcess } from 'child_process'; import { IConfigurationService } from '../../../common/types'; import { EXTENSION_ROOT_DIR } from '../../../constants'; -import { - DiscoveredTestPayload, - ITestDiscoveryAdapter, - ITestResultResolver, - TestCommandOptions, - TestDiscoveryCommand, -} from '../common/types'; -import { createDeferred } from '../../../common/utils/async'; -import { EnvironmentVariables, IEnvironmentVariablesProvider } from '../../../common/variables/types'; +import { ITestDiscoveryAdapter, ITestResultResolver } from '../common/types'; +import { IEnvironmentVariablesProvider } from '../../../common/variables/types'; import { ExecutionFactoryCreateWithEnvironmentOptions, - ExecutionResult, IPythonExecutionFactory, SpawnOptions, } from '../../../common/process/types'; -import { createDiscoveryErrorPayload, fixLogLinesNoTrailing, startDiscoveryNamedPipe } from '../common/utils'; -import { traceError, traceInfo, traceLog, traceVerbose } from '../../../logging'; +import { traceError, traceInfo, traceVerbose } from '../../../logging'; import { getEnvironment, runInBackground, useEnvExtension } from '../../../envExt/api.internal'; +import { PythonEnvironment } from '../../../pythonEnvironments/info'; +import { createTestingDeferred } from '../common/utils'; +import { buildDiscoveryCommand, buildUnittestEnv as configureSubprocessEnv } from './unittestHelpers'; +import { cleanupOnCancellation, createProcessHandlers, setupDiscoveryPipe } from '../common/discoveryHelpers'; +import { ProjectAdapter } from '../common/projectAdapter'; /** - * Wrapper class for unittest test discovery. This is where we call `runTestCommand`. + * Configures the subprocess environment for unittest discovery. + * @param envVarsService Service to retrieve environment variables + * @param uri Workspace URI + * @param discoveryPipeName Name of the discovery pipe to pass to the subprocess + * @returns Configured environment variables for the subprocess + */ +async function configureDiscoveryEnv( + envVarsService: IEnvironmentVariablesProvider | undefined, + uri: Uri, + discoveryPipeName: string, +): Promise { + const envVars = await envVarsService?.getEnvironmentVariables(uri); + const mutableEnv = configureSubprocessEnv(envVars, discoveryPipeName); + return mutableEnv; +} + +/** + * Wrapper class for unittest test discovery. */ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter { constructor( @@ -36,181 +47,165 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter { private readonly envVarsService?: IEnvironmentVariablesProvider, ) {} - public async discoverTests( + async discoverTests( uri: Uri, executionFactory: IPythonExecutionFactory, token?: CancellationToken, + interpreter?: PythonEnvironment, + project?: ProjectAdapter, ): Promise { - const settings = this.configSettings.getSettings(uri); - const { unittestArgs } = settings.testing; - const cwd = settings.testing.cwd && settings.testing.cwd.length > 0 ? settings.testing.cwd : uri.fsPath; - - const cSource = new CancellationTokenSource(); - // Create a deferred to return to the caller - const deferredReturn = createDeferred(); - - token?.onCancellationRequested(() => { - traceInfo(`Test discovery cancelled.`); - cSource.cancel(); - deferredReturn.resolve(); - }); - - const name = await startDiscoveryNamedPipe((data: DiscoveredTestPayload) => { - if (!token?.isCancellationRequested) { - this.resultResolver?.resolveDiscovery(data); - } - }, cSource.token); - - // set up env with the pipe name - let env: EnvironmentVariables | undefined = await this.envVarsService?.getEnvironmentVariables(uri); - if (env === undefined) { - env = {} as EnvironmentVariables; + // Setup discovery pipe and cancellation + const { + pipeName: discoveryPipeName, + cancellation: discoveryPipeCancellation, + tokenDisposable, + } = await setupDiscoveryPipe(this.resultResolver, token, uri); + + // Setup process handlers deferred (used by both execution paths) + const deferredTillExecClose = createTestingDeferred(); + + // Collect all disposables for cleanup in finally block + const disposables: Disposable[] = []; + if (tokenDisposable) { + disposables.push(tokenDisposable); } - env.TEST_RUN_PIPE = name; - - const command = buildDiscoveryCommand(unittestArgs); - const options: TestCommandOptions = { - workspaceFolder: uri, - command, - cwd, - token, - }; + try { + // Build unittest command and arguments + const settings = this.configSettings.getSettings(uri); + const { unittestArgs } = settings.testing; + const cwd = settings.testing.cwd && settings.testing.cwd.length > 0 ? settings.testing.cwd : uri.fsPath; + const execArgs = buildDiscoveryCommand(unittestArgs, EXTENSION_ROOT_DIR); + traceVerbose(`Running unittest discovery with command: ${execArgs.join(' ')} for workspace ${uri.fsPath}.`); + + // Configure subprocess environment + const mutableEnv = await configureDiscoveryEnv(this.envVarsService, uri, discoveryPipeName); + + // Set PROJECT_ROOT_PATH for project-based testing (tells Python where to root the test tree) + if (project) { + mutableEnv.PROJECT_ROOT_PATH = project.projectUri.fsPath; + traceInfo( + `[test-by-project] Setting PROJECT_ROOT_PATH=${project.projectUri.fsPath} for unittest discovery`, + ); + } - this.runDiscovery(uri, options, name, cwd, cSource, executionFactory).then(() => { - deferredReturn.resolve(); - }); + // Setup process handlers (shared by both execution paths) + const handlers = createProcessHandlers('unittest', uri, cwd, this.resultResolver, deferredTillExecClose); - return deferredReturn.promise; - } - - async runDiscovery( - uri: Uri, - options: TestCommandOptions, - testRunPipeName: string, - cwd: string, - cSource: CancellationTokenSource, - executionFactory: IPythonExecutionFactory, - ): Promise { - // get and edit env vars - const mutableEnv = { - ...(await this.envVarsService?.getEnvironmentVariables(uri)), - }; - mutableEnv.TEST_RUN_PIPE = testRunPipeName; - const args = [options.command.script].concat(options.command.args); - - if (options.outChannel) { - options.outChannel.appendLine(`python ${args.join(' ')}`); - } - - if (useEnvExtension()) { - const pythonEnv = await getEnvironment(uri); - if (pythonEnv) { - const deferredTillExecClose = createDeferred(); + // Execute using environment extension if available + if (useEnvExtension()) { + traceInfo(`Using environment extension for unittest discovery in workspace ${uri.fsPath}`); + const pythonEnv = project?.pythonEnvironment ?? (await getEnvironment(uri)); + if (!pythonEnv) { + traceError( + `Python environment not found for workspace ${uri.fsPath}. Cannot proceed with test discovery.`, + ); + deferredTillExecClose.resolve(); + return; + } + traceVerbose(`Using Python environment: ${JSON.stringify(pythonEnv)}`); const proc = await runInBackground(pythonEnv, { cwd, - args, + args: execArgs, env: (mutableEnv as unknown) as { [key: string]: string }, }); - options.token?.onCancellationRequested(() => { - traceInfo(`Test discovery cancelled, killing unittest subprocess for workspace ${uri.fsPath}`); - proc.kill(); - deferredTillExecClose.resolve(); - cSource.cancel(); - }); - proc.stdout.on('data', (data) => { - const out = fixLogLinesNoTrailing(data.toString()); - traceInfo(out); - }); - proc.stderr.on('data', (data) => { - const out = fixLogLinesNoTrailing(data.toString()); - traceError(out); + traceInfo(`Started unittest discovery subprocess (environment extension) for workspace ${uri.fsPath}`); + + // Wire up cancellation and process events + const envExtCancellationHandler = token?.onCancellationRequested(() => { + cleanupOnCancellation('unittest', proc, deferredTillExecClose, discoveryPipeCancellation, uri); }); + if (envExtCancellationHandler) { + disposables.push(envExtCancellationHandler); + } + proc.stdout.on('data', handlers.onStdout); + proc.stderr.on('data', handlers.onStderr); proc.onExit((code, signal) => { - if (code !== 0) { - traceError( - `Subprocess exited unsuccessfully with exit code ${code} and signal ${signal} on workspace ${uri.fsPath}`, - ); - } - deferredTillExecClose.resolve(); + handlers.onExit(code, signal); + handlers.onClose(code, signal); }); + await deferredTillExecClose.promise; - } else { - traceError(`Python Environment not found for: ${uri.fsPath}`); + traceInfo(`Unittest discovery completed for workspace ${uri.fsPath}`); + return; } - return; - } - const spawnOptions: SpawnOptions = { - token: options.token, - cwd: options.cwd, - throwOnStdErr: true, - env: mutableEnv, - }; - - try { - traceLog(`Discovering unittest tests for workspace ${options.cwd} with arguments: ${args}\r\n`); - const deferredTillExecClose = createDeferred>(); - - // Create the Python environment in which to execute the command. + // Execute using execution factory (fallback path) + traceInfo(`Using execution factory for unittest discovery in workspace ${uri.fsPath}`); const creationOptions: ExecutionFactoryCreateWithEnvironmentOptions = { allowEnvironmentFetchExceptions: false, - resource: options.workspaceFolder, + resource: uri, + interpreter, }; const execService = await executionFactory.createActivatedEnvironment(creationOptions); - const execInfo = await execService?.getExecutablePath(); - traceVerbose(`Executable path for unittest discovery: ${execInfo}.`); + if (!execService) { + traceError( + `Failed to create execution service for workspace ${uri.fsPath}. Cannot proceed with test discovery.`, + ); + deferredTillExecClose.resolve(); + return; + } + const execInfo = await execService.getExecutablePath(); + traceVerbose(`Using Python executable: ${execInfo} for workspace ${uri.fsPath}`); + + // Check for cancellation before spawning process + if (token?.isCancellationRequested) { + traceInfo(`Unittest discovery cancelled before spawning process for workspace ${uri.fsPath}`); + deferredTillExecClose.resolve(); + return; + } + + const spawnOptions: SpawnOptions = { + cwd, + throwOnStdErr: true, + env: mutableEnv, + token, + }; let resultProc: ChildProcess | undefined; - options.token?.onCancellationRequested(() => { - traceInfo(`Test discovery cancelled, killing unittest subprocess for workspace ${uri.fsPath}`); - // if the resultProc exists just call kill on it which will handle resolving the ExecClose deferred, otherwise resolve the deferred here. - if (resultProc) { - resultProc?.kill(); - } else { - deferredTillExecClose.resolve(); - cSource.cancel(); - } - }); - const result = execService?.execObservable(args, spawnOptions); - resultProc = result?.proc; - // Displays output to user and ensure the subprocess doesn't run into buffer overflow. - result?.proc?.stdout?.on('data', (data) => { - const out = fixLogLinesNoTrailing(data.toString()); - traceInfo(out); - }); - result?.proc?.stderr?.on('data', (data) => { - const out = fixLogLinesNoTrailing(data.toString()); - traceError(out); + // Set up cancellation handler after all early return checks + const cancellationHandler = token?.onCancellationRequested(() => { + traceInfo(`Cancellation requested during unittest discovery for workspace ${uri.fsPath}`); + cleanupOnCancellation('unittest', resultProc, deferredTillExecClose, discoveryPipeCancellation, uri); }); + if (cancellationHandler) { + disposables.push(cancellationHandler); + } - result?.proc?.on('exit', (code, signal) => { - // if the child has testIds then this is a run request + try { + const result = execService.execObservable(execArgs, spawnOptions); + resultProc = result?.proc; - if (code !== 0) { - // This occurs when we are running discovery - traceError( - `Subprocess exited unsuccessfully with exit code ${code} and signal ${signal} on workspace ${options.cwd}. Creating and sending error discovery payload \n`, - ); - traceError( - `Subprocess exited unsuccessfully with exit code ${code} and signal ${signal} on workspace ${uri.fsPath}. Creating and sending error discovery payload`, - ); - this.resultResolver?.resolveDiscovery(createDiscoveryErrorPayload(code, signal, cwd)); + if (!resultProc) { + traceError(`Failed to spawn unittest discovery subprocess for workspace ${uri.fsPath}`); + deferredTillExecClose.resolve(); + return; } + traceInfo(`Started unittest discovery subprocess (execution factory) for workspace ${uri.fsPath}`); + } catch (error) { + traceError(`Error spawning unittest discovery subprocess for workspace ${uri.fsPath}: ${error}`); deferredTillExecClose.resolve(); - }); + throw error; + } + resultProc.stdout?.on('data', handlers.onStdout); + resultProc.stderr?.on('data', handlers.onStderr); + resultProc.on('exit', handlers.onExit); + resultProc.on('close', handlers.onClose); + + traceVerbose(`Waiting for unittest discovery subprocess to complete for workspace ${uri.fsPath}`); await deferredTillExecClose.promise; - } catch (ex) { - traceError(`Error while server attempting to run unittest command for workspace ${uri.fsPath}: ${ex}`); + traceInfo(`Unittest discovery completed for workspace ${uri.fsPath}`); + } catch (error) { + traceError(`Error during unittest discovery for workspace ${uri.fsPath}: ${error}`); + deferredTillExecClose.resolve(); + throw error; + } finally { + traceVerbose(`Cleaning up unittest discovery resources for workspace ${uri.fsPath}`); + // Dispose all cancellation handlers and event subscriptions + disposables.forEach((d) => d.dispose()); + // Dispose the discovery pipe cancellation token + discoveryPipeCancellation.dispose(); } } } -function buildDiscoveryCommand(args: string[]): TestDiscoveryCommand { - const discoveryScript = path.join(EXTENSION_ROOT_DIR, 'python_files', 'unittestadapter', 'discovery.py'); - - return { - script: discoveryScript, - args: ['--udiscovery', ...args], - }; -} diff --git a/src/client/testing/testController/unittest/testExecutionAdapter.ts b/src/client/testing/testController/unittest/testExecutionAdapter.ts index cbc1d2985f84..c7d21b768c5b 100644 --- a/src/client/testing/testController/unittest/testExecutionAdapter.ts +++ b/src/client/testing/testController/unittest/testExecutionAdapter.ts @@ -27,6 +27,8 @@ import { ITestDebugLauncher, LaunchOptions } from '../../common/types'; import { UNITTEST_PROVIDER } from '../../common/constants'; import * as utils from '../common/utils'; import { getEnvironment, runInBackground, useEnvExtension } from '../../../envExt/api.internal'; +import { PythonEnvironment } from '../../../pythonEnvironments/info'; +import { ProjectAdapter } from '../common/projectAdapter'; /** * Wrapper Class for unittest test execution. This is where we call `runTestCommand`? @@ -46,6 +48,8 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter { runInstance: TestRun, executionFactory: IPythonExecutionFactory, debugLauncher?: ITestDebugLauncher, + _interpreter?: PythonEnvironment, + project?: ProjectAdapter, ): Promise { // deferredTillServerClose awaits named pipe server close const deferredTillServerClose: Deferred = utils.createTestingDeferred(); @@ -80,6 +84,7 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter { profileKind, executionFactory, debugLauncher, + project, ); } catch (error) { traceError(`Error in running unittest tests: ${error}`); @@ -97,6 +102,7 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter { profileKind: boolean | TestRunProfileKind | undefined, executionFactory: IPythonExecutionFactory, debugLauncher?: ITestDebugLauncher, + project?: ProjectAdapter, ): Promise { const settings = this.configSettings.getSettings(uri); const { unittestArgs } = settings.testing; @@ -111,6 +117,15 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter { const pythonPathCommand = [cwd, ...pythonPathParts].join(path.delimiter); mutableEnv.PYTHONPATH = pythonPathCommand; mutableEnv.TEST_RUN_PIPE = resultNamedPipeName; + + // Set PROJECT_ROOT_PATH for project-based testing (tells Python where to root the test tree) + if (project) { + mutableEnv.PROJECT_ROOT_PATH = project.projectUri.fsPath; + traceInfo( + `[test-by-project] Setting PROJECT_ROOT_PATH=${project.projectUri.fsPath} for unittest execution`, + ); + } + if (profileKind && profileKind === TestRunProfileKind.Coverage) { mutableEnv.COVERAGE_ENABLED = cwd; } @@ -165,6 +180,8 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter { testProvider: UNITTEST_PROVIDER, runTestIdsPort: testIdsFileName, pytestPort: resultNamedPipeName, // change this from pytest + // Pass project for project-based debugging (Python path and session name derived from this) + project: project?.pythonProject, }; const sessionOptions: DebugSessionOptions = { testRun: runInstance, @@ -183,7 +200,7 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter { sessionOptions, ); } else if (useEnvExtension()) { - const pythonEnv = await getEnvironment(uri); + const pythonEnv = project?.pythonEnvironment ?? (await getEnvironment(uri)); if (pythonEnv) { traceInfo(`Running unittest with arguments: ${args.join(' ')} for workspace ${uri.fsPath} \r\n`); const deferredTillExecClose = createDeferred(); diff --git a/src/client/testing/testController/unittest/unittestHelpers.ts b/src/client/testing/testController/unittest/unittestHelpers.ts new file mode 100644 index 000000000000..249a78dda7b7 --- /dev/null +++ b/src/client/testing/testController/unittest/unittestHelpers.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +import * as path from 'path'; +import { traceInfo } from '../../../logging'; + +/** + * Builds the environment variables required for unittest discovery. + * Sets TEST_RUN_PIPE for communication. + */ +export function buildUnittestEnv( + envVars: { [key: string]: string | undefined } | undefined, + discoveryPipeName: string, +): { [key: string]: string | undefined } { + const mutableEnv = { + ...envVars, + }; + mutableEnv.TEST_RUN_PIPE = discoveryPipeName; + traceInfo(`Environment variables set for unittest discovery: TEST_RUN_PIPE=${mutableEnv.TEST_RUN_PIPE}`); + return mutableEnv; +} + +/** + * Builds the unittest discovery command. + */ +export function buildDiscoveryCommand(args: string[], extensionRootDir: string): string[] { + const discoveryScript = path.join(extensionRootDir, 'python_files', 'unittestadapter', 'discovery.py'); + return [discoveryScript, '--udiscovery', ...args]; +} diff --git a/src/client/testing/testController/workspaceTestAdapter.ts b/src/client/testing/testController/workspaceTestAdapter.ts index 75b9489f708e..f17687732f57 100644 --- a/src/client/testing/testController/workspaceTestAdapter.ts +++ b/src/client/testing/testController/workspaceTestAdapter.ts @@ -15,6 +15,7 @@ import { IPythonExecutionFactory } from '../../common/process/types'; import { ITestDebugLauncher } from '../common/types'; import { buildErrorNodeOptions } from './common/utils'; import { PythonEnvironment } from '../../pythonEnvironments/info'; +import { ProjectAdapter } from './common/projectAdapter'; /** * This class exposes a test-provider-agnostic way of discovering tests. @@ -47,6 +48,7 @@ export class WorkspaceTestAdapter { profileKind?: boolean | TestRunProfileKind, debugLauncher?: ITestDebugLauncher, interpreter?: PythonEnvironment, + project?: ProjectAdapter, ): Promise { if (this.executing) { traceError('Test execution already in progress, not starting a new one.'); @@ -84,6 +86,7 @@ export class WorkspaceTestAdapter { executionFactory, debugLauncher, interpreter, + project, ); deferred.resolve(); } catch (ex) { diff --git a/src/test/chat/utils.unit.test.ts b/src/test/chat/utils.unit.test.ts new file mode 100644 index 000000000000..8d45c1ac118f --- /dev/null +++ b/src/test/chat/utils.unit.test.ts @@ -0,0 +1,248 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { Uri, WorkspaceFolder } from 'vscode'; +import { resolveFilePath } from '../../client/chat/utils'; +import * as workspaceApis from '../../client/common/vscodeApis/workspaceApis'; + +suite('Chat Utils - resolveFilePath()', () => { + let getWorkspaceFoldersStub: sinon.SinonStub; + + setup(() => { + getWorkspaceFoldersStub = sinon.stub(workspaceApis, 'getWorkspaceFolders'); + getWorkspaceFoldersStub.returns([]); + }); + + teardown(() => { + sinon.restore(); + }); + + suite('When filepath is undefined or empty', () => { + test('Should return first workspace folder URI when workspace folders exist', () => { + const expectedUri = Uri.file('/test/workspace'); + const mockFolder: WorkspaceFolder = { + uri: expectedUri, + name: 'test', + index: 0, + }; + getWorkspaceFoldersStub.returns([mockFolder]); + + const result = resolveFilePath(undefined); + + expect(result?.toString()).to.equal(expectedUri.toString()); + }); + + test('Should return first folder when multiple workspace folders exist', () => { + const firstUri = Uri.file('/first/workspace'); + const secondUri = Uri.file('/second/workspace'); + const mockFolders: WorkspaceFolder[] = [ + { uri: firstUri, name: 'first', index: 0 }, + { uri: secondUri, name: 'second', index: 1 }, + ]; + getWorkspaceFoldersStub.returns(mockFolders); + + const result = resolveFilePath(undefined); + + expect(result?.toString()).to.equal(firstUri.toString()); + }); + + test('Should return undefined when no workspace folders exist', () => { + getWorkspaceFoldersStub.returns(undefined); + + const result = resolveFilePath(undefined); + + expect(result).to.be.undefined; + }); + + test('Should return undefined when workspace folders is empty array', () => { + getWorkspaceFoldersStub.returns([]); + + const result = resolveFilePath(undefined); + + expect(result).to.be.undefined; + }); + + test('Should return undefined for empty string when no workspace folders', () => { + getWorkspaceFoldersStub.returns(undefined); + + const result = resolveFilePath(''); + + expect(result).to.be.undefined; + }); + }); + + suite('Windows file paths', () => { + test('Should handle Windows path with lowercase drive letter', () => { + const filepath = 'c:\\GIT\\tests\\simple-python-app'; + + const result = resolveFilePath(filepath); + + expect(result).to.not.be.undefined; + expect(result?.scheme).to.equal('file'); + // Uri.file normalizes drive letters to lowercase + expect(result?.fsPath.toLowerCase()).to.include('git'); + }); + + test('Should handle Windows path with uppercase drive letter', () => { + const filepath = 'C:\\Users\\test\\project'; + + const result = resolveFilePath(filepath); + + expect(result).to.not.be.undefined; + expect(result?.scheme).to.equal('file'); + expect(result?.fsPath.toLowerCase()).to.include('users'); + }); + + test('Should handle Windows path with forward slashes', () => { + const filepath = 'C:/Users/test/project'; + + const result = resolveFilePath(filepath); + + expect(result).to.not.be.undefined; + expect(result?.scheme).to.equal('file'); + }); + }); + + suite('Unix file paths', () => { + test('Should handle Unix absolute path', () => { + const filepath = '/home/user/projects/myapp'; + + const result = resolveFilePath(filepath); + + expect(result).to.not.be.undefined; + expect(result?.scheme).to.equal('file'); + expect(result?.path).to.include('/home/user/projects/myapp'); + }); + + test('Should handle Unix root path', () => { + const filepath = '/'; + + const result = resolveFilePath(filepath); + + expect(result).to.not.be.undefined; + expect(result?.scheme).to.equal('file'); + }); + }); + + suite('Relative paths', () => { + test('Should handle relative path with dot prefix', () => { + const filepath = './src/main.py'; + + const result = resolveFilePath(filepath); + + expect(result).to.not.be.undefined; + expect(result?.scheme).to.equal('file'); + }); + + test('Should handle relative path without prefix', () => { + const filepath = 'src/main.py'; + + const result = resolveFilePath(filepath); + + expect(result).to.not.be.undefined; + expect(result?.scheme).to.equal('file'); + }); + + test('Should handle parent directory reference', () => { + const filepath = '../other-project/file.py'; + + const result = resolveFilePath(filepath); + + expect(result).to.not.be.undefined; + expect(result?.scheme).to.equal('file'); + }); + }); + + suite('URI schemes', () => { + test('Should handle file:// URI scheme', () => { + const filepath = 'file:///home/user/test.py'; + + const result = resolveFilePath(filepath); + + expect(result).to.not.be.undefined; + expect(result?.scheme).to.equal('file'); + expect(result?.path).to.include('/home/user/test.py'); + }); + + test('Should handle vscode-notebook:// URI scheme', () => { + const filepath = 'vscode-notebook://jupyter/notebook.ipynb'; + + const result = resolveFilePath(filepath); + + expect(result).to.not.be.undefined; + expect(result?.scheme).to.equal('vscode-notebook'); + }); + + test('Should handle untitled: URI scheme without double slash as file path', () => { + const filepath = 'untitled:Untitled-1'; + + const result = resolveFilePath(filepath); + + expect(result).to.not.be.undefined; + // untitled: doesn't have ://, so it will be treated as a file path + expect(result?.scheme).to.equal('file'); + }); + + test('Should handle https:// URI scheme', () => { + const filepath = 'https://example.com/path'; + + const result = resolveFilePath(filepath); + + expect(result).to.not.be.undefined; + expect(result?.scheme).to.equal('https'); + }); + + test('Should handle vscode-vfs:// URI scheme', () => { + const filepath = 'vscode-vfs://github/microsoft/vscode/file.ts'; + + const result = resolveFilePath(filepath); + + expect(result).to.not.be.undefined; + expect(result?.scheme).to.equal('vscode-vfs'); + }); + }); + + suite('Edge cases', () => { + test('Should handle path with spaces', () => { + const filepath = '/home/user/my project/file.py'; + + const result = resolveFilePath(filepath); + + expect(result).to.not.be.undefined; + expect(result?.scheme).to.equal('file'); + }); + + test('Should handle path with special characters', () => { + const filepath = '/home/user/project-name_v2/file.py'; + + const result = resolveFilePath(filepath); + + expect(result).to.not.be.undefined; + expect(result?.scheme).to.equal('file'); + }); + + test('Should not treat Windows drive letter colon as URI scheme', () => { + // Windows path should not be confused with a URI scheme + const filepath = 'd:\\projects\\test'; + + const result = resolveFilePath(filepath); + + expect(result).to.not.be.undefined; + expect(result?.scheme).to.equal('file'); + }); + + test('Should not treat single colon as URI scheme', () => { + // A path with a colon but not :// should be treated as a file + const filepath = 'c:somepath'; + + const result = resolveFilePath(filepath); + + expect(result).to.not.be.undefined; + expect(result?.scheme).to.equal('file'); + }); + }); +}); diff --git a/src/test/common/application/commands/reportIssueCommand.unit.test.ts b/src/test/common/application/commands/reportIssueCommand.unit.test.ts index b1884fa83c21..175a43d14007 100644 --- a/src/test/common/application/commands/reportIssueCommand.unit.test.ts +++ b/src/test/common/application/commands/reportIssueCommand.unit.test.ts @@ -126,17 +126,17 @@ suite('Report Issue Command', () => { ); const expectedData = fs.readFileSync(userDataTemplatePath, 'utf8'); - const args: [string, { extensionId: string; issueBody: string; data: string }] = capture< + const args: [string, { extensionId: string; issueBody: string; extensionData: string }] = capture< AllCommands, - { extensionId: string; issueBody: string; data: string } + { extensionId: string; issueBody: string; extensionData: string } >(cmdManager.executeCommand).last(); verify(cmdManager.registerCommand(Commands.ReportIssue, anything(), anything())).once(); verify(cmdManager.executeCommand('workbench.action.openIssueReporter', anything())).once(); expect(args[0]).to.be.equal('workbench.action.openIssueReporter'); - const { issueBody, data } = args[1]; + const { issueBody, extensionData } = args[1]; expect(issueBody).to.be.equal(expectedIssueBody); - expect(data).to.be.equal(expectedData); + expect(extensionData).to.be.equal(expectedData); }); test('Test if issue body is filled when only including settings which are explicitly set', async () => { @@ -167,16 +167,16 @@ suite('Report Issue Command', () => { ); const expectedData = fs.readFileSync(userDataTemplatePath, 'utf8'); - const args: [string, { extensionId: string; issueBody: string; data: string }] = capture< + const args: [string, { extensionId: string; issueBody: string; extensionData: string }] = capture< AllCommands, - { extensionId: string; issueBody: string; data: string } + { extensionId: string; issueBody: string; extensionData: string } >(cmdManager.executeCommand).last(); verify(cmdManager.executeCommand('workbench.action.openIssueReporter', anything())).once(); expect(args[0]).to.be.equal('workbench.action.openIssueReporter'); - const { issueBody, data } = args[1]; + const { issueBody, extensionData } = args[1]; expect(issueBody).to.be.equal(expectedIssueBody); - expect(data).to.be.equal(expectedData); + expect(extensionData).to.be.equal(expectedData); }); test('Should send telemetry event when run Report Issue Command', async () => { const sendTelemetryStub = sinon.stub(Telemetry, 'sendTelemetryEvent'); diff --git a/src/test/common/terminals/activation.unit.test.ts b/src/test/common/terminals/activation.unit.test.ts index 49ada1c06b11..d87d33ea03e6 100644 --- a/src/test/common/terminals/activation.unit.test.ts +++ b/src/test/common/terminals/activation.unit.test.ts @@ -3,6 +3,7 @@ 'use strict'; import { expect } from 'chai'; +import * as sinon from 'sinon'; import { anything, instance, mock, verify, when } from 'ts-mockito'; import * as TypeMoq from 'typemoq'; import { Terminal, Uri } from 'vscode'; @@ -15,6 +16,7 @@ import { IDisposable } from '../../../client/common/types'; import { TerminalAutoActivation } from '../../../client/terminals/activation'; import { ITerminalAutoActivation } from '../../../client/terminals/types'; import { noop } from '../../core'; +import * as extapi from '../../../client/envExt/api.internal'; suite('Terminal Auto Activation', () => { let activator: ITerminalActivator; @@ -25,6 +27,7 @@ suite('Terminal Auto Activation', () => { let terminal: Terminal; setup(() => { + sinon.stub(extapi, 'shouldEnvExtHandleActivation').returns(false); terminal = ({ dispose: noop, hide: noop, @@ -46,6 +49,9 @@ suite('Terminal Auto Activation', () => { instance(activeResourceService), ); }); + teardown(() => { + sinon.restore(); + }); test('New Terminals should be activated', async () => { type EventHandler = (e: Terminal) => void; diff --git a/src/test/common/terminals/activator/index.unit.test.ts b/src/test/common/terminals/activator/index.unit.test.ts index 6a50901bc99d..34d1cf8f1bcd 100644 --- a/src/test/common/terminals/activator/index.unit.test.ts +++ b/src/test/common/terminals/activator/index.unit.test.ts @@ -6,7 +6,7 @@ import { assert } from 'chai'; import * as sinon from 'sinon'; import * as TypeMoq from 'typemoq'; -import { Terminal } from 'vscode'; +import { Terminal, Uri } from 'vscode'; import { TerminalActivator } from '../../../../client/common/terminal/activator'; import { ITerminalActivationHandler, @@ -20,6 +20,8 @@ import { ITerminalSettings, } from '../../../../client/common/types'; import * as extapi from '../../../../client/envExt/api.internal'; +import * as workspaceApis from '../../../../client/common/vscodeApis/workspaceApis'; +import * as extensionsApi from '../../../../client/common/vscodeApis/extensionsApi'; suite('Terminal Activator', () => { let activator: TerminalActivator; @@ -29,9 +31,12 @@ suite('Terminal Activator', () => { let terminalSettings: TypeMoq.IMock; let experimentService: TypeMoq.IMock; let useEnvExtensionStub: sinon.SinonStub; + let shouldEnvExtHandleActivationStub: sinon.SinonStub; setup(() => { useEnvExtensionStub = sinon.stub(extapi, 'useEnvExtension'); useEnvExtensionStub.returns(false); + shouldEnvExtHandleActivationStub = sinon.stub(extapi, 'shouldEnvExtHandleActivation'); + shouldEnvExtHandleActivationStub.returns(false); baseActivator = TypeMoq.Mock.ofType(); terminalSettings = TypeMoq.Mock.ofType(); @@ -113,4 +118,92 @@ suite('Terminal Activator', () => { test('Terminal is not activated if auto-activate setting is set to true but terminal is hidden', () => testActivationAndHandlers(false, true, true)); test('Terminal is not activated and handlers are invoked', () => testActivationAndHandlers(false, false)); + + test('Terminal is not activated from Python extension when Env extension should handle activation', async () => { + shouldEnvExtHandleActivationStub.returns(true); + terminalSettings.setup((b) => b.activateEnvironment).returns(() => true); + baseActivator + .setup((b) => b.activateEnvironmentInTerminal(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .returns(() => Promise.resolve(true)) + .verifiable(TypeMoq.Times.never()); + + const terminal = TypeMoq.Mock.ofType(); + const activated = await activator.activateEnvironmentInTerminal(terminal.object, { + preserveFocus: true, + }); + + assert.strictEqual(activated, false); + baseActivator.verifyAll(); + }); +}); + +suite('shouldEnvExtHandleActivation', () => { + let getExtensionStub: sinon.SinonStub; + let getConfigurationStub: sinon.SinonStub; + let getWorkspaceFoldersStub: sinon.SinonStub; + + setup(() => { + getExtensionStub = sinon.stub(extensionsApi, 'getExtension'); + getConfigurationStub = sinon.stub(workspaceApis, 'getConfiguration'); + getWorkspaceFoldersStub = sinon.stub(workspaceApis, 'getWorkspaceFolders'); + getWorkspaceFoldersStub.returns(undefined); + }); + + teardown(() => { + sinon.restore(); + }); + + test('Returns false when envs extension is not installed', () => { + getExtensionStub.returns(undefined); + assert.strictEqual(extapi.shouldEnvExtHandleActivation(), false); + }); + + test('Returns true when envs extension is installed and setting is not explicitly set', () => { + getExtensionStub.returns({ id: extapi.ENVS_EXTENSION_ID }); + getConfigurationStub.returns({ + inspect: () => ({ globalValue: undefined, workspaceValue: undefined }), + }); + assert.strictEqual(extapi.shouldEnvExtHandleActivation(), true); + }); + + test('Returns false when envs extension is installed but globalValue is false', () => { + getExtensionStub.returns({ id: extapi.ENVS_EXTENSION_ID }); + getConfigurationStub.returns({ + inspect: () => ({ globalValue: false, workspaceValue: undefined }), + }); + assert.strictEqual(extapi.shouldEnvExtHandleActivation(), false); + }); + + test('Returns false when envs extension is installed but workspaceValue is false', () => { + getExtensionStub.returns({ id: extapi.ENVS_EXTENSION_ID }); + getConfigurationStub.returns({ + inspect: () => ({ globalValue: undefined, workspaceValue: false }), + }); + assert.strictEqual(extapi.shouldEnvExtHandleActivation(), false); + }); + + test('Returns true when envs extension is installed and setting is explicitly true', () => { + getExtensionStub.returns({ id: extapi.ENVS_EXTENSION_ID }); + getConfigurationStub.returns({ + inspect: () => ({ globalValue: true, workspaceValue: undefined }), + }); + assert.strictEqual(extapi.shouldEnvExtHandleActivation(), true); + }); + + test('Returns false when a workspace folder has workspaceFolderValue set to false', () => { + getExtensionStub.returns({ id: extapi.ENVS_EXTENSION_ID }); + const folderUri = Uri.parse('file:///workspace/folder1'); + getWorkspaceFoldersStub.returns([{ uri: folderUri, name: 'folder1', index: 0 }]); + getConfigurationStub.callsFake((_section: string, scope?: Uri) => { + if (scope) { + return { + inspect: () => ({ workspaceFolderValue: false }), + }; + } + return { + inspect: () => ({ globalValue: undefined, workspaceValue: undefined }), + }; + }); + assert.strictEqual(extapi.shouldEnvExtHandleActivation(), false); + }); }); diff --git a/src/test/common/terminals/service.unit.test.ts b/src/test/common/terminals/service.unit.test.ts index 63a1cd544940..3a6d54c9390b 100644 --- a/src/test/common/terminals/service.unit.test.ts +++ b/src/test/common/terminals/service.unit.test.ts @@ -14,8 +14,9 @@ import { Uri, Terminal as VSCodeTerminal, WorkspaceConfiguration, + TerminalDataWriteEvent, } from 'vscode'; -import { ITerminalManager, IWorkspaceService } from '../../../client/common/application/types'; +import { IApplicationShell, ITerminalManager, IWorkspaceService } from '../../../client/common/application/types'; import { EXTENSION_ROOT_DIR } from '../../../client/common/constants'; import { IPlatformService } from '../../../client/common/platform/types'; import { TerminalService } from '../../../client/common/terminal/service'; @@ -56,6 +57,9 @@ suite('Terminal Service', () => { let useEnvExtensionStub: sinon.SinonStub; let interpreterService: TypeMoq.IMock; let options: TypeMoq.IMock; + let applicationShell: TypeMoq.IMock; + let onDidWriteTerminalDataEmitter: EventEmitter; + let onDidChangeTerminalStateEmitter: EventEmitter; setup(() => { useEnvExtensionStub = sinon.stub(extapi, 'useEnvExtension'); @@ -118,6 +122,17 @@ suite('Terminal Service', () => { mockServiceContainer.setup((c) => c.get(ITerminalActivator)).returns(() => terminalActivator.object); mockServiceContainer.setup((c) => c.get(ITerminalAutoActivation)).returns(() => terminalAutoActivator.object); mockServiceContainer.setup((c) => c.get(IInterpreterService)).returns(() => interpreterService.object); + + applicationShell = TypeMoq.Mock.ofType(); + onDidWriteTerminalDataEmitter = new EventEmitter(); + applicationShell.setup((a) => a.onDidWriteTerminalData).returns(() => onDidWriteTerminalDataEmitter.event); + mockServiceContainer.setup((c) => c.get(IApplicationShell)).returns(() => applicationShell.object); + + onDidChangeTerminalStateEmitter = new EventEmitter(); + terminalManager + .setup((t) => t.onDidChangeTerminalState(TypeMoq.It.isAny())) + .returns((handler) => onDidChangeTerminalStateEmitter.event(handler)); + getConfigurationStub = sinon.stub(workspaceApis, 'getConfiguration'); isWindowsStub = sinon.stub(platform, 'isWindows'); pythonConfig = TypeMoq.Mock.ofType(); @@ -230,8 +245,10 @@ suite('Terminal Service', () => { terminalHelper.setup((h) => h.identifyTerminalShell(TypeMoq.It.isAny())).returns(() => TerminalShellType.bash); terminalManager.setup((t) => t.createTerminal(TypeMoq.It.isAny())).returns(() => terminal.object); - service.ensureTerminal(); - service.executeCommand(textToSend, true); + await service.ensureTerminal(); + const executePromise = service.executeCommand(textToSend, true); + onDidWriteTerminalDataEmitter.fire({ terminal: terminal.object, data: '>>> ' }); + await executePromise; terminal.verify((t) => t.show(TypeMoq.It.isValue(true)), TypeMoq.Times.exactly(1)); terminal.verify((t) => t.sendText(TypeMoq.It.isValue(textToSend)), TypeMoq.Times.exactly(1)); @@ -251,14 +268,16 @@ suite('Terminal Service', () => { terminalHelper.setup((h) => h.identifyTerminalShell(TypeMoq.It.isAny())).returns(() => TerminalShellType.bash); terminalManager.setup((t) => t.createTerminal(TypeMoq.It.isAny())).returns(() => terminal.object); - service.ensureTerminal(); - service.executeCommand(textToSend, true); + await service.ensureTerminal(); + const executePromise = service.executeCommand(textToSend, true); + onDidWriteTerminalDataEmitter.fire({ terminal: terminal.object, data: '>>> ' }); + await executePromise; terminal.verify((t) => t.show(TypeMoq.It.isValue(true)), TypeMoq.Times.exactly(1)); terminal.verify((t) => t.sendText(TypeMoq.It.isValue(textToSend)), TypeMoq.Times.exactly(1)); }); - test('Ensure sendText is NOT called when Python shell integration and terminal shell integration are both enabled - Mac, Linux && Python < 3.13', async () => { + test('Ensure sendText is called when Python shell integration and terminal shell integration are both enabled - Mac, Linux && Python < 3.13', async () => { isWindowsStub.returns(false); pythonConfig .setup((p) => p.get('terminal.shellIntegration.enabled')) @@ -273,11 +292,13 @@ suite('Terminal Service', () => { terminalHelper.setup((h) => h.identifyTerminalShell(TypeMoq.It.isAny())).returns(() => TerminalShellType.bash); terminalManager.setup((t) => t.createTerminal(TypeMoq.It.isAny())).returns(() => terminal.object); - service.ensureTerminal(); - service.executeCommand(textToSend, true); + await service.ensureTerminal(); + const executePromise = service.executeCommand(textToSend, true); + onDidWriteTerminalDataEmitter.fire({ terminal: terminal.object, data: '>>> ' }); + await executePromise; terminal.verify((t) => t.show(TypeMoq.It.isValue(true)), TypeMoq.Times.exactly(1)); - terminal.verify((t) => t.sendText(TypeMoq.It.isValue(textToSend)), TypeMoq.Times.never()); + terminal.verify((t) => t.sendText(TypeMoq.It.isValue(textToSend)), TypeMoq.Times.exactly(1)); }); test('Ensure sendText is called when Python shell integration and terminal shell integration are both enabled - Mac, Linux && Python >= 3.13', async () => { @@ -305,7 +326,9 @@ suite('Terminal Service', () => { terminalManager.setup((t) => t.createTerminal(TypeMoq.It.isAny())).returns(() => terminal.object); await service.ensureTerminal(); - await service.executeCommand(textToSend, true); + const executePromise = service.executeCommand(textToSend, true); + onDidWriteTerminalDataEmitter.fire({ terminal: terminal.object, data: '>>> ' }); + await executePromise; terminal.verify((t) => t.sendText(TypeMoq.It.isValue(textToSend)), TypeMoq.Times.once()); }); @@ -325,13 +348,39 @@ suite('Terminal Service', () => { terminalHelper.setup((h) => h.identifyTerminalShell(TypeMoq.It.isAny())).returns(() => TerminalShellType.bash); terminalManager.setup((t) => t.createTerminal(TypeMoq.It.isAny())).returns(() => terminal.object); - service.ensureTerminal(); - service.executeCommand(textToSend, true); + await service.ensureTerminal(); + const executePromise = service.executeCommand(textToSend, true); + onDidWriteTerminalDataEmitter.fire({ terminal: terminal.object, data: '>>> ' }); + await executePromise; terminal.verify((t) => t.show(TypeMoq.It.isValue(true)), TypeMoq.Times.exactly(1)); terminal.verify((t) => t.sendText(TypeMoq.It.isValue(textToSend)), TypeMoq.Times.exactly(1)); }); + test('Ensure REPL ready when onDidChangeTerminalState fires with python shell', async () => { + pythonConfig + .setup((p) => p.get('terminal.shellIntegration.enabled')) + .returns(() => false) + .verifiable(TypeMoq.Times.once()); + + terminalHelper + .setup((helper) => helper.getEnvironmentActivationCommands(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .returns(() => Promise.resolve(undefined)); + service = new TerminalService(mockServiceContainer.object); + const textToSend = 'Some Text'; + terminalHelper.setup((h) => h.identifyTerminalShell(TypeMoq.It.isAny())).returns(() => TerminalShellType.bash); + + terminal.setup((t) => t.state).returns(() => ({ isInteractedWith: true, shell: 'python' })); + terminalManager.setup((t) => t.createTerminal(TypeMoq.It.isAny())).returns(() => terminal.object); + + await service.ensureTerminal(); + const executePromise = service.executeCommand(textToSend, true); + onDidChangeTerminalStateEmitter.fire(terminal.object); + await executePromise; + + terminal.verify((t) => t.sendText(TypeMoq.It.isValue(textToSend)), TypeMoq.Times.exactly(1)); + }); + test('Ensure terminal is not shown if `hideFromUser` option is set to `true`', async () => { terminalHelper .setup((helper) => helper.getEnvironmentActivationCommands(TypeMoq.It.isAny(), TypeMoq.It.isAny())) diff --git a/src/test/interpreters/display.unit.test.ts b/src/test/interpreters/display.unit.test.ts index 7d53fbfc0561..d9be806ff709 100644 --- a/src/test/interpreters/display.unit.test.ts +++ b/src/test/interpreters/display.unit.test.ts @@ -59,7 +59,7 @@ suite('Interpreters Display', () => { let pathUtils: TypeMoq.IMock; let languageStatusItem: TypeMoq.IMock; let traceLogStub: sinon.SinonStub; - let useEnvExtensionStub: sinon.SinonStub; + let shouldEnvExtHandleActivationStub: sinon.SinonStub; async function createInterpreterDisplay(filters: IInterpreterStatusbarVisibilityFilter[] = []) { interpreterDisplay = new InterpreterDisplay(serviceContainer.object); try { @@ -69,8 +69,8 @@ suite('Interpreters Display', () => { } async function setupMocks(useLanguageStatus: boolean) { - useEnvExtensionStub = sinon.stub(extapi, 'useEnvExtension'); - useEnvExtensionStub.returns(false); + shouldEnvExtHandleActivationStub = sinon.stub(extapi, 'shouldEnvExtHandleActivation'); + shouldEnvExtHandleActivationStub.returns(false); serviceContainer = TypeMoq.Mock.ofType(); workspaceService = TypeMoq.Mock.ofType(); diff --git a/src/test/interpreters/recommededEnvironmentService.unit.test.ts b/src/test/interpreters/recommededEnvironmentService.unit.test.ts new file mode 100644 index 000000000000..7cb5aed58239 --- /dev/null +++ b/src/test/interpreters/recommededEnvironmentService.unit.test.ts @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { expect } from 'chai'; +import { anything, reset, verify, when } from 'ts-mockito'; +import { Disposable, Uri } from 'vscode'; +import { mockedVSCodeNamespaces } from '../vscode-mock'; +import { RecommendedEnvironmentService } from '../../client/interpreter/configuration/recommededEnvironmentService'; + +suite('RecommendedEnvironmentService - activate', () => { + let service: RecommendedEnvironmentService; + let subscriptions: Disposable[]; + + setup(() => { + subscriptions = []; + const extensionContext = { + subscriptions, + globalState: { + get: () => undefined, + update: () => Promise.resolve(), + }, + } as any; + + when(mockedVSCodeNamespaces.commands!.registerCommand(anything(), anything())).thenReturn({ + dispose: () => {}, + } as Disposable); + + service = new RecommendedEnvironmentService(extensionContext); + }); + + teardown(() => { + reset(mockedVSCodeNamespaces.commands!); + }); + + test('Multiroot workspace: command is registered only once across multiple activate calls', async () => { + // Simulate multiroot workspace where activate is called once per workspace root + const workspaceRoot1 = Uri.file('/workspace/root1'); + const workspaceRoot2 = Uri.file('/workspace/root2'); + const workspaceRoot3 = Uri.file('/workspace/root3'); + + await service.activate(workspaceRoot1); + await service.activate(workspaceRoot2); + await service.activate(workspaceRoot3); + + verify(mockedVSCodeNamespaces.commands!.registerCommand('python.getRecommendedEnvironment', anything())).once(); + expect(subscriptions).to.have.lengthOf(1); + }); +}); diff --git a/src/test/providers/terminal.unit.test.ts b/src/test/providers/terminal.unit.test.ts index ac39ded922c8..8f684835b7cf 100644 --- a/src/test/providers/terminal.unit.test.ts +++ b/src/test/providers/terminal.unit.test.ts @@ -29,10 +29,13 @@ suite('Terminal Provider', () => { let experimentService: TypeMoq.IMock; let terminalProvider: TerminalProvider; let useEnvExtensionStub: sinon.SinonStub; + let shouldEnvExtHandleActivationStub: sinon.SinonStub; const resource = Uri.parse('a'); setup(() => { useEnvExtensionStub = sinon.stub(extapi, 'useEnvExtension'); useEnvExtensionStub.returns(false); + shouldEnvExtHandleActivationStub = sinon.stub(extapi, 'shouldEnvExtHandleActivation'); + shouldEnvExtHandleActivationStub.returns(false); serviceContainer = TypeMoq.Mock.ofType(); commandManager = TypeMoq.Mock.ofType(); diff --git a/src/test/repl/nativeRepl.test.ts b/src/test/repl/nativeRepl.test.ts index c05bb311a839..2cf18cefe1f7 100644 --- a/src/test/repl/nativeRepl.test.ts +++ b/src/test/repl/nativeRepl.test.ts @@ -129,6 +129,8 @@ suite('REPL - Native REPL', () => { input: sinon.stub(), checkValidCommand: sinon.stub().resolves(true), dispose: sinon.stub(), + isExecuting: false, + isDisposed: false, }; // Track the number of times createPythonServer was called diff --git a/src/test/repl/replCommand.test.ts b/src/test/repl/replCommand.test.ts index 7c26ebd69c80..0b5edda863f9 100644 --- a/src/test/repl/replCommand.test.ts +++ b/src/test/repl/replCommand.test.ts @@ -1,6 +1,6 @@ // Create test suite and test cases for the `replUtils` module import * as TypeMoq from 'typemoq'; -import { Disposable } from 'vscode'; +import { commands, Disposable, Uri } from 'vscode'; import * as sinon from 'sinon'; import { expect } from 'chai'; import { IInterpreterService } from '../../client/interpreter/contracts'; @@ -9,6 +9,7 @@ import { ICodeExecutionHelper } from '../../client/terminals/types'; import * as replCommands from '../../client/repl/replCommands'; import * as replUtils from '../../client/repl/replUtils'; import * as nativeRepl from '../../client/repl/nativeRepl'; +import * as windowApis from '../../client/common/vscodeApis/windowApis'; import { Commands } from '../../client/common/constants'; import { PythonEnvironment } from '../../client/pythonEnvironments/info'; @@ -203,3 +204,47 @@ suite('REPL - register native repl command', () => { sinon.assert.notCalled(getNativeReplStub); }); }); + +suite('Native REPL getActiveInterpreter', () => { + let interpreterService: TypeMoq.IMock; + let executeCommandStub: sinon.SinonStub; + let getActiveResourceStub: sinon.SinonStub; + + setup(() => { + interpreterService = TypeMoq.Mock.ofType(); + executeCommandStub = sinon.stub(commands, 'executeCommand').resolves(undefined); + getActiveResourceStub = sinon.stub(windowApis, 'getActiveResource'); + }); + + teardown(() => { + sinon.restore(); + }); + + test('Uses active resource when uri is undefined', async () => { + const resource = Uri.file('/workspace/app.py'); + const expected = ({ path: 'ps' } as unknown) as PythonEnvironment; + getActiveResourceStub.returns(resource); + interpreterService + .setup((i) => i.getActiveInterpreter(TypeMoq.It.isValue(resource))) + .returns(() => Promise.resolve(expected)); + + const result = await replUtils.getActiveInterpreter(undefined, interpreterService.object); + + expect(result).to.equal(expected); + interpreterService.verify((i) => i.getActiveInterpreter(TypeMoq.It.isValue(resource)), TypeMoq.Times.once()); + sinon.assert.notCalled(executeCommandStub); + }); + + test('Triggers environment selection using active resource when interpreter is missing', async () => { + const resource = Uri.file('/workspace/app.py'); + getActiveResourceStub.returns(resource); + interpreterService + .setup((i) => i.getActiveInterpreter(TypeMoq.It.isValue(resource))) + .returns(() => Promise.resolve(undefined)); + + const result = await replUtils.getActiveInterpreter(undefined, interpreterService.object); + + expect(result).to.equal(undefined); + sinon.assert.calledWith(executeCommandStub, Commands.TriggerEnvironmentSelection, resource); + }); +}); diff --git a/src/test/smoke/smartSend.smoke.test.ts b/src/test/smoke/smartSend.smoke.test.ts index 80eabf356330..cae41cc094d5 100644 --- a/src/test/smoke/smartSend.smoke.test.ts +++ b/src/test/smoke/smartSend.smoke.test.ts @@ -19,11 +19,8 @@ suite('Smoke Test: Run Smart Selection and Advance Cursor', async () => { suiteTeardown(closeActiveWindows); teardown(closeActiveWindows); - // TODO: Re-enable this test once the flakiness on Windows is resolved - test('Smart Send', async function () { - if (process.platform === 'win32') { - return this.skip(); - } + // TODO: Re-enable this test once the flakiness on Windows, linux are resolved + test.skip('Smart Send', async function () { const file = path.join( EXTENSION_ROOT_DIR_FOR_TESTS, 'src', diff --git a/src/test/terminals/activation.unit.test.ts b/src/test/terminals/activation.unit.test.ts index dea0c891229d..4c5294a82f49 100644 --- a/src/test/terminals/activation.unit.test.ts +++ b/src/test/terminals/activation.unit.test.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { anything, instance, mock, verify, when } from 'ts-mockito'; +import * as sinon from 'sinon'; import { EventEmitter, Terminal } from 'vscode'; import { ActiveResourceService } from '../../client/common/application/activeResource'; import { TerminalManager } from '../../client/common/application/terminalManager'; @@ -11,6 +12,7 @@ import { ITerminalActivator } from '../../client/common/terminal/types'; import { TerminalAutoActivation } from '../../client/terminals/activation'; import { ITerminalAutoActivation } from '../../client/terminals/types'; import { noop } from '../core'; +import * as extapi from '../../client/envExt/api.internal'; suite('Terminal', () => { suite('Terminal Auto Activation', () => { @@ -21,8 +23,12 @@ suite('Terminal', () => { let onDidOpenTerminalEventEmitter: EventEmitter; let terminal: Terminal; let nonActivatedTerminal: Terminal; + let shouldEnvExtHandleActivationStub: sinon.SinonStub; setup(() => { + shouldEnvExtHandleActivationStub = sinon.stub(extapi, 'shouldEnvExtHandleActivation'); + shouldEnvExtHandleActivationStub.returns(false); + manager = mock(TerminalManager); activator = mock(TerminalActivator); resourceService = mock(ActiveResourceService); @@ -60,6 +66,9 @@ suite('Terminal', () => { autoActivation.register(); }); // teardown(() => fakeTimer.uninstall()); + teardown(() => { + sinon.restore(); + }); test('Should activate terminal', async () => { // Trigger opening a terminal. @@ -77,5 +86,12 @@ suite('Terminal', () => { // The terminal should get activated. verify(activator.activateEnvironmentInTerminal(anything(), anything())).never(); }); + test('Should not activate terminal when envs extension should handle activation', async () => { + shouldEnvExtHandleActivationStub.returns(true); + + await ((onDidOpenTerminalEventEmitter.fire(terminal) as unknown) as Promise); + + verify(activator.activateEnvironmentInTerminal(anything(), anything())).never(); + }); }); }); diff --git a/src/test/testing/common/debugLauncher.unit.test.ts b/src/test/testing/common/debugLauncher.unit.test.ts index 397ae03eafc2..86e862103bf6 100644 --- a/src/test/testing/common/debugLauncher.unit.test.ts +++ b/src/test/testing/common/debugLauncher.unit.test.ts @@ -30,6 +30,7 @@ import { TestProvider } from '../../../client/testing/types'; import { isOs, OSType } from '../../common'; import { IEnvironmentActivationService } from '../../../client/interpreter/activation/types'; import { createDeferred } from '../../../client/common/utils/async'; +import * as envExtApi from '../../../client/envExt/api.internal'; use(chaiAsPromised.default); @@ -106,7 +107,7 @@ suite('Unit Tests - Debug Launcher', () => { ); } function setupDebugManager( - workspaceFolder: WorkspaceFolder, + _workspaceFolder: WorkspaceFolder, expected: DebugConfiguration, testProvider: TestProvider, ) { @@ -123,35 +124,48 @@ suite('Unit Tests - Debug Launcher', () => { .returns(() => Promise.resolve(expected.env)); const deferred = createDeferred(); + let capturedConfig: DebugConfiguration | undefined; + // Use TypeMoq.It.isAny() because the implementation adds a session marker to the config debugService - .setup((d) => - d.startDebugging(TypeMoq.It.isValue(workspaceFolder), TypeMoq.It.isValue(expected), undefined), - ) - .returns((_wspc: WorkspaceFolder, _expectedParam: DebugConfiguration) => { + .setup((d) => d.startDebugging(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .callback((_wspc: WorkspaceFolder, config: DebugConfiguration) => { + capturedConfig = config; deferred.resolve(); - return Promise.resolve(undefined as any); - }); - - // create a fake debug session that the debug service will return on terminate - const fakeDebugSession = TypeMoq.Mock.ofType(); - fakeDebugSession.setup((ds) => ds.id).returns(() => 'id-val'); - const debugSessionInstance = fakeDebugSession.object; + }) + .returns(() => Promise.resolve(true)); + // Setup onDidStartDebugSession - the new implementation uses this to capture the session debugService - .setup((d) => d.activeDebugSession) - .returns(() => debugSessionInstance) - .verifiable(TypeMoq.Times.once()); + .setup((d) => d.onDidStartDebugSession(TypeMoq.It.isAny())) + .returns((callback) => { + deferred.promise.then(() => { + if (capturedConfig) { + callback(({ + id: 'test-session-id', + configuration: capturedConfig, + } as unknown) as DebugSession); + } + }); + return { dispose: () => {} }; + }); + // Setup onDidTerminateDebugSession - fires after the session starts debugService .setup((d) => d.onDidTerminateDebugSession(TypeMoq.It.isAny())) .returns((callback) => { deferred.promise.then(() => { - callback(debugSessionInstance); + setTimeout(() => { + if (capturedConfig) { + callback(({ + id: 'test-session-id', + configuration: capturedConfig, + } as unknown) as DebugSession); + } + }, 10); }); - return undefined as any; - }) - .verifiable(TypeMoq.Times.once()); + return { dispose: () => {} }; + }); } function createWorkspaceFolder(folderPath: string): WorkspaceFolder { return { @@ -692,4 +706,228 @@ suite('Unit Tests - Debug Launcher', () => { expect(configs).to.be.deep.equal([]); }); + + // ===== PROJECT-BASED DEBUG SESSION TESTS ===== + + suite('Project-based debug sessions', () => { + function setupForProjectTests(options: LaunchOptions) { + interpreterService + .setup((i) => i.getActiveInterpreter(TypeMoq.It.isAny())) + .returns(() => Promise.resolve(({ path: 'python' } as unknown) as PythonEnvironment)); + settings.setup((p) => p.envFile).returns(() => __filename); + + debugEnvHelper + .setup((x) => x.getEnvironmentVariables(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .returns(() => Promise.resolve({})); + + const workspaceFolders = [{ index: 0, name: 'test', uri: Uri.file(options.cwd) }]; + getWorkspaceFoldersStub.returns(workspaceFolders); + getWorkspaceFolderStub.returns(workspaceFolders[0]); + pathExistsStub.resolves(false); + + // Stub useEnvExtension to avoid null reference errors in tests + sinon.stub(envExtApi, 'useEnvExtension').returns(false); + } + + /** + * Helper to setup debug service mocks with proper session lifecycle simulation. + * The implementation uses onDidStartDebugSession to capture the session via marker, + * then onDidTerminateDebugSession to resolve when that session ends. + */ + function setupDebugServiceWithSessionLifecycle(): { + capturedConfigs: DebugConfiguration[]; + } { + const capturedConfigs: DebugConfiguration[] = []; + let startCallback: ((session: DebugSession) => void) | undefined; + let terminateCallback: ((session: DebugSession) => void) | undefined; + + debugService + .setup((d) => d.startDebugging(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .callback((_, config) => { + capturedConfigs.push(config); + // Simulate the full session lifecycle after startDebugging resolves + setTimeout(() => { + const session = ({ + id: `session-${capturedConfigs.length}`, + configuration: config, + } as unknown) as DebugSession; + // Fire start first (so ourSession is captured) + startCallback?.(session); + // Then fire terminate (so the promise resolves) + setTimeout(() => terminateCallback?.(session), 5); + }, 5); + }) + .returns(() => Promise.resolve(true)); + + debugService + .setup((d) => d.onDidStartDebugSession(TypeMoq.It.isAny())) + .callback((cb) => { + startCallback = cb; + }) + .returns(() => ({ dispose: () => {} })); + + debugService + .setup((d) => d.onDidTerminateDebugSession(TypeMoq.It.isAny())) + .callback((cb) => { + terminateCallback = cb; + }) + .returns(() => ({ dispose: () => {} })); + + return { capturedConfigs }; + } + + test('should use project name in config name when provided', async () => { + const options: LaunchOptions = { + cwd: 'one/two/three', + args: ['/one/two/three/testfile.py'], + testProvider: 'pytest', + runTestIdsPort: 'runTestIdsPort', + pytestPort: 'pytestPort', + project: { name: 'myproject (Python 3.11)', uri: Uri.file('one/two/three') }, + }; + + setupForProjectTests(options); + const { capturedConfigs } = setupDebugServiceWithSessionLifecycle(); + + await debugLauncher.launchDebugger(options); + + expect(capturedConfigs).to.have.length(1); + expect(capturedConfigs[0].name).to.equal('Debug Tests: myproject (Python 3.11)'); + }); + + test('should use default python when no project provided', async () => { + const options: LaunchOptions = { + cwd: 'one/two/three', + args: ['/one/two/three/testfile.py'], + testProvider: 'pytest', + runTestIdsPort: 'runTestIdsPort', + pytestPort: 'pytestPort', + }; + + setupForProjectTests(options); + const { capturedConfigs } = setupDebugServiceWithSessionLifecycle(); + + await debugLauncher.launchDebugger(options); + + expect(capturedConfigs).to.have.length(1); + // Should use the default 'python' from interpreterService mock + expect(capturedConfigs[0].python).to.equal('python'); + }); + + test('should add unique session marker to launch config', async () => { + const options: LaunchOptions = { + cwd: 'one/two/three', + args: ['/one/two/three/testfile.py'], + testProvider: 'pytest', + runTestIdsPort: 'runTestIdsPort', + pytestPort: 'pytestPort', + }; + + setupForProjectTests(options); + const { capturedConfigs } = setupDebugServiceWithSessionLifecycle(); + + await debugLauncher.launchDebugger(options); + + expect(capturedConfigs).to.have.length(1); + // Should have a session marker of format 'test-{timestamp}-{random}' + const marker = (capturedConfigs[0] as any).__vscodeTestSessionMarker; + expect(marker).to.be.a('string'); + expect(marker).to.match(/^test-\d+-[a-z0-9]+$/); + }); + + test('should generate unique markers for each launch', async () => { + const options: LaunchOptions = { + cwd: 'one/two/three', + args: ['/one/two/three/testfile.py'], + testProvider: 'pytest', + runTestIdsPort: 'runTestIdsPort', + pytestPort: 'pytestPort', + }; + + setupForProjectTests(options); + const { capturedConfigs } = setupDebugServiceWithSessionLifecycle(); + + // Launch twice + await debugLauncher.launchDebugger(options); + await debugLauncher.launchDebugger(options); + + expect(capturedConfigs).to.have.length(2); + const marker1 = (capturedConfigs[0] as any).__vscodeTestSessionMarker; + const marker2 = (capturedConfigs[1] as any).__vscodeTestSessionMarker; + expect(marker1).to.not.equal(marker2); + }); + + test('should only resolve when matching session terminates', async () => { + const options: LaunchOptions = { + cwd: 'one/two/three', + args: ['/one/two/three/testfile.py'], + testProvider: 'pytest', + runTestIdsPort: 'runTestIdsPort', + pytestPort: 'pytestPort', + }; + + setupForProjectTests(options); + + let capturedConfig: DebugConfiguration | undefined; + let terminateCallback: ((session: DebugSession) => void) | undefined; + let startCallback: ((session: DebugSession) => void) | undefined; + + debugService + .setup((d) => d.startDebugging(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .callback((_, config) => { + capturedConfig = config; + }) + .returns(() => Promise.resolve(true)); + + debugService + .setup((d) => d.onDidStartDebugSession(TypeMoq.It.isAny())) + .callback((cb) => { + startCallback = cb; + }) + .returns(() => ({ dispose: () => {} })); + + debugService + .setup((d) => d.onDidTerminateDebugSession(TypeMoq.It.isAny())) + .callback((cb) => { + terminateCallback = cb; + }) + .returns(() => ({ dispose: () => {} })); + + const launchPromise = debugLauncher.launchDebugger(options); + + // Wait for config to be captured + await new Promise((r) => setTimeout(r, 10)); + + // Simulate our session starting + const ourSession = ({ + id: 'our-session-id', + configuration: capturedConfig!, + } as unknown) as DebugSession; + startCallback?.(ourSession); + + // Create a different session (like another project's debug) + const otherSession = ({ + id: 'other-session-id', + configuration: { __vscodeTestSessionMarker: 'different-marker' }, + } as unknown) as DebugSession; + + // Terminate the OTHER session first - should NOT resolve our promise + terminateCallback?.(otherSession); + + // Wait a bit to ensure it didn't resolve + let resolved = false; + const checkPromise = launchPromise.then(() => { + resolved = true; + }); + + await new Promise((r) => setTimeout(r, 20)); + expect(resolved).to.be.false; + + // Now terminate OUR session - should resolve + terminateCallback?.(ourSession); + + await checkPromise; + expect(resolved).to.be.true; + }); + }); }); diff --git a/src/test/testing/common/testingAdapter.test.ts b/src/test/testing/common/testingAdapter.test.ts index 97c04d5dfdf1..478e9dd85744 100644 --- a/src/test/testing/common/testingAdapter.test.ts +++ b/src/test/testing/common/testingAdapter.test.ts @@ -161,11 +161,10 @@ suite('End to End Tests: test adapters', () => { resultResolver = new PythonResultResolver(testController, unittestProvider, workspaceUri); let callCount = 0; // const deferredTillEOT = createTestingDeferred(); - resultResolver._resolveDiscovery = async (payload, _token?) => { + resultResolver.resolveDiscovery = (payload, _token?) => { traceLog(`resolveDiscovery ${payload}`); callCount = callCount + 1; actualData = payload; - return Promise.resolve(); }; // set workspace to test workspace folder and set up settings @@ -202,11 +201,10 @@ suite('End to End Tests: test adapters', () => { }; resultResolver = new PythonResultResolver(testController, unittestProvider, workspaceUri); let callCount = 0; - resultResolver._resolveDiscovery = async (payload, _token?) => { + resultResolver.resolveDiscovery = (payload, _token?) => { traceLog(`resolveDiscovery ${payload}`); callCount = callCount + 1; actualData = payload; - return Promise.resolve(); }; // set settings to work for the given workspace @@ -242,10 +240,9 @@ suite('End to End Tests: test adapters', () => { workspaceUri = Uri.parse(rootPathSmallWorkspace); resultResolver = new PythonResultResolver(testController, pytestProvider, workspaceUri); let callCount = 0; - resultResolver._resolveDiscovery = async (payload, _token?) => { + resultResolver.resolveDiscovery = (payload, _token?) => { callCount = callCount + 1; actualData = payload; - return Promise.resolve(); }; // run pytest discovery const discoveryAdapter = new PytestTestDiscoveryAdapter(configService, resultResolver, envVarsService); @@ -291,11 +288,10 @@ suite('End to End Tests: test adapters', () => { resultResolver = new PythonResultResolver(testController, pytestProvider, workspaceUri); let callCount = 0; - resultResolver._resolveDiscovery = async (payload, _token?) => { + resultResolver.resolveDiscovery = (payload, _token?) => { traceLog(`resolveDiscovery ${payload}`); callCount = callCount + 1; actualData = payload; - return Promise.resolve(); }; // run pytest discovery const discoveryAdapter = new PytestTestDiscoveryAdapter(configService, resultResolver, envVarsService); @@ -375,11 +371,10 @@ suite('End to End Tests: test adapters', () => { resultResolver = new PythonResultResolver(testController, pytestProvider, workspaceUri); let callCount = 0; - resultResolver._resolveDiscovery = async (payload, _token?) => { + resultResolver.resolveDiscovery = (payload, _token?) => { traceLog(`resolveDiscovery ${payload}`); callCount = callCount + 1; actualData = payload; - return Promise.resolve(); }; // run pytest discovery const discoveryAdapter = new PytestTestDiscoveryAdapter(configService, resultResolver, envVarsService); @@ -446,11 +441,10 @@ suite('End to End Tests: test adapters', () => { }; resultResolver = new PythonResultResolver(testController, pytestProvider, workspaceUri); let callCount = 0; - resultResolver._resolveDiscovery = async (payload, _token?) => { + resultResolver.resolveDiscovery = (payload, _token?) => { traceLog(`resolveDiscovery ${payload}`); callCount = callCount + 1; actualData = payload; - return Promise.resolve(); }; // run pytest discovery const discoveryAdapter = new PytestTestDiscoveryAdapter(configService, resultResolver, envVarsService); @@ -480,22 +474,23 @@ suite('End to End Tests: test adapters', () => { let callCount = 0; let failureOccurred = false; let failureMsg = ''; - resultResolver._resolveExecution = async (payload, _token?) => { + resultResolver.resolveExecution = (payload, _token?) => { traceLog(`resolveDiscovery ${payload}`); callCount = callCount + 1; // the payloads that get to the _resolveExecution are all data and should be successful. try { - assert.strictEqual( - payload.status, - 'success', - `Expected status to be 'success', instead status is ${payload.status}`, - ); - assert.ok(payload.result, 'Expected results to be present'); + if ('status' in payload) { + assert.strictEqual( + payload.status, + 'success', + `Expected status to be 'success', instead status is ${payload.status}`, + ); + assert.ok(payload.result, 'Expected results to be present'); + } } catch (err) { failureMsg = err ? (err as Error).toString() : ''; failureOccurred = true; } - return Promise.resolve(); }; // set workspace to test workspace folder @@ -554,22 +549,25 @@ suite('End to End Tests: test adapters', () => { let callCount = 0; let failureOccurred = false; let failureMsg = ''; - resultResolver._resolveExecution = async (payload, _token?) => { + resultResolver.resolveExecution = (payload, _token?) => { traceLog(`resolveDiscovery ${payload}`); callCount = callCount + 1; // the payloads that get to the _resolveExecution are all data and should be successful. try { - const validStatuses = ['subtest-success', 'subtest-failure']; - assert.ok( - validStatuses.includes(payload.status), - `Expected status to be one of ${validStatuses.join(', ')}, but instead status is ${payload.status}`, - ); - assert.ok(payload.result, 'Expected results to be present'); + if ('status' in payload) { + const validStatuses = ['subtest-success', 'subtest-failure']; + assert.ok( + validStatuses.includes(payload.status), + `Expected status to be one of ${validStatuses.join(', ')}, but instead status is ${ + payload.status + }`, + ); + assert.ok(payload.result, 'Expected results to be present'); + } } catch (err) { failureMsg = err ? (err as Error).toString() : ''; failureOccurred = true; } - return Promise.resolve(); }; // set workspace to test workspace folder @@ -625,22 +623,23 @@ suite('End to End Tests: test adapters', () => { let callCount = 0; let failureOccurred = false; let failureMsg = ''; - resultResolver._resolveExecution = async (payload, _token?) => { + resultResolver.resolveExecution = (payload, _token?) => { traceLog(`resolveDiscovery ${payload}`); callCount = callCount + 1; // the payloads that get to the _resolveExecution are all data and should be successful. try { - assert.strictEqual( - payload.status, - 'success', - `Expected status to be 'success', instead status is ${payload.status}`, - ); - assert.ok(payload.result, 'Expected results to be present'); + if ('status' in payload) { + assert.strictEqual( + payload.status, + 'success', + `Expected status to be 'success', instead status is ${payload.status}`, + ); + assert.ok(payload.result, 'Expected results to be present'); + } } catch (err) { failureMsg = err ? (err as Error).toString() : ''; failureOccurred = true; } - return Promise.resolve(); }; // set workspace to test workspace folder workspaceUri = Uri.parse(rootPathSmallWorkspace); @@ -707,7 +706,7 @@ suite('End to End Tests: test adapters', () => { test('Unittest execution with coverage, small workspace', async () => { // result resolver and saved data for assertions resultResolver = new PythonResultResolver(testController, unittestProvider, workspaceUri); - resultResolver._resolveCoverage = async (payload, _token?) => { + resultResolver._resolveCoverage = (payload, _token?) => { assert.strictEqual(payload.cwd, rootPathCoverageWorkspace, 'Expected cwd to be the workspace folder'); assert.ok(payload.result, 'Expected results to be present'); const simpleFileCov = payload.result[`${rootPathCoverageWorkspace}/even.py`]; @@ -717,7 +716,6 @@ suite('End to End Tests: test adapters', () => { assert.strictEqual(simpleFileCov.lines_missed.length, 1, 'Expected 3 lines to be missed in even.py'); assert.strictEqual(simpleFileCov.executed_branches, 1, 'Expected 1 branch to be executed in even.py'); assert.strictEqual(simpleFileCov.total_branches, 2, 'Expected 2 branches in even.py'); - return Promise.resolve(); }; // set workspace to test workspace folder @@ -757,7 +755,7 @@ suite('End to End Tests: test adapters', () => { test('pytest coverage execution, small workspace', async () => { // result resolver and saved data for assertions resultResolver = new PythonResultResolver(testController, pytestProvider, workspaceUri); - resultResolver._resolveCoverage = async (payload, _runInstance?) => { + resultResolver._resolveCoverage = (payload, _runInstance?) => { assert.strictEqual(payload.cwd, rootPathCoverageWorkspace, 'Expected cwd to be the workspace folder'); assert.ok(payload.result, 'Expected results to be present'); const simpleFileCov = payload.result[`${rootPathCoverageWorkspace}/even.py`]; @@ -767,8 +765,6 @@ suite('End to End Tests: test adapters', () => { assert.strictEqual(simpleFileCov.lines_missed.length, 1, 'Expected 3 lines to be missed in even.py'); assert.strictEqual(simpleFileCov.executed_branches, 1, 'Expected 1 branch to be executed in even.py'); assert.strictEqual(simpleFileCov.total_branches, 2, 'Expected 2 branches in even.py'); - - return Promise.resolve(); }; // set workspace to test workspace folder workspaceUri = Uri.parse(rootPathCoverageWorkspace); @@ -811,22 +807,23 @@ suite('End to End Tests: test adapters', () => { let callCount = 0; let failureOccurred = false; let failureMsg = ''; - resultResolver._resolveExecution = async (payload, _token?) => { + resultResolver.resolveExecution = (payload, _token?) => { traceLog(`resolveDiscovery ${payload}`); callCount = callCount + 1; // the payloads that get to the _resolveExecution are all data and should be successful. try { - assert.strictEqual( - payload.status, - 'success', - `Expected status to be 'success', instead status is ${payload.status}`, - ); - assert.ok(payload.result, 'Expected results to be present'); + if ('status' in payload) { + assert.strictEqual( + payload.status, + 'success', + `Expected status to be 'success', instead status is ${payload.status}`, + ); + assert.ok(payload.result, 'Expected results to be present'); + } } catch (err) { failureMsg = err ? (err as Error).toString() : ''; failureOccurred = true; } - return Promise.resolve(); }; // set workspace to test workspace folder @@ -878,7 +875,7 @@ suite('End to End Tests: test adapters', () => { let callCount = 0; let failureOccurred = false; let failureMsg = ''; - resultResolver._resolveDiscovery = async (data, _token?) => { + resultResolver.resolveDiscovery = (data, _token?) => { // do the following asserts for each time resolveExecution is called, should be called once per test. callCount = callCount + 1; traceLog(`unittest discovery adapter seg fault error handling \n ${JSON.stringify(data)}`); @@ -903,7 +900,6 @@ suite('End to End Tests: test adapters', () => { failureMsg = err ? (err as Error).toString() : ''; failureOccurred = true; } - return Promise.resolve(); }; // set workspace to test workspace folder @@ -931,7 +927,7 @@ suite('End to End Tests: test adapters', () => { let callCount = 0; let failureOccurred = false; let failureMsg = ''; - resultResolver._resolveDiscovery = async (data, _token?) => { + resultResolver.resolveDiscovery = (data, _token?) => { // do the following asserts for each time resolveExecution is called, should be called once per test. callCount = callCount + 1; traceLog(`add one to call count, is now ${callCount}`); @@ -961,7 +957,6 @@ suite('End to End Tests: test adapters', () => { failureMsg = err ? (err as Error).toString() : ''; failureOccurred = true; } - return Promise.resolve(); }; // run pytest discovery const discoveryAdapter = new PytestTestDiscoveryAdapter(configService, resultResolver, envVarsService); @@ -984,22 +979,24 @@ suite('End to End Tests: test adapters', () => { let callCount = 0; let failureOccurred = false; let failureMsg = ''; - resultResolver._resolveExecution = async (data, _token?) => { + resultResolver.resolveExecution = (data, _token?) => { // do the following asserts for each time resolveExecution is called, should be called once per test. console.log(`pytest execution adapter seg fault error handling \n ${JSON.stringify(data)}`); callCount = callCount + 1; try { - if (data.status === 'error') { - assert.ok(data.error, "Expected errors in 'error' field"); - } else { - const indexOfTest = JSON.stringify(data.result).search('error'); - assert.notDeepEqual( - indexOfTest, - -1, - 'If payload status is not error then the individual tests should be marked as errors. This should occur on windows machines.', - ); + if ('status' in data) { + if (data.status === 'error') { + assert.ok(data.error, "Expected errors in 'error' field"); + } else { + const indexOfTest = JSON.stringify(data.result).search('error'); + assert.notDeepEqual( + indexOfTest, + -1, + 'If payload status is not error then the individual tests should be marked as errors. This should occur on windows machines.', + ); + } + assert.ok(data.result, 'Expected results to be present'); } - assert.ok(data.result, 'Expected results to be present'); // make sure the testID is found in the results const indexOfTest = JSON.stringify(data).search( 'test_seg_fault.py::TestSegmentationFault::test_segfault', @@ -1009,7 +1006,6 @@ suite('End to End Tests: test adapters', () => { failureMsg = err ? (err as Error).toString() : ''; failureOccurred = true; } - return Promise.resolve(); }; const testId = `${rootPathErrorWorkspace}/test_seg_fault.py::TestSegmentationFault::test_segfault`; @@ -1038,8 +1034,8 @@ suite('End to End Tests: test adapters', () => { }); }); - test('_resolveExecution performance test: validates efficient test result processing', async () => { - // This test validates that _resolveExecution processes test results efficiently + test('resolveExecution performance test: validates efficient test result processing', async () => { + // This test validates that resolveExecution processes test results efficiently // without expensive tree rebuilding or linear searching operations. // // The test ensures that processing many test results (like parameterized tests) @@ -1085,21 +1081,23 @@ suite('End to End Tests: test adapters', () => { const testItemUtilities = require('../../../client/testing/testController/common/testItemUtilities'); testItemUtilities.getTestCaseNodes = getTestCaseNodesSpy; + // Stub isTestItemValid to always return true for performance test + // This prevents expensive tree searches during validation + const testItemIndexStub = sinon.stub((resultResolver as any).testItemIndex, 'isTestItemValid').returns(true); + // Wrap the _resolveExecution function to measure performance - const original_resolveExecution = resultResolver._resolveExecution.bind(resultResolver); - resultResolver._resolveExecution = async (payload, runInstance) => { + const original_resolveExecution = resultResolver.resolveExecution.bind(resultResolver); + resultResolver.resolveExecution = (payload, runInstance) => { const startTime = performance.now(); callCount++; // Call the actual implementation - await original_resolveExecution(payload, runInstance); + original_resolveExecution(payload, runInstance); const endTime = performance.now(); const callTime = endTime - startTime; callTimes.push(callTime); totalCallTime += callTime; - - return Promise.resolve(); }; // ================================================================ @@ -1160,7 +1158,8 @@ suite('End to End Tests: test adapters', () => { } // Create payload with multiple test results (simulates real test execution) const testResults: Record = {}; for (let i = 0; i < numParameterizedResults; i++) { - testResults[`test_0_${i % 20}`] = { + // Use test IDs that actually exist in our mock setup (test_0_0 through test_0_9) + testResults[`test_0_${i % testFunctionsPerFile}`] = { test: `test_method[${i}]`, outcome: 'success', message: null, @@ -1189,8 +1188,8 @@ suite('End to End Tests: test adapters', () => { const overallStartTime = performance.now(); - // Run the _resolveExecution function with test data - await resultResolver._resolveExecution(payload, mockRunInstance as any); + // Run the resolveExecution function with test data + await resultResolver.resolveExecution(payload, mockRunInstance as any); const overallEndTime = performance.now(); const totalTime = overallEndTime - overallStartTime; @@ -1199,6 +1198,7 @@ suite('End to End Tests: test adapters', () => { // CLEANUP: Restore original functions // ================================================================ testItemUtilities.getTestCaseNodes = originalGetTestCaseNodes; + testItemIndexStub.restore(); // ================================================================ // ASSERT: Verify efficient performance characteristics @@ -1214,7 +1214,7 @@ suite('End to End Tests: test adapters', () => { console.log(`Results processed: ${numParameterizedResults}`); // Basic function call verification - assert.strictEqual(callCount, 1, 'Expected _resolveExecution to be called once'); + assert.strictEqual(callCount, 1, 'Expected resolveExecution to be called once'); // EFFICIENCY VERIFICATION: Ensure minimal expensive operations assert.strictEqual( diff --git a/src/test/testing/testController/common/buildErrorNodeOptions.unit.test.ts b/src/test/testing/testController/common/buildErrorNodeOptions.unit.test.ts index cf41136db697..643ea17903e6 100644 --- a/src/test/testing/testController/common/buildErrorNodeOptions.unit.test.ts +++ b/src/test/testing/testController/common/buildErrorNodeOptions.unit.test.ts @@ -5,33 +5,56 @@ import { expect } from 'chai'; import { Uri } from 'vscode'; import { buildErrorNodeOptions } from '../../../../client/testing/testController/common/utils'; -suite('buildErrorNodeOptions - pytest not installed detection', () => { +suite('buildErrorNodeOptions - missing module detection', () => { const workspaceUri = Uri.file('/test/workspace'); - test('Should detect pytest ModuleNotFoundError and provide specific message', () => { + test('Should detect pytest ModuleNotFoundError and show missing module label', () => { const errorMessage = 'Traceback (most recent call last):\n File "", line 1, in \n import pytest\nModuleNotFoundError: No module named \'pytest\''; const result = buildErrorNodeOptions(workspaceUri, errorMessage, 'pytest'); - expect(result.label).to.equal('pytest Not Installed [workspace]'); + expect(result.label).to.equal('Missing Module: pytest [workspace]'); expect(result.error).to.equal( - 'pytest is not installed in the selected Python environment. Please install pytest to enable test discovery and execution.', + "The module 'pytest' is not installed in the selected Python environment. Please install it to enable test discovery.", ); }); - test('Should detect pytest ImportError and provide specific message', () => { + test('Should detect pytest ImportError and show missing module label', () => { const errorMessage = 'ImportError: No module named pytest'; const result = buildErrorNodeOptions(workspaceUri, errorMessage, 'pytest'); - expect(result.label).to.equal('pytest Not Installed [workspace]'); + expect(result.label).to.equal('Missing Module: pytest [workspace]'); expect(result.error).to.equal( - 'pytest is not installed in the selected Python environment. Please install pytest to enable test discovery and execution.', + "The module 'pytest' is not installed in the selected Python environment. Please install it to enable test discovery.", ); }); - test('Should use generic error for non-pytest-related errors', () => { + test('Should detect other missing modules and show module name in label', () => { + const errorMessage = + "bob\\test_bob.py:3: in \n import requests\nE ModuleNotFoundError: No module named 'requests'\n=========================== short test summary info"; + + const result = buildErrorNodeOptions(workspaceUri, errorMessage, 'pytest'); + + expect(result.label).to.equal('Missing Module: requests [workspace]'); + expect(result.error).to.equal( + "The module 'requests' is not installed in the selected Python environment. Please install it to enable test discovery.", + ); + }); + + test('Should detect missing module with double quotes', () => { + const errorMessage = 'ModuleNotFoundError: No module named "numpy"'; + + const result = buildErrorNodeOptions(workspaceUri, errorMessage, 'pytest'); + + expect(result.label).to.equal('Missing Module: numpy [workspace]'); + expect(result.error).to.equal( + "The module 'numpy' is not installed in the selected Python environment. Please install it to enable test discovery.", + ); + }); + + test('Should use generic error for non-module-related errors', () => { const errorMessage = 'Some other error occurred'; const result = buildErrorNodeOptions(workspaceUri, errorMessage, 'pytest'); @@ -40,12 +63,49 @@ suite('buildErrorNodeOptions - pytest not installed detection', () => { expect(result.error).to.equal('Some other error occurred'); }); - test('Should use generic error for unittest errors', () => { - const errorMessage = "ModuleNotFoundError: No module named 'pytest'"; + test('Should detect missing module for unittest errors', () => { + const errorMessage = "ModuleNotFoundError: No module named 'pandas'"; const result = buildErrorNodeOptions(workspaceUri, errorMessage, 'unittest'); + expect(result.label).to.equal('Missing Module: pandas [workspace]'); + expect(result.error).to.equal( + "The module 'pandas' is not installed in the selected Python environment. Please install it to enable test discovery.", + ); + }); + + test('Should use generic error for unittest non-module errors', () => { + const errorMessage = 'Some other error occurred'; + + const result = buildErrorNodeOptions(workspaceUri, errorMessage, 'unittest'); + + expect(result.label).to.equal('Unittest Discovery Error [workspace]'); + expect(result.error).to.equal('Some other error occurred'); + }); + + test('Should use project name in label when projectName is provided', () => { + const errorMessage = 'Some error occurred'; + + const result = buildErrorNodeOptions(workspaceUri, errorMessage, 'unittest', 'my-project'); + + expect(result.label).to.equal('Unittest Discovery Error [my-project]'); + expect(result.error).to.equal('Some error occurred'); + }); + + test('Should use project name in label for pytest when projectName is provided', () => { + const errorMessage = 'Some error occurred'; + + const result = buildErrorNodeOptions(workspaceUri, errorMessage, 'pytest', 'ada'); + + expect(result.label).to.equal('pytest Discovery Error [ada]'); + expect(result.error).to.equal('Some error occurred'); + }); + + test('Should use folder name when projectName is undefined', () => { + const errorMessage = 'Some error occurred'; + + const result = buildErrorNodeOptions(workspaceUri, errorMessage, 'unittest', undefined); + expect(result.label).to.equal('Unittest Discovery Error [workspace]'); - expect(result.error).to.equal("ModuleNotFoundError: No module named 'pytest'"); }); }); diff --git a/src/test/testing/testController/common/projectTestExecution.unit.test.ts b/src/test/testing/testController/common/projectTestExecution.unit.test.ts new file mode 100644 index 000000000000..1cce2d1a8ce0 --- /dev/null +++ b/src/test/testing/testController/common/projectTestExecution.unit.test.ts @@ -0,0 +1,740 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import * as typemoq from 'typemoq'; +import { + CancellationToken, + CancellationTokenSource, + TestRun, + TestRunProfile, + TestRunProfileKind, + TestRunRequest, + Uri, +} from 'vscode'; +import { + createMockDependencies, + createMockProjectAdapter, + createMockTestItem, + createMockTestItemWithoutUri, + createMockTestRun, +} from '../testMocks'; +import { + executeTestsForProject, + executeTestsForProjects, + findProjectForTestItem, + getTestCaseNodesRecursive, + groupTestItemsByProject, + setupCoverageForProjects, +} from '../../../../client/testing/testController/common/projectTestExecution'; +import * as telemetry from '../../../../client/telemetry'; +import * as envExtApi from '../../../../client/envExt/api.internal'; + +suite('Project Test Execution', () => { + let sandbox: sinon.SinonSandbox; + let useEnvExtensionStub: sinon.SinonStub; + + setup(() => { + sandbox = sinon.createSandbox(); + // Default to disabled env extension for path-based fallback tests + useEnvExtensionStub = sandbox.stub(envExtApi, 'useEnvExtension').returns(false); + }); + + teardown(() => { + sandbox.restore(); + }); + + // ===== findProjectForTestItem Tests ===== + + suite('findProjectForTestItem', () => { + test('should return undefined when test item has no URI', async () => { + // Mock + const item = createMockTestItemWithoutUri('test1'); + const projects = [createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' })]; + + // Run + const result = await findProjectForTestItem(item, projects); + + // Assert + expect(result).to.be.undefined; + }); + + test('should return matching project when item path is within project directory', async () => { + // Mock + const item = createMockTestItem('test1', '/workspace/proj/tests/test_file.py'); + const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' }); + + // Run + const result = await findProjectForTestItem(item, [project]); + + // Assert + expect(result).to.equal(project); + }); + + test('should return undefined when item path is outside all project directories', async () => { + // Mock + const item = createMockTestItem('test1', '/other/path/test.py'); + const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' }); + + // Run + const result = await findProjectForTestItem(item, [project]); + + // Assert + expect(result).to.be.undefined; + }); + + test('should return most specific (deepest) project when nested projects exist', async () => { + // Mock - parent and child project with overlapping paths + const item = createMockTestItem('test1', '/workspace/parent/child/tests/test.py'); + const parentProject = createMockProjectAdapter({ projectPath: '/workspace/parent', projectName: 'parent' }); + const childProject = createMockProjectAdapter({ + projectPath: '/workspace/parent/child', + projectName: 'child', + }); + + // Run + const result = await findProjectForTestItem(item, [parentProject, childProject]); + + // Assert - should match child (longer path) not parent + expect(result).to.equal(childProject); + }); + + test('should return most specific project regardless of input order', async () => { + // Mock - same as above but different order + const item = createMockTestItem('test1', '/workspace/parent/child/tests/test.py'); + const parentProject = createMockProjectAdapter({ projectPath: '/workspace/parent', projectName: 'parent' }); + const childProject = createMockProjectAdapter({ + projectPath: '/workspace/parent/child', + projectName: 'child', + }); + + // Run - pass child first, then parent + const result = await findProjectForTestItem(item, [childProject, parentProject]); + + // Assert - order shouldn't affect result + expect(result).to.equal(childProject); + }); + + test('should match item at project root level', async () => { + // Mock + const item = createMockTestItem('test1', '/workspace/proj/test.py'); + const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' }); + + // Run + const result = await findProjectForTestItem(item, [project]); + + // Assert + expect(result).to.equal(project); + }); + + test('should use env extension API when available', async () => { + // Enable env extension + useEnvExtensionStub.returns(true); + + // Mock the env extension API + const item = createMockTestItem('test1', '/workspace/proj/tests/test_file.py'); + const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' }); + + const mockEnvApi = { + getPythonProject: sandbox.stub().returns({ uri: project.projectUri }), + }; + sandbox.stub(envExtApi, 'getEnvExtApi').resolves(mockEnvApi as any); + + // Run + const result = await findProjectForTestItem(item, [project]); + + // Assert + expect(result).to.equal(project); + expect(mockEnvApi.getPythonProject.calledOnceWith(item.uri)).to.be.true; + }); + + test('should fall back to path matching when env extension API is unavailable', async () => { + // Env extension enabled but throws + useEnvExtensionStub.returns(true); + sandbox.stub(envExtApi, 'getEnvExtApi').rejects(new Error('API unavailable')); + + // Mock + const item = createMockTestItem('test1', '/workspace/proj/tests/test_file.py'); + const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' }); + + // Run + const result = await findProjectForTestItem(item, [project]); + + // Assert - should still work via fallback + expect(result).to.equal(project); + }); + }); + + // ===== groupTestItemsByProject Tests ===== + + suite('groupTestItemsByProject', () => { + test('should group single test item to its matching project', async () => { + // Mock + const item = createMockTestItem('test1', '/workspace/proj/test.py'); + const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' }); + + // Run + const result = await groupTestItemsByProject([item], [project]); + + // Assert + expect(result.size).to.equal(1); + const entry = Array.from(result.values())[0]; + expect(entry.project).to.equal(project); + expect(entry.items).to.deep.equal([item]); + }); + + test('should aggregate multiple items belonging to same project', async () => { + // Mock + const item1 = createMockTestItem('test1', '/workspace/proj/tests/test1.py'); + const item2 = createMockTestItem('test2', '/workspace/proj/tests/test2.py'); + const item3 = createMockTestItem('test3', '/workspace/proj/test3.py'); + const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' }); + + // Run + const result = await groupTestItemsByProject([item1, item2, item3], [project]); + + // Assert - use Set for order-agnostic comparison + expect(result.size).to.equal(1); + const entry = Array.from(result.values())[0]; + expect(entry.items).to.have.length(3); + expect(new Set(entry.items)).to.deep.equal(new Set([item1, item2, item3])); + }); + + test('should separate items into groups by their owning project', async () => { + // Mock + const item1 = createMockTestItem('test1', '/workspace/proj1/test.py'); + const item2 = createMockTestItem('test2', '/workspace/proj2/test.py'); + const item3 = createMockTestItem('test3', '/workspace/proj1/other_test.py'); + const proj1 = createMockProjectAdapter({ projectPath: '/workspace/proj1', projectName: 'proj1' }); + const proj2 = createMockProjectAdapter({ projectPath: '/workspace/proj2', projectName: 'proj2' }); + + // Run + const result = await groupTestItemsByProject([item1, item2, item3], [proj1, proj2]); + + // Assert - use Set for order-agnostic comparison + expect(result.size).to.equal(2); + const proj1Entry = result.get(proj1.projectUri.toString()); + const proj2Entry = result.get(proj2.projectUri.toString()); + expect(proj1Entry?.items).to.have.length(2); + expect(new Set(proj1Entry?.items)).to.deep.equal(new Set([item1, item3])); + expect(proj2Entry?.items).to.deep.equal([item2]); + }); + + test('should return empty map when no test items provided', async () => { + // Mock + const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' }); + + // Run + const result = await groupTestItemsByProject([], [project]); + + // Assert + expect(result.size).to.equal(0); + }); + + test('should exclude items that do not match any project path', async () => { + // Mock + const item = createMockTestItem('test1', '/other/path/test.py'); + const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' }); + + // Run + const result = await groupTestItemsByProject([item], [project]); + + // Assert + expect(result.size).to.equal(0); + }); + + test('should assign item to most specific (deepest) project for nested paths', async () => { + // Mock + const item = createMockTestItem('test1', '/workspace/parent/child/test.py'); + const parentProject = createMockProjectAdapter({ projectPath: '/workspace/parent', projectName: 'parent' }); + const childProject = createMockProjectAdapter({ + projectPath: '/workspace/parent/child', + projectName: 'child', + }); + + // Run + const result = await groupTestItemsByProject([item], [parentProject, childProject]); + + // Assert + expect(result.size).to.equal(1); + const entry = result.get(childProject.projectUri.toString()); + expect(entry?.project).to.equal(childProject); + expect(entry?.items).to.deep.equal([item]); + }); + + test('should omit projects that have no matching test items', async () => { + // Mock + const item = createMockTestItem('test1', '/workspace/proj1/test.py'); + const proj1 = createMockProjectAdapter({ projectPath: '/workspace/proj1', projectName: 'proj1' }); + const proj2 = createMockProjectAdapter({ projectPath: '/workspace/proj2', projectName: 'proj2' }); + + // Run + const result = await groupTestItemsByProject([item], [proj1, proj2]); + + // Assert + expect(result.size).to.equal(1); + expect(result.has(proj1.projectUri.toString())).to.be.true; + expect(result.has(proj2.projectUri.toString())).to.be.false; + }); + }); + + // ===== getTestCaseNodesRecursive Tests ===== + + suite('getTestCaseNodesRecursive', () => { + test('should return single item when it is a leaf node with no children', () => { + // Mock + const item = createMockTestItem('test_func', '/test.py'); + + // Run + const result = getTestCaseNodesRecursive(item); + + // Assert + expect(result).to.deep.equal([item]); + }); + + test('should return all leaf nodes from single-level nested structure', () => { + // Mock + const leaf1 = createMockTestItem('test_method1', '/test.py'); + const leaf2 = createMockTestItem('test_method2', '/test.py'); + const classItem = createMockTestItem('TestClass', '/test.py', [leaf1, leaf2]); + + // Run + const result = getTestCaseNodesRecursive(classItem); + + // Assert - use Set for order-agnostic comparison + expect(result).to.have.length(2); + expect(new Set(result)).to.deep.equal(new Set([leaf1, leaf2])); + }); + + test('should traverse deeply nested structure to find all leaf nodes', () => { + // Mock - 3 levels deep: file β†’ class β†’ inner class β†’ test + const leaf1 = createMockTestItem('test1', '/test.py'); + const leaf2 = createMockTestItem('test2', '/test.py'); + const innerClass = createMockTestItem('InnerClass', '/test.py', [leaf2]); + const outerClass = createMockTestItem('OuterClass', '/test.py', [leaf1, innerClass]); + const fileItem = createMockTestItem('test_file.py', '/test.py', [outerClass]); + + // Run + const result = getTestCaseNodesRecursive(fileItem); + + // Assert - use Set for order-agnostic comparison + expect(result).to.have.length(2); + expect(new Set(result)).to.deep.equal(new Set([leaf1, leaf2])); + }); + + test('should collect leaves from multiple sibling branches', () => { + // Mock - multiple test classes at same level + const leaf1 = createMockTestItem('test1', '/test.py'); + const leaf2 = createMockTestItem('test2', '/test.py'); + const leaf3 = createMockTestItem('test3', '/test.py'); + const class1 = createMockTestItem('Class1', '/test.py', [leaf1]); + const class2 = createMockTestItem('Class2', '/test.py', [leaf2, leaf3]); + const fileItem = createMockTestItem('test_file.py', '/test.py', [class1, class2]); + + // Run + const result = getTestCaseNodesRecursive(fileItem); + + // Assert - use Set for order-agnostic comparison + expect(result).to.have.length(3); + expect(new Set(result)).to.deep.equal(new Set([leaf1, leaf2, leaf3])); + }); + }); + + // ===== executeTestsForProject Tests ===== + + suite('executeTestsForProject', () => { + test('should call executionAdapter.runTests with project URI and mapped test IDs', async () => { + // Mock + const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' }); + project.resultResolver.vsIdToRunId.set('test1', 'test_file.py::test1'); + const testItem = createMockTestItem('test1', '/workspace/proj/test.py'); + const runMock = createMockTestRun(); + const request = { profile: { kind: TestRunProfileKind.Run } } as TestRunRequest; + const deps = createMockDependencies(); + + // Run + await executeTestsForProject(project, [testItem], runMock.object, request, deps); + + // Assert + expect(project.executionAdapterStub.calledOnce).to.be.true; + const callArgs = project.executionAdapterStub.firstCall.args; + expect(callArgs[0].fsPath).to.equal(project.projectUri.fsPath); // uri + expect(callArgs[1]).to.deep.equal(['test_file.py::test1']); // testCaseIds + expect(callArgs[7]).to.equal(project); // project + }); + + test('should mark all leaf test items as started in the test run', async () => { + // Mock + const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' }); + project.resultResolver.vsIdToRunId.set('test1', 'runId1'); + project.resultResolver.vsIdToRunId.set('test2', 'runId2'); + const item1 = createMockTestItem('test1', '/workspace/proj/test.py'); + const item2 = createMockTestItem('test2', '/workspace/proj/test.py'); + const runMock = createMockTestRun(); + const request = { profile: { kind: TestRunProfileKind.Run } } as TestRunRequest; + const deps = createMockDependencies(); + + // Run + await executeTestsForProject(project, [item1, item2], runMock.object, request, deps); + + // Assert - both items marked as started + runMock.verify((r) => r.started(item1), typemoq.Times.once()); + runMock.verify((r) => r.started(item2), typemoq.Times.once()); + }); + + test('should resolve test IDs via resultResolver.vsIdToRunId mapping', async () => { + // Mock + const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' }); + project.resultResolver.vsIdToRunId.set('test1', 'path/to/test1'); + project.resultResolver.vsIdToRunId.set('test2', 'path/to/test2'); + const item1 = createMockTestItem('test1', '/workspace/proj/test.py'); + const item2 = createMockTestItem('test2', '/workspace/proj/test.py'); + const runMock = createMockTestRun(); + const request = { profile: { kind: TestRunProfileKind.Run } } as TestRunRequest; + const deps = createMockDependencies(); + + // Run + await executeTestsForProject(project, [item1, item2], runMock.object, request, deps); + + // Assert - use Set for order-agnostic comparison + const passedTestIds = project.executionAdapterStub.firstCall.args[1] as string[]; + expect(new Set(passedTestIds)).to.deep.equal(new Set(['path/to/test1', 'path/to/test2'])); + }); + + test('should skip execution when no items have vsIdToRunId mappings', async () => { + // Mock - no mappings set, so lookups return undefined + const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' }); + const item = createMockTestItem('unmapped_test', '/workspace/proj/test.py'); + const runMock = createMockTestRun(); + const request = { profile: { kind: TestRunProfileKind.Run } } as TestRunRequest; + const deps = createMockDependencies(); + + // Run + await executeTestsForProject(project, [item], runMock.object, request, deps); + + // Assert - execution adapter never called + expect(project.executionAdapterStub.called).to.be.false; + }); + + test('should recursively expand nested test items to find leaf nodes', async () => { + // Mock - class containing two test methods + const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' }); + const leaf1 = createMockTestItem('test1', '/workspace/proj/test.py'); + const leaf2 = createMockTestItem('test2', '/workspace/proj/test.py'); + const classItem = createMockTestItem('TestClass', '/workspace/proj/test.py', [leaf1, leaf2]); + project.resultResolver.vsIdToRunId.set('test1', 'runId1'); + project.resultResolver.vsIdToRunId.set('test2', 'runId2'); + const runMock = createMockTestRun(); + const request = { profile: { kind: TestRunProfileKind.Run } } as TestRunRequest; + const deps = createMockDependencies(); + + // Run + await executeTestsForProject(project, [classItem], runMock.object, request, deps); + + // Assert - leaf nodes marked as started, not the parent class + runMock.verify((r) => r.started(leaf1), typemoq.Times.once()); + runMock.verify((r) => r.started(leaf2), typemoq.Times.once()); + const passedTestIds = project.executionAdapterStub.firstCall.args[1] as string[]; + expect(passedTestIds).to.have.length(2); + }); + }); + + // ===== executeTestsForProjects Tests ===== + + suite('executeTestsForProjects', () => { + let telemetryStub: sinon.SinonStub; + + setup(() => { + telemetryStub = sandbox.stub(telemetry, 'sendTelemetryEvent'); + }); + + test('should return immediately when empty projects array provided', async () => { + // Mock + const runMock = createMockTestRun(); + const token = new CancellationTokenSource().token; + const request = { profile: { kind: TestRunProfileKind.Run } } as TestRunRequest; + const deps = createMockDependencies(); + + // Run + await executeTestsForProjects([], [], runMock.object, request, token, deps); + + // Assert - no telemetry sent since no projects executed + expect(telemetryStub.called).to.be.false; + }); + + test('should skip execution when cancellation requested before start', async () => { + // Mock + const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' }); + const item = createMockTestItem('test1', '/workspace/proj/test.py'); + const runMock = createMockTestRun(); + const tokenSource = new CancellationTokenSource(); + tokenSource.cancel(); // Pre-cancel + const request = { profile: { kind: TestRunProfileKind.Run } } as TestRunRequest; + const deps = createMockDependencies(); + + // Run + await executeTestsForProjects([project], [item], runMock.object, request, tokenSource.token, deps); + + // Assert - execution adapter never called + expect(project.executionAdapterStub.called).to.be.false; + }); + + test('should execute tests for each project when multiple projects provided', async () => { + // Mock + const proj1 = createMockProjectAdapter({ projectPath: '/workspace/proj1', projectName: 'proj1' }); + const proj2 = createMockProjectAdapter({ projectPath: '/workspace/proj2', projectName: 'proj2' }); + proj1.resultResolver.vsIdToRunId.set('test1', 'runId1'); + proj2.resultResolver.vsIdToRunId.set('test2', 'runId2'); + const item1 = createMockTestItem('test1', '/workspace/proj1/test.py'); + const item2 = createMockTestItem('test2', '/workspace/proj2/test.py'); + const runMock = createMockTestRun(); + const token = new CancellationTokenSource().token; + const request = { profile: { kind: TestRunProfileKind.Run } } as TestRunRequest; + const deps = createMockDependencies(); + + // Run + await executeTestsForProjects([proj1, proj2], [item1, item2], runMock.object, request, token, deps); + + // Assert - both projects had their execution adapters called + expect(proj1.executionAdapterStub.calledOnce).to.be.true; + expect(proj2.executionAdapterStub.calledOnce).to.be.true; + }); + + test('should emit telemetry event for each project execution', async () => { + // Mock + const proj1 = createMockProjectAdapter({ projectPath: '/workspace/proj1', projectName: 'proj1' }); + const proj2 = createMockProjectAdapter({ projectPath: '/workspace/proj2', projectName: 'proj2' }); + proj1.resultResolver.vsIdToRunId.set('test1', 'runId1'); + proj2.resultResolver.vsIdToRunId.set('test2', 'runId2'); + const item1 = createMockTestItem('test1', '/workspace/proj1/test.py'); + const item2 = createMockTestItem('test2', '/workspace/proj2/test.py'); + const runMock = createMockTestRun(); + const token = new CancellationTokenSource().token; + const request = { profile: { kind: TestRunProfileKind.Run } } as TestRunRequest; + const deps = createMockDependencies(); + + // Run + await executeTestsForProjects([proj1, proj2], [item1, item2], runMock.object, request, token, deps); + + // Assert - telemetry sent twice (once per project) + expect(telemetryStub.callCount).to.equal(2); + }); + + test('should stop processing remaining projects when cancellation requested mid-execution', async () => { + // Mock + const tokenSource = new CancellationTokenSource(); + const proj1 = createMockProjectAdapter({ projectPath: '/workspace/proj1', projectName: 'proj1' }); + const proj2 = createMockProjectAdapter({ projectPath: '/workspace/proj2', projectName: 'proj2' }); + // First project triggers cancellation during its execution + proj1.executionAdapterStub.callsFake(async () => { + tokenSource.cancel(); + }); + proj1.resultResolver.vsIdToRunId.set('test1', 'runId1'); + proj2.resultResolver.vsIdToRunId.set('test2', 'runId2'); + const item1 = createMockTestItem('test1', '/workspace/proj1/test.py'); + const item2 = createMockTestItem('test2', '/workspace/proj2/test.py'); + const runMock = createMockTestRun(); + const request = { profile: { kind: TestRunProfileKind.Run } } as TestRunRequest; + const deps = createMockDependencies(); + + // Run + await executeTestsForProjects( + [proj1, proj2], + [item1, item2], + runMock.object, + request, + tokenSource.token, + deps, + ); + + // Assert - first project executed, second may be skipped due to cancellation check + expect(proj1.executionAdapterStub.calledOnce).to.be.true; + }); + + test('should continue executing remaining projects when one project fails', async () => { + // Mock + const proj1 = createMockProjectAdapter({ projectPath: '/workspace/proj1', projectName: 'proj1' }); + const proj2 = createMockProjectAdapter({ projectPath: '/workspace/proj2', projectName: 'proj2' }); + proj1.executionAdapterStub.rejects(new Error('Execution failed')); + proj1.resultResolver.vsIdToRunId.set('test1', 'runId1'); + proj2.resultResolver.vsIdToRunId.set('test2', 'runId2'); + const item1 = createMockTestItem('test1', '/workspace/proj1/test.py'); + const item2 = createMockTestItem('test2', '/workspace/proj2/test.py'); + const runMock = createMockTestRun(); + const token = new CancellationTokenSource().token; + const request = { profile: { kind: TestRunProfileKind.Run } } as TestRunRequest; + const deps = createMockDependencies(); + + // Run - should not throw + await executeTestsForProjects([proj1, proj2], [item1, item2], runMock.object, request, token, deps); + + // Assert - second project still executed despite first failing + expect(proj2.executionAdapterStub.calledOnce).to.be.true; + }); + + test('should configure loadDetailedCoverage callback when run profile is Coverage', async () => { + // Mock + const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' }); + project.resultResolver.vsIdToRunId.set('test1', 'runId1'); + const item = createMockTestItem('test1', '/workspace/proj/test.py'); + const runMock = createMockTestRun(); + const token = new CancellationTokenSource().token; + const profileMock = ({ + kind: TestRunProfileKind.Coverage, + loadDetailedCoverage: undefined, + } as unknown) as TestRunProfile; + const request = { profile: profileMock } as TestRunRequest; + const deps = createMockDependencies(); + + // Run + await executeTestsForProjects([project], [item], runMock.object, request, token, deps); + + // Assert - loadDetailedCoverage callback was configured + expect(profileMock.loadDetailedCoverage).to.not.be.undefined; + }); + + test('should include debugging=true in telemetry when run profile is Debug', async () => { + // Mock + const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' }); + project.resultResolver.vsIdToRunId.set('test1', 'runId1'); + const item = createMockTestItem('test1', '/workspace/proj/test.py'); + const runMock = createMockTestRun(); + const token = new CancellationTokenSource().token; + const request = { profile: { kind: TestRunProfileKind.Debug } } as TestRunRequest; + const deps = createMockDependencies(); + + // Run + await executeTestsForProjects([project], [item], runMock.object, request, token, deps); + + // Assert - telemetry contains debugging=true + expect(telemetryStub.calledOnce).to.be.true; + const telemetryProps = telemetryStub.firstCall.args[2]; + expect(telemetryProps.debugging).to.be.true; + }); + }); + + // ===== setupCoverageForProjects Tests ===== + + suite('setupCoverageForProjects', () => { + test('should configure loadDetailedCoverage callback when profile kind is Coverage', () => { + // Mock + const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' }); + const profileMock = ({ + kind: TestRunProfileKind.Coverage, + loadDetailedCoverage: undefined, + } as unknown) as TestRunProfile; + const request = { profile: profileMock } as TestRunRequest; + + // Run + setupCoverageForProjects(request, [project]); + + // Assert + expect(profileMock.loadDetailedCoverage).to.be.a('function'); + }); + + test('should leave loadDetailedCoverage undefined when profile kind is Run', () => { + // Mock + const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' }); + const profileMock = ({ + kind: TestRunProfileKind.Run, + loadDetailedCoverage: undefined, + } as unknown) as TestRunProfile; + const request = { profile: profileMock } as TestRunRequest; + + // Run + setupCoverageForProjects(request, [project]); + + // Assert + expect(profileMock.loadDetailedCoverage).to.be.undefined; + }); + + test('should return coverage data from detailedCoverageMap when loadDetailedCoverage is called', async () => { + // Mock + const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' }); + const mockCoverageDetails = [{ line: 1, executed: true }]; + // Use Uri.fsPath as the key to match the implementation's lookup + const fileUri = Uri.file('/workspace/proj/file.py'); + project.resultResolver.detailedCoverageMap.set(fileUri.fsPath, mockCoverageDetails as any); + const profileMock = ({ + kind: TestRunProfileKind.Coverage, + loadDetailedCoverage: undefined, + } as unknown) as TestRunProfile; + const request = { profile: profileMock } as TestRunRequest; + + // Run - configure coverage + setupCoverageForProjects(request, [project]); + + // Run - call the configured callback + const fileCoverage = { uri: fileUri }; + const result = await profileMock.loadDetailedCoverage!( + {} as TestRun, + fileCoverage as any, + {} as CancellationToken, + ); + + // Assert + expect(result).to.deep.equal(mockCoverageDetails); + }); + + test('should return empty array when file has no coverage data in map', async () => { + // Mock + const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' }); + const profileMock = ({ + kind: TestRunProfileKind.Coverage, + loadDetailedCoverage: undefined, + } as unknown) as TestRunProfile; + const request = { profile: profileMock } as TestRunRequest; + + // Run - configure coverage + setupCoverageForProjects(request, [project]); + + // Run - call callback for file not in map + const fileCoverage = { uri: Uri.file('/workspace/proj/uncovered_file.py') }; + const result = await profileMock.loadDetailedCoverage!( + {} as TestRun, + fileCoverage as any, + {} as CancellationToken, + ); + + // Assert + expect(result).to.deep.equal([]); + }); + + test('should route to correct project when multiple projects have coverage data', async () => { + // Mock - two projects with different coverage data + const project1 = createMockProjectAdapter({ projectPath: '/workspace/proj1', projectName: 'proj1' }); + const project2 = createMockProjectAdapter({ projectPath: '/workspace/proj2', projectName: 'proj2' }); + const coverage1 = [{ line: 1, executed: true }]; + const coverage2 = [{ line: 2, executed: false }]; + const file1Uri = Uri.file('/workspace/proj1/file1.py'); + const file2Uri = Uri.file('/workspace/proj2/file2.py'); + project1.resultResolver.detailedCoverageMap.set(file1Uri.fsPath, coverage1 as any); + project2.resultResolver.detailedCoverageMap.set(file2Uri.fsPath, coverage2 as any); + + const profileMock = ({ + kind: TestRunProfileKind.Coverage, + loadDetailedCoverage: undefined, + } as unknown) as TestRunProfile; + const request = { profile: profileMock } as TestRunRequest; + + // Run - configure coverage with both projects + setupCoverageForProjects(request, [project1, project2]); + + // Assert - can get coverage from both projects through single callback + const result1 = await profileMock.loadDetailedCoverage!( + {} as TestRun, + { uri: file1Uri } as any, + {} as CancellationToken, + ); + const result2 = await profileMock.loadDetailedCoverage!( + {} as TestRun, + { uri: file2Uri } as any, + {} as CancellationToken, + ); + + expect(result1).to.deep.equal(coverage1); + expect(result2).to.deep.equal(coverage2); + }); + }); +}); diff --git a/src/test/testing/testController/common/projectUtils.unit.test.ts b/src/test/testing/testController/common/projectUtils.unit.test.ts new file mode 100644 index 000000000000..75f399e89fc0 --- /dev/null +++ b/src/test/testing/testController/common/projectUtils.unit.test.ts @@ -0,0 +1,241 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { expect } from 'chai'; +import { Uri } from 'vscode'; +import { + getProjectId, + createProjectDisplayName, + parseVsId, + PROJECT_ID_SEPARATOR, +} from '../../../../client/testing/testController/common/projectUtils'; + +suite('Project Utils Tests', () => { + suite('getProjectId', () => { + test('should return URI string representation', () => { + const uri = Uri.file('/workspace/project'); + + const id = getProjectId(uri); + + expect(id).to.equal(uri.toString()); + }); + + test('should be consistent for same URI', () => { + const uri = Uri.file('/workspace/project'); + + const id1 = getProjectId(uri); + const id2 = getProjectId(uri); + + expect(id1).to.equal(id2); + }); + + test('should be different for different URIs', () => { + const uri1 = Uri.file('/workspace/project1'); + const uri2 = Uri.file('/workspace/project2'); + + const id1 = getProjectId(uri1); + const id2 = getProjectId(uri2); + + expect(id1).to.not.equal(id2); + }); + + test('should handle Windows paths', () => { + const uri = Uri.file('C:\\workspace\\project'); + + const id = getProjectId(uri); + + expect(id).to.be.a('string'); + expect(id).to.have.length.greaterThan(0); + }); + + test('should handle nested project paths', () => { + const parentUri = Uri.file('/workspace/parent'); + const childUri = Uri.file('/workspace/parent/child'); + + const parentId = getProjectId(parentUri); + const childId = getProjectId(childUri); + + expect(parentId).to.not.equal(childId); + }); + + test('should match Python Environments extension format', () => { + const uri = Uri.file('/workspace/project'); + + const id = getProjectId(uri); + + // Should match how Python Environments extension keys projects + expect(id).to.equal(uri.toString()); + expect(typeof id).to.equal('string'); + }); + }); + + suite('createProjectDisplayName', () => { + test('should format name with major.minor version', () => { + const result = createProjectDisplayName('MyProject', '3.11.2'); + + expect(result).to.equal('MyProject (Python 3.11)'); + }); + + test('should handle version with patch and pre-release', () => { + const result = createProjectDisplayName('MyProject', '3.12.0rc1'); + + expect(result).to.equal('MyProject (Python 3.12)'); + }); + + test('should handle version with only major.minor', () => { + const result = createProjectDisplayName('MyProject', '3.10'); + + expect(result).to.equal('MyProject (Python 3.10)'); + }); + + test('should handle invalid version format gracefully', () => { + const result = createProjectDisplayName('MyProject', 'invalid-version'); + + expect(result).to.equal('MyProject (Python invalid-version)'); + }); + + test('should handle empty version string', () => { + const result = createProjectDisplayName('MyProject', ''); + + expect(result).to.equal('MyProject (Python )'); + }); + + test('should handle version with single digit', () => { + const result = createProjectDisplayName('MyProject', '3'); + + expect(result).to.equal('MyProject (Python 3)'); + }); + + test('should handle project name with special characters', () => { + const result = createProjectDisplayName('My-Project_123', '3.11.5'); + + expect(result).to.equal('My-Project_123 (Python 3.11)'); + }); + + test('should handle empty project name', () => { + const result = createProjectDisplayName('', '3.11.2'); + + expect(result).to.equal(' (Python 3.11)'); + }); + }); + + suite('parseVsId', () => { + test('should parse project-scoped ID correctly', () => { + const projectUri = Uri.file('/workspace/project'); + const projectId = getProjectId(projectUri); + const vsId = `${projectId}${PROJECT_ID_SEPARATOR}test_file.py::test_name`; + + const [parsedProjectId, runId] = parseVsId(vsId); + + expect(parsedProjectId).to.equal(projectId); + expect(runId).to.equal('test_file.py::test_name'); + }); + + test('should handle legacy ID without project scope', () => { + const vsId = 'test_file.py'; + + const [projectId, runId] = parseVsId(vsId); + + expect(projectId).to.be.undefined; + expect(runId).to.equal('test_file.py'); + }); + + test('should handle runId containing separator', () => { + const projectUri = Uri.file('/workspace/project'); + const projectId = getProjectId(projectUri); + const vsId = `${projectId}${PROJECT_ID_SEPARATOR}test_file.py::test_class::test_method`; + + const [parsedProjectId, runId] = parseVsId(vsId); + + expect(parsedProjectId).to.equal(projectId); + expect(runId).to.equal('test_file.py::test_class::test_method'); + }); + + test('should handle empty project ID', () => { + const vsId = `${PROJECT_ID_SEPARATOR}test_file.py::test_name`; + + const [projectId, runId] = parseVsId(vsId); + + expect(projectId).to.equal(''); + expect(runId).to.equal('test_file.py::test_name'); + }); + + test('should handle empty runId', () => { + const vsId = `project-abc123def456${PROJECT_ID_SEPARATOR}`; + + const [projectId, runId] = parseVsId(vsId); + + expect(projectId).to.equal('project-abc123def456'); + expect(runId).to.equal(''); + }); + + test('should handle ID with file path', () => { + const vsId = `project-abc123def456${PROJECT_ID_SEPARATOR}/workspace/tests/test_file.py`; + + const [projectId, runId] = parseVsId(vsId); + + expect(projectId).to.equal('project-abc123def456'); + expect(runId).to.equal('/workspace/tests/test_file.py'); + }); + + test('should handle Windows file paths', () => { + const projectUri = Uri.file('/workspace/project'); + const projectId = getProjectId(projectUri); + const vsId = `${projectId}${PROJECT_ID_SEPARATOR}C:\\workspace\\tests\\test_file.py`; + + const [parsedProjectId, runId] = parseVsId(vsId); + + expect(parsedProjectId).to.equal(projectId); + expect(runId).to.equal('C:\\workspace\\tests\\test_file.py'); + }); + }); + + suite('Integration Tests', () => { + test('should generate unique IDs for different URIs', () => { + const uris = [ + Uri.file('/workspace/a'), + Uri.file('/workspace/b'), + Uri.file('/workspace/c'), + Uri.file('/workspace/d'), + Uri.file('/workspace/e'), + ]; + + const ids = uris.map((uri) => getProjectId(uri)); + const uniqueIds = new Set(ids); + + expect(uniqueIds.size).to.equal(uris.length, 'All IDs should be unique'); + }); + + test('should handle nested project paths', () => { + const parentUri = Uri.file('/workspace/parent'); + const childUri = Uri.file('/workspace/parent/child'); + + const parentId = getProjectId(parentUri); + const childId = getProjectId(childUri); + + expect(parentId).to.not.equal(childId); + }); + + test('should create complete vsId and parse it back', () => { + const projectUri = Uri.file('/workspace/myproject'); + const projectId = getProjectId(projectUri); + const runId = 'tests/test_module.py::TestClass::test_method'; + const vsId = `${projectId}${PROJECT_ID_SEPARATOR}${runId}`; + + const [parsedProjectId, parsedRunId] = parseVsId(vsId); + + expect(parsedProjectId).to.equal(projectId); + expect(parsedRunId).to.equal(runId); + }); + + test('should match Python Environments extension URI format', () => { + const uri = Uri.file('/workspace/project'); + + const projectId = getProjectId(uri); + + // Should be string representation of URI + expect(projectId).to.equal(uri.toString()); + expect(typeof projectId).to.equal('string'); + }); + }); +}); diff --git a/src/test/testing/testController/common/testCoverageHandler.unit.test.ts b/src/test/testing/testController/common/testCoverageHandler.unit.test.ts new file mode 100644 index 000000000000..a81aed591128 --- /dev/null +++ b/src/test/testing/testController/common/testCoverageHandler.unit.test.ts @@ -0,0 +1,502 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { TestRun, Uri, FileCoverage } from 'vscode'; +import * as typemoq from 'typemoq'; +import * as assert from 'assert'; +import { TestCoverageHandler } from '../../../../client/testing/testController/common/testCoverageHandler'; +import { CoveragePayload } from '../../../../client/testing/testController/common/types'; + +suite('TestCoverageHandler', () => { + let coverageHandler: TestCoverageHandler; + let runInstanceMock: typemoq.IMock; + + setup(() => { + coverageHandler = new TestCoverageHandler(); + runInstanceMock = typemoq.Mock.ofType(); + }); + + suite('processCoverage', () => { + test('should return empty map for undefined result', () => { + const payload: CoveragePayload = { + coverage: true, + cwd: '/foo/bar', + result: undefined, + error: '', + }; + + const result = coverageHandler.processCoverage(payload, runInstanceMock.object); + + assert.strictEqual(result.size, 0); + runInstanceMock.verify((r) => r.addCoverage(typemoq.It.isAny()), typemoq.Times.never()); + }); + + test('should create FileCoverage for each file', () => { + const payload: CoveragePayload = { + coverage: true, + cwd: '/foo/bar', + result: { + '/path/to/file1.py': { + lines_covered: [1, 2, 3], + lines_missed: [4, 5], + executed_branches: 5, + total_branches: 10, + }, + '/path/to/file2.py': { + lines_covered: [1, 2], + lines_missed: [3], + executed_branches: 2, + total_branches: 4, + }, + }, + error: '', + }; + + coverageHandler.processCoverage(payload, runInstanceMock.object); + + runInstanceMock.verify((r) => r.addCoverage(typemoq.It.isAny()), typemoq.Times.exactly(2)); + }); + + test('should call runInstance.addCoverage with correct FileCoverage', () => { + const payload: CoveragePayload = { + coverage: true, + cwd: '/foo/bar', + result: { + '/path/to/file.py': { + lines_covered: [1, 2, 3], + lines_missed: [4, 5], + executed_branches: 5, + total_branches: 10, + }, + }, + error: '', + }; + + let capturedCoverage: FileCoverage | undefined; + runInstanceMock + .setup((r) => r.addCoverage(typemoq.It.isAny())) + .callback((coverage: FileCoverage) => { + capturedCoverage = coverage; + }); + + coverageHandler.processCoverage(payload, runInstanceMock.object); + + assert.ok(capturedCoverage); + assert.strictEqual(capturedCoverage!.uri.fsPath, Uri.file('/path/to/file.py').fsPath); + }); + + test('should return detailed coverage map with correct keys', () => { + const payload: CoveragePayload = { + coverage: true, + cwd: '/foo/bar', + result: { + '/path/to/file1.py': { + lines_covered: [1, 2], + lines_missed: [3], + executed_branches: 2, + total_branches: 4, + }, + '/path/to/file2.py': { + lines_covered: [5, 6, 7], + lines_missed: [], + executed_branches: 3, + total_branches: 3, + }, + }, + error: '', + }; + + const result = coverageHandler.processCoverage(payload, runInstanceMock.object); + + assert.strictEqual(result.size, 2); + assert.ok(result.has(Uri.file('/path/to/file1.py').fsPath)); + assert.ok(result.has(Uri.file('/path/to/file2.py').fsPath)); + }); + + test('should handle empty coverage data', () => { + const payload: CoveragePayload = { + coverage: true, + cwd: '/foo/bar', + result: {}, + error: '', + }; + + const result = coverageHandler.processCoverage(payload, runInstanceMock.object); + + assert.strictEqual(result.size, 0); + }); + + test('should handle file with no covered lines', () => { + const payload: CoveragePayload = { + coverage: true, + cwd: '/foo/bar', + result: { + '/path/to/file.py': { + lines_covered: [], + lines_missed: [1, 2, 3], + executed_branches: 0, + total_branches: 5, + }, + }, + error: '', + }; + + const result = coverageHandler.processCoverage(payload, runInstanceMock.object); + + const detailedCoverage = result.get(Uri.file('/path/to/file.py').fsPath); + assert.ok(detailedCoverage); + assert.strictEqual(detailedCoverage!.length, 3); // Only missed lines + }); + + test('should handle file with no missed lines', () => { + const payload: CoveragePayload = { + coverage: true, + cwd: '/foo/bar', + result: { + '/path/to/file.py': { + lines_covered: [1, 2, 3], + lines_missed: [], + executed_branches: 5, + total_branches: 5, + }, + }, + error: '', + }; + + const result = coverageHandler.processCoverage(payload, runInstanceMock.object); + + const detailedCoverage = result.get(Uri.file('/path/to/file.py').fsPath); + assert.ok(detailedCoverage); + assert.strictEqual(detailedCoverage!.length, 3); // Only covered lines + }); + + test('should handle undefined lines_covered', () => { + const payload: CoveragePayload = { + coverage: true, + cwd: '/foo/bar', + result: { + '/path/to/file.py': { + lines_covered: undefined as any, + lines_missed: [1, 2], + executed_branches: 0, + total_branches: 2, + }, + }, + error: '', + }; + + const result = coverageHandler.processCoverage(payload, runInstanceMock.object); + + const detailedCoverage = result.get(Uri.file('/path/to/file.py').fsPath); + assert.ok(detailedCoverage); + assert.strictEqual(detailedCoverage!.length, 2); // Only missed lines + }); + + test('should handle undefined lines_missed', () => { + const payload: CoveragePayload = { + coverage: true, + cwd: '/foo/bar', + result: { + '/path/to/file.py': { + lines_covered: [1, 2], + lines_missed: undefined as any, + executed_branches: 2, + total_branches: 2, + }, + }, + error: '', + }; + + const result = coverageHandler.processCoverage(payload, runInstanceMock.object); + + const detailedCoverage = result.get(Uri.file('/path/to/file.py').fsPath); + assert.ok(detailedCoverage); + assert.strictEqual(detailedCoverage!.length, 2); // Only covered lines + }); + }); + + suite('createFileCoverage', () => { + test('should handle line coverage only when totalBranches is -1', () => { + const payload: CoveragePayload = { + coverage: true, + cwd: '/foo/bar', + result: { + '/path/to/file.py': { + lines_covered: [1, 2, 3], + lines_missed: [4, 5], + executed_branches: 0, + total_branches: -1, // Branch coverage disabled + }, + }, + error: '', + }; + + let capturedCoverage: FileCoverage | undefined; + runInstanceMock + .setup((r) => r.addCoverage(typemoq.It.isAny())) + .callback((coverage: FileCoverage) => { + capturedCoverage = coverage; + }); + + coverageHandler.processCoverage(payload, runInstanceMock.object); + + assert.ok(capturedCoverage); + // Branch coverage should not be included + assert.strictEqual((capturedCoverage as any).branchCoverage, undefined); + }); + + test('should include branch coverage when available', () => { + const payload: CoveragePayload = { + coverage: true, + cwd: '/foo/bar', + result: { + '/path/to/file.py': { + lines_covered: [1, 2, 3], + lines_missed: [4], + executed_branches: 7, + total_branches: 10, + }, + }, + error: '', + }; + + let capturedCoverage: FileCoverage | undefined; + runInstanceMock + .setup((r) => r.addCoverage(typemoq.It.isAny())) + .callback((coverage: FileCoverage) => { + capturedCoverage = coverage; + }); + + coverageHandler.processCoverage(payload, runInstanceMock.object); + + assert.ok(capturedCoverage); + // Should have branch coverage + assert.ok((capturedCoverage as any).branchCoverage); + }); + + test('should calculate line coverage counts correctly', () => { + const payload: CoveragePayload = { + coverage: true, + cwd: '/foo/bar', + result: { + '/path/to/file.py': { + lines_covered: [1, 2, 3, 4, 5], + lines_missed: [6, 7], + executed_branches: 0, + total_branches: -1, + }, + }, + error: '', + }; + + let capturedCoverage: FileCoverage | undefined; + runInstanceMock + .setup((r) => r.addCoverage(typemoq.It.isAny())) + .callback((coverage: FileCoverage) => { + capturedCoverage = coverage; + }); + + coverageHandler.processCoverage(payload, runInstanceMock.object); + + assert.ok(capturedCoverage); + // 5 covered out of 7 total (5 covered + 2 missed) + assert.strictEqual((capturedCoverage as any).statementCoverage.covered, 5); + assert.strictEqual((capturedCoverage as any).statementCoverage.total, 7); + }); + }); + + suite('createDetailedCoverage', () => { + test('should create StatementCoverage for covered lines', () => { + const payload: CoveragePayload = { + coverage: true, + cwd: '/foo/bar', + result: { + '/path/to/file.py': { + lines_covered: [1, 2, 3], + lines_missed: [], + executed_branches: 0, + total_branches: -1, + }, + }, + error: '', + }; + + const result = coverageHandler.processCoverage(payload, runInstanceMock.object); + + const detailedCoverage = result.get(Uri.file('/path/to/file.py').fsPath); + assert.ok(detailedCoverage); + assert.strictEqual(detailedCoverage!.length, 3); + + // All should be covered (true) + detailedCoverage!.forEach((coverage) => { + assert.strictEqual((coverage as any).executed, true); + }); + }); + + test('should create StatementCoverage for missed lines', () => { + const payload: CoveragePayload = { + coverage: true, + cwd: '/foo/bar', + result: { + '/path/to/file.py': { + lines_covered: [], + lines_missed: [1, 2, 3], + executed_branches: 0, + total_branches: -1, + }, + }, + error: '', + }; + + const result = coverageHandler.processCoverage(payload, runInstanceMock.object); + + const detailedCoverage = result.get(Uri.file('/path/to/file.py').fsPath); + assert.ok(detailedCoverage); + assert.strictEqual(detailedCoverage!.length, 3); + + // All should be NOT covered (false) + detailedCoverage!.forEach((coverage) => { + assert.strictEqual((coverage as any).executed, false); + }); + }); + + test('should convert 1-indexed to 0-indexed line numbers for covered lines', () => { + const payload: CoveragePayload = { + coverage: true, + cwd: '/foo/bar', + result: { + '/path/to/file.py': { + lines_covered: [1, 5, 10], + lines_missed: [], + executed_branches: 0, + total_branches: -1, + }, + }, + error: '', + }; + + const result = coverageHandler.processCoverage(payload, runInstanceMock.object); + + const detailedCoverage = result.get(Uri.file('/path/to/file.py').fsPath); + assert.ok(detailedCoverage); + + // Line 1 should map to range starting at line 0 + assert.strictEqual((detailedCoverage![0] as any).location.start.line, 0); + // Line 5 should map to range starting at line 4 + assert.strictEqual((detailedCoverage![1] as any).location.start.line, 4); + // Line 10 should map to range starting at line 9 + assert.strictEqual((detailedCoverage![2] as any).location.start.line, 9); + }); + + test('should convert 1-indexed to 0-indexed line numbers for missed lines', () => { + const payload: CoveragePayload = { + coverage: true, + cwd: '/foo/bar', + result: { + '/path/to/file.py': { + lines_covered: [], + lines_missed: [3, 7, 12], + executed_branches: 0, + total_branches: -1, + }, + }, + error: '', + }; + + const result = coverageHandler.processCoverage(payload, runInstanceMock.object); + + const detailedCoverage = result.get(Uri.file('/path/to/file.py').fsPath); + assert.ok(detailedCoverage); + + // Line 3 should map to range starting at line 2 + assert.strictEqual((detailedCoverage![0] as any).location.start.line, 2); + // Line 7 should map to range starting at line 6 + assert.strictEqual((detailedCoverage![1] as any).location.start.line, 6); + // Line 12 should map to range starting at line 11 + assert.strictEqual((detailedCoverage![2] as any).location.start.line, 11); + }); + + test('should handle large line numbers', () => { + const payload: CoveragePayload = { + coverage: true, + cwd: '/foo/bar', + result: { + '/path/to/file.py': { + lines_covered: [1000, 5000, 10000], + lines_missed: [], + executed_branches: 0, + total_branches: -1, + }, + }, + error: '', + }; + + const result = coverageHandler.processCoverage(payload, runInstanceMock.object); + + const detailedCoverage = result.get(Uri.file('/path/to/file.py').fsPath); + assert.ok(detailedCoverage); + assert.strictEqual(detailedCoverage!.length, 3); + + // Verify conversion is correct for large numbers + assert.strictEqual((detailedCoverage![0] as any).location.start.line, 999); + assert.strictEqual((detailedCoverage![1] as any).location.start.line, 4999); + assert.strictEqual((detailedCoverage![2] as any).location.start.line, 9999); + }); + + test('should create detailed coverage with both covered and missed lines', () => { + const payload: CoveragePayload = { + coverage: true, + cwd: '/foo/bar', + result: { + '/path/to/file.py': { + lines_covered: [1, 3, 5], + lines_missed: [2, 4, 6], + executed_branches: 3, + total_branches: 6, + }, + }, + error: '', + }; + + const result = coverageHandler.processCoverage(payload, runInstanceMock.object); + + const detailedCoverage = result.get(Uri.file('/path/to/file.py').fsPath); + assert.ok(detailedCoverage); + assert.strictEqual(detailedCoverage!.length, 6); // 3 covered + 3 missed + + // Count covered vs not covered + const covered = detailedCoverage!.filter((c) => (c as any).executed === true); + const notCovered = detailedCoverage!.filter((c) => (c as any).executed === false); + + assert.strictEqual(covered.length, 3); + assert.strictEqual(notCovered.length, 3); + }); + + test('should set range to cover entire line', () => { + const payload: CoveragePayload = { + coverage: true, + cwd: '/foo/bar', + result: { + '/path/to/file.py': { + lines_covered: [1], + lines_missed: [], + executed_branches: 0, + total_branches: -1, + }, + }, + error: '', + }; + + const result = coverageHandler.processCoverage(payload, runInstanceMock.object); + + const detailedCoverage = result.get(Uri.file('/path/to/file.py').fsPath); + assert.ok(detailedCoverage); + + const coverage = detailedCoverage![0] as any; + // Start at column 0 + assert.strictEqual(coverage.location.start.character, 0); + // End at max safe integer (entire line) + assert.strictEqual(coverage.location.end.character, Number.MAX_SAFE_INTEGER); + }); + }); +}); diff --git a/src/test/testing/testController/common/testDiscoveryHandler.unit.test.ts b/src/test/testing/testController/common/testDiscoveryHandler.unit.test.ts new file mode 100644 index 000000000000..458e3d984405 --- /dev/null +++ b/src/test/testing/testController/common/testDiscoveryHandler.unit.test.ts @@ -0,0 +1,517 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { TestController, TestItem, Uri, CancellationToken, TestItemCollection } from 'vscode'; +import * as typemoq from 'typemoq'; +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { TestDiscoveryHandler } from '../../../../client/testing/testController/common/testDiscoveryHandler'; +import { TestItemIndex } from '../../../../client/testing/testController/common/testItemIndex'; +import { DiscoveredTestPayload, DiscoveredTestNode } from '../../../../client/testing/testController/common/types'; +import { TestProvider } from '../../../../client/testing/types'; +import * as utils from '../../../../client/testing/testController/common/utils'; +import * as testItemUtilities from '../../../../client/testing/testController/common/testItemUtilities'; + +suite('TestDiscoveryHandler', () => { + let discoveryHandler: TestDiscoveryHandler; + let testControllerMock: typemoq.IMock; + let testItemIndexMock: typemoq.IMock; + let testItemCollectionMock: typemoq.IMock; + let workspaceUri: Uri; + let testProvider: TestProvider; + let cancelationToken: CancellationToken; + + setup(() => { + discoveryHandler = new TestDiscoveryHandler(); + testControllerMock = typemoq.Mock.ofType(); + testItemIndexMock = typemoq.Mock.ofType(); + testItemCollectionMock = typemoq.Mock.ofType(); + + // Setup default test controller items mock + testControllerMock.setup((t) => t.items).returns(() => testItemCollectionMock.object); + testItemCollectionMock.setup((x) => x.delete(typemoq.It.isAny())).returns(() => undefined); + testItemCollectionMock.setup((x) => x.get(typemoq.It.isAny())).returns(() => undefined); + testItemCollectionMock.setup((x) => x.add(typemoq.It.isAny())).returns(() => undefined); + + workspaceUri = Uri.file('/foo/bar'); + testProvider = 'pytest'; + cancelationToken = ({ + isCancellationRequested: false, + } as unknown) as CancellationToken; + }); + + teardown(() => { + sinon.restore(); + }); + + suite('processDiscovery', () => { + test('should handle null payload gracefully', () => { + discoveryHandler.processDiscovery( + null as any, + testControllerMock.object, + testItemIndexMock.object, + workspaceUri, + testProvider, + cancelationToken, + ); + + // Should not throw and should not call populateTestTree + testItemIndexMock.verify((x) => x.clear(), typemoq.Times.never()); + }); + + test('should call populateTestTree with correct params on success', () => { + const tests: DiscoveredTestNode = { + path: '/foo/bar', + name: 'root', + type_: 'folder', + id_: 'root_id', + children: [], + }; + + const payload: DiscoveredTestPayload = { + cwd: workspaceUri.fsPath, + status: 'success', + tests, + }; + + const populateTestTreeStub = sinon.stub(utils, 'populateTestTree'); + testItemIndexMock.setup((x) => x.clear()).returns(() => undefined); + + // Setup map getters for populateTestTree + const mockRunIdMap = new Map(); + const mockVSidMap = new Map(); + const mockVStoRunMap = new Map(); + testItemIndexMock.setup((x) => x.runIdToTestItemMap).returns(() => mockRunIdMap); + testItemIndexMock.setup((x) => x.runIdToVSidMap).returns(() => mockVSidMap); + testItemIndexMock.setup((x) => x.vsIdToRunIdMap).returns(() => mockVStoRunMap); + + discoveryHandler.processDiscovery( + payload, + testControllerMock.object, + testItemIndexMock.object, + workspaceUri, + testProvider, + cancelationToken, + ); + + testItemIndexMock.verify((x) => x.clear(), typemoq.Times.once()); + assert.ok(populateTestTreeStub.calledOnce); + sinon.assert.calledWith( + populateTestTreeStub, + testControllerMock.object, + tests, + undefined, + sinon.match.any, + cancelationToken, + ); + }); + + test('should clear index before populating', () => { + const tests: DiscoveredTestNode = { + path: '/foo/bar', + name: 'root', + type_: 'folder', + id_: 'root_id', + children: [], + }; + + const payload: DiscoveredTestPayload = { + cwd: workspaceUri.fsPath, + status: 'success', + tests, + }; + + sinon.stub(utils, 'populateTestTree'); + + const clearSpy = sinon.spy(); + testItemIndexMock.setup((x) => x.clear()).callback(clearSpy); + testItemIndexMock.setup((x) => x.runIdToTestItemMap).returns(() => new Map()); + testItemIndexMock.setup((x) => x.runIdToVSidMap).returns(() => new Map()); + testItemIndexMock.setup((x) => x.vsIdToRunIdMap).returns(() => new Map()); + + discoveryHandler.processDiscovery( + payload, + testControllerMock.object, + testItemIndexMock.object, + workspaceUri, + testProvider, + cancelationToken, + ); + + assert.ok(clearSpy.calledOnce); + }); + + test('should handle error status and create error node', () => { + const payload: DiscoveredTestPayload = { + cwd: workspaceUri.fsPath, + status: 'error', + error: ['Error message 1', 'Error message 2'], + }; + + const createErrorNodeSpy = sinon.spy(discoveryHandler, 'createErrorNode'); + + // Mock createTestItem to return a proper TestItem + const mockErrorItem = ({ + id: 'error_id', + error: null, + canResolveChildren: false, + tags: [], + } as unknown) as TestItem; + testControllerMock + .setup((t) => t.createTestItem(typemoq.It.isAny(), typemoq.It.isAny())) + .returns(() => mockErrorItem); + + discoveryHandler.processDiscovery( + payload, + testControllerMock.object, + testItemIndexMock.object, + workspaceUri, + testProvider, + cancelationToken, + ); + + assert.ok(createErrorNodeSpy.calledOnce); + assert.ok( + createErrorNodeSpy.calledWith(testControllerMock.object, workspaceUri, payload.error, testProvider), + ); + }); + + test('should handle both errors and tests in same payload', () => { + const tests: DiscoveredTestNode = { + path: '/foo/bar', + name: 'root', + type_: 'folder', + id_: 'root_id', + children: [], + }; + + const payload: DiscoveredTestPayload = { + cwd: workspaceUri.fsPath, + status: 'error', + error: ['Partial error'], + tests, + }; + + sinon.stub(utils, 'populateTestTree'); + const createErrorNodeSpy = sinon.spy(discoveryHandler, 'createErrorNode'); + + // Mock createTestItem to return a proper TestItem + const mockErrorItem = ({ + id: 'error_id', + error: null, + canResolveChildren: false, + tags: [], + } as unknown) as TestItem; + testControllerMock + .setup((t) => t.createTestItem(typemoq.It.isAny(), typemoq.It.isAny())) + .returns(() => mockErrorItem); + + testItemIndexMock.setup((x) => x.clear()).returns(() => undefined); + testItemIndexMock.setup((x) => x.runIdToTestItemMap).returns(() => new Map()); + testItemIndexMock.setup((x) => x.runIdToVSidMap).returns(() => new Map()); + testItemIndexMock.setup((x) => x.vsIdToRunIdMap).returns(() => new Map()); + + discoveryHandler.processDiscovery( + payload, + testControllerMock.object, + testItemIndexMock.object, + workspaceUri, + testProvider, + cancelationToken, + ); + + // Should create error node AND populate test tree + assert.ok(createErrorNodeSpy.calledOnce); + testItemIndexMock.verify((x) => x.clear(), typemoq.Times.once()); + }); + + test('should delete error node on successful discovery', () => { + const tests: DiscoveredTestNode = { + path: '/foo/bar', + name: 'root', + type_: 'folder', + id_: 'root_id', + children: [], + }; + + const payload: DiscoveredTestPayload = { + cwd: workspaceUri.fsPath, + status: 'success', + tests, + }; + + const deleteSpy = sinon.spy(); + // Reset and reconfigure the collection mock to capture delete call + testItemCollectionMock.reset(); + testItemCollectionMock + .setup((x) => x.delete(typemoq.It.isAny())) + .callback(deleteSpy) + .returns(() => undefined); + testItemCollectionMock.setup((x) => x.get(typemoq.It.isAny())).returns(() => undefined); + testItemCollectionMock.setup((x) => x.add(typemoq.It.isAny())).returns(() => undefined); + testControllerMock.reset(); + testControllerMock.setup((t) => t.items).returns(() => testItemCollectionMock.object); + + sinon.stub(utils, 'populateTestTree'); + testItemIndexMock.setup((x) => x.clear()).returns(() => undefined); + testItemIndexMock.setup((x) => x.runIdToTestItemMap).returns(() => new Map()); + testItemIndexMock.setup((x) => x.runIdToVSidMap).returns(() => new Map()); + testItemIndexMock.setup((x) => x.vsIdToRunIdMap).returns(() => new Map()); + + discoveryHandler.processDiscovery( + payload, + testControllerMock.object, + testItemIndexMock.object, + workspaceUri, + testProvider, + cancelationToken, + ); + + assert.ok(deleteSpy.calledOnce); + assert.ok(deleteSpy.calledWith(`DiscoveryError:${workspaceUri.fsPath}`)); + }); + + test('should respect cancellation token', () => { + const tests: DiscoveredTestNode = { + path: '/foo/bar', + name: 'root', + type_: 'folder', + id_: 'root_id', + children: [], + }; + + const payload: DiscoveredTestPayload = { + cwd: workspaceUri.fsPath, + status: 'success', + tests, + }; + + const populateTestTreeStub = sinon.stub(utils, 'populateTestTree'); + testItemIndexMock.setup((x) => x.clear()).returns(() => undefined); + testItemIndexMock.setup((x) => x.runIdToTestItemMap).returns(() => new Map()); + testItemIndexMock.setup((x) => x.runIdToVSidMap).returns(() => new Map()); + testItemIndexMock.setup((x) => x.vsIdToRunIdMap).returns(() => new Map()); + + discoveryHandler.processDiscovery( + payload, + testControllerMock.object, + testItemIndexMock.object, + workspaceUri, + testProvider, + cancelationToken, + ); + + // Verify token was passed to populateTestTree + assert.ok(populateTestTreeStub.calledOnce); + const lastArg = populateTestTreeStub.getCall(0).args[4]; + assert.strictEqual(lastArg, cancelationToken); + }); + + test('should handle null tests in payload', () => { + const payload: DiscoveredTestPayload = { + cwd: workspaceUri.fsPath, + status: 'success', + tests: null as any, + }; + + const populateTestTreeStub = sinon.stub(utils, 'populateTestTree'); + testItemIndexMock.setup((x) => x.clear()).returns(() => undefined); + testItemIndexMock.setup((x) => x.runIdToTestItemMap).returns(() => new Map()); + testItemIndexMock.setup((x) => x.runIdToVSidMap).returns(() => new Map()); + testItemIndexMock.setup((x) => x.vsIdToRunIdMap).returns(() => new Map()); + + discoveryHandler.processDiscovery( + payload, + testControllerMock.object, + testItemIndexMock.object, + workspaceUri, + testProvider, + cancelationToken, + ); + + // Should still call populateTestTree with null + assert.ok(populateTestTreeStub.calledOnce); + testItemIndexMock.verify((x) => x.clear(), typemoq.Times.once()); + }); + }); + + suite('createErrorNode', () => { + test('should create error with correct message for pytest', () => { + const error = ['Error line 1', 'Error line 2']; + testProvider = 'pytest'; + + const buildErrorNodeOptionsStub = sinon.stub(utils, 'buildErrorNodeOptions').returns({ + id: 'error_id', + label: 'Error Label', + error: 'Error Message', + }); + + const mockErrorItem = ({ + id: 'error_id', + error: null, + } as unknown) as TestItem; + + const createErrorTestItemStub = sinon.stub(testItemUtilities, 'createErrorTestItem').returns(mockErrorItem); + + const testItemCollectionMock = typemoq.Mock.ofType(); + testItemCollectionMock.setup((x) => x.get(typemoq.It.isAny())).returns(() => undefined); + testItemCollectionMock.setup((x) => x.add(typemoq.It.isAny())).returns(() => undefined); + testControllerMock.setup((t) => t.items).returns(() => testItemCollectionMock.object); + + discoveryHandler.createErrorNode(testControllerMock.object, workspaceUri, error, testProvider); + + assert.ok(buildErrorNodeOptionsStub.calledOnce); + assert.ok(createErrorTestItemStub.calledOnce); + assert.ok(mockErrorItem.error !== null); + }); + + test('should create error with correct message for unittest', () => { + const error = ['Unittest error']; + testProvider = 'unittest'; + + sinon.stub(utils, 'buildErrorNodeOptions').returns({ + id: 'error_id', + label: 'Error Label', + error: 'Error Message', + }); + + const mockErrorItem = ({ + id: 'error_id', + error: null, + } as unknown) as TestItem; + + sinon.stub(testItemUtilities, 'createErrorTestItem').returns(mockErrorItem); + + const testItemCollectionMock = typemoq.Mock.ofType(); + testItemCollectionMock.setup((x) => x.get(typemoq.It.isAny())).returns(() => undefined); + testItemCollectionMock.setup((x) => x.add(typemoq.It.isAny())).returns(() => undefined); + testControllerMock.setup((t) => t.items).returns(() => testItemCollectionMock.object); + + discoveryHandler.createErrorNode(testControllerMock.object, workspaceUri, error, testProvider); + + assert.ok(mockErrorItem.error !== null); + }); + + test('should set markdown error label correctly', () => { + const error = ['Test error']; + + sinon.stub(utils, 'buildErrorNodeOptions').returns({ + id: 'error_id', + label: 'Error Label', + error: 'Error Message', + }); + + const mockErrorItem = ({ + id: 'error_id', + error: null, + } as unknown) as TestItem; + + sinon.stub(testItemUtilities, 'createErrorTestItem').returns(mockErrorItem); + + const testItemCollectionMock = typemoq.Mock.ofType(); + testItemCollectionMock.setup((x) => x.get(typemoq.It.isAny())).returns(() => undefined); + testItemCollectionMock.setup((x) => x.add(typemoq.It.isAny())).returns(() => undefined); + testControllerMock.setup((t) => t.items).returns(() => testItemCollectionMock.object); + + discoveryHandler.createErrorNode(testControllerMock.object, workspaceUri, error, testProvider); + + assert.ok(mockErrorItem.error); + assert.strictEqual( + (mockErrorItem.error as any).value, + '[Show output](command:python.viewOutput) to view error logs', + ); + assert.strictEqual((mockErrorItem.error as any).isTrusted, true); + }); + + test('should handle undefined error array', () => { + sinon.stub(utils, 'buildErrorNodeOptions').returns({ + id: 'error_id', + label: 'Error Label', + error: 'Error Message', + }); + + const mockErrorItem = ({ + id: 'error_id', + error: null, + } as unknown) as TestItem; + + sinon.stub(testItemUtilities, 'createErrorTestItem').returns(mockErrorItem); + + const testItemCollectionMock = typemoq.Mock.ofType(); + testItemCollectionMock.setup((x) => x.get(typemoq.It.isAny())).returns(() => undefined); + testItemCollectionMock.setup((x) => x.add(typemoq.It.isAny())).returns(() => undefined); + testControllerMock.setup((t) => t.items).returns(() => testItemCollectionMock.object); + + discoveryHandler.createErrorNode(testControllerMock.object, workspaceUri, undefined, testProvider); + + // Should not throw + assert.ok(mockErrorItem.error !== null); + }); + + test('should reuse existing error node if present', () => { + const error = ['Error']; + + // Create a proper object with settable error property + const existingErrorItem: any = { + id: `DiscoveryError:${workspaceUri.fsPath}`, + error: null, + canResolveChildren: false, + tags: [], + }; + + sinon.stub(utils, 'buildErrorNodeOptions').returns({ + id: `DiscoveryError:${workspaceUri.fsPath}`, + label: 'Error Label', + error: 'Error Message', + }); + + const createErrorTestItemStub = sinon.stub(testItemUtilities, 'createErrorTestItem'); + + // Reset and setup collection to return existing item + testItemCollectionMock.reset(); + testItemCollectionMock + .setup((x) => x.get(`DiscoveryError:${workspaceUri.fsPath}`)) + .returns(() => existingErrorItem); + testItemCollectionMock.setup((x) => x.add(typemoq.It.isAny())).returns(() => undefined); + testControllerMock.reset(); + testControllerMock.setup((t) => t.items).returns(() => testItemCollectionMock.object); + + discoveryHandler.createErrorNode(testControllerMock.object, workspaceUri, error, testProvider); + + // Should not create a new error item + assert.ok(createErrorTestItemStub.notCalled); + // Should still update the error property + assert.ok(existingErrorItem.error !== null); + }); + + test('should handle multiple error messages', () => { + const error = ['Error 1', 'Error 2', 'Error 3']; + + const buildStub = sinon.stub(utils, 'buildErrorNodeOptions').returns({ + id: 'error_id', + label: 'Error Label', + error: 'Error Message', + }); + + const mockErrorItem = ({ + id: 'error_id', + error: null, + } as unknown) as TestItem; + + sinon.stub(testItemUtilities, 'createErrorTestItem').returns(mockErrorItem); + + const testItemCollectionMock = typemoq.Mock.ofType(); + testItemCollectionMock.setup((x) => x.get(typemoq.It.isAny())).returns(() => undefined); + testItemCollectionMock.setup((x) => x.add(typemoq.It.isAny())).returns(() => undefined); + testControllerMock.setup((t) => t.items).returns(() => testItemCollectionMock.object); + + discoveryHandler.createErrorNode(testControllerMock.object, workspaceUri, error, testProvider); + + // Verify the error messages are joined + const expectedMessage = sinon.match((value: string) => { + return value.includes('Error 1') && value.includes('Error 2') && value.includes('Error 3'); + }); + sinon.assert.calledWith(buildStub, workspaceUri, expectedMessage, testProvider); + }); + }); +}); diff --git a/src/test/testing/testController/common/testExecutionHandler.unit.test.ts b/src/test/testing/testController/common/testExecutionHandler.unit.test.ts new file mode 100644 index 000000000000..c6be4548c192 --- /dev/null +++ b/src/test/testing/testController/common/testExecutionHandler.unit.test.ts @@ -0,0 +1,922 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { TestController, TestItem, TestRun, TestMessage, Uri, Range, TestItemCollection, MarkdownString } from 'vscode'; +import * as typemoq from 'typemoq'; +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { TestExecutionHandler } from '../../../../client/testing/testController/common/testExecutionHandler'; +import { TestItemIndex } from '../../../../client/testing/testController/common/testItemIndex'; +import { ExecutionTestPayload } from '../../../../client/testing/testController/common/types'; + +suite('TestExecutionHandler', () => { + let executionHandler: TestExecutionHandler; + let testControllerMock: typemoq.IMock; + let testItemIndexMock: typemoq.IMock; + let runInstanceMock: typemoq.IMock; + let mockTestItem: TestItem; + let mockParentItem: TestItem; + + setup(() => { + executionHandler = new TestExecutionHandler(); + testControllerMock = typemoq.Mock.ofType(); + testItemIndexMock = typemoq.Mock.ofType(); + runInstanceMock = typemoq.Mock.ofType(); + + mockTestItem = createMockTestItem('test1', 'Test 1'); + mockParentItem = createMockTestItem('parentTest', 'Parent Test'); + }); + + teardown(() => { + sinon.restore(); + }); + + suite('processExecution', () => { + test('should process empty payload without errors', () => { + const payload: ExecutionTestPayload = { + cwd: '/foo/bar', + status: 'success', + result: {}, + error: '', + }; + + executionHandler.processExecution( + payload, + runInstanceMock.object, + testItemIndexMock.object, + testControllerMock.object, + ); + + // No errors should be thrown + }); + + test('should process undefined result without errors', () => { + const payload: ExecutionTestPayload = { + cwd: '/foo/bar', + status: 'success', + error: '', + }; + + executionHandler.processExecution( + payload, + runInstanceMock.object, + testItemIndexMock.object, + testControllerMock.object, + ); + + // No errors should be thrown + }); + + test('should process multiple test results', () => { + const payload: ExecutionTestPayload = { + cwd: '/foo/bar', + status: 'success', + result: { + test1: { test: 'test1', outcome: 'success', message: '', traceback: '' }, + test2: { test: 'test2', outcome: 'failure', message: 'Failed', traceback: 'traceback' }, + }, + error: '', + }; + + const mockTestItem2 = createMockTestItem('test2', 'Test 2'); + + testItemIndexMock + .setup((x) => x.getTestItem('test1', testControllerMock.object)) + .returns(() => mockTestItem); + testItemIndexMock + .setup((x) => x.getTestItem('test2', testControllerMock.object)) + .returns(() => mockTestItem2); + + executionHandler.processExecution( + payload, + runInstanceMock.object, + testItemIndexMock.object, + testControllerMock.object, + ); + + runInstanceMock.verify((r) => r.passed(mockTestItem), typemoq.Times.once()); + runInstanceMock.verify((r) => r.failed(mockTestItem2, typemoq.It.isAny()), typemoq.Times.once()); + }); + }); + + suite('handleTestError', () => { + test('should create error message with traceback', () => { + const payload: ExecutionTestPayload = { + cwd: '/foo/bar', + status: 'success', + result: { + test1: { + test: 'test1', + outcome: 'error', + message: 'Error occurred', + traceback: 'line1\nline2\nline3', + }, + }, + error: '', + }; + + testItemIndexMock + .setup((x) => x.getTestItem('test1', testControllerMock.object)) + .returns(() => mockTestItem); + + let capturedMessage: TestMessage | undefined; + runInstanceMock + .setup((r) => r.errored(mockTestItem, typemoq.It.isAny())) + .callback((_, message: TestMessage) => { + capturedMessage = message; + }); + + executionHandler.processExecution( + payload, + runInstanceMock.object, + testItemIndexMock.object, + testControllerMock.object, + ); + + assert.ok(capturedMessage); + const messageText = + capturedMessage!.message instanceof MarkdownString + ? capturedMessage!.message.value + : capturedMessage!.message; + assert.ok(messageText.includes('Error occurred')); + assert.ok(messageText.includes('line1')); + assert.ok(messageText.includes('line2')); + runInstanceMock.verify((r) => r.errored(mockTestItem, typemoq.It.isAny()), typemoq.Times.once()); + }); + + test('should set location when test item has range', () => { + const payload: ExecutionTestPayload = { + cwd: '/foo/bar', + status: 'success', + result: { + test1: { + test: 'test1', + outcome: 'error', + message: 'Error', + traceback: '', + }, + }, + error: '', + }; + + testItemIndexMock + .setup((x) => x.getTestItem('test1', testControllerMock.object)) + .returns(() => mockTestItem); + + let capturedMessage: TestMessage | undefined; + runInstanceMock + .setup((r) => r.errored(mockTestItem, typemoq.It.isAny())) + .callback((_, message: TestMessage) => { + capturedMessage = message; + }); + + executionHandler.processExecution( + payload, + runInstanceMock.object, + testItemIndexMock.object, + testControllerMock.object, + ); + + assert.ok(capturedMessage); + assert.ok(capturedMessage!.location); + assert.strictEqual(capturedMessage!.location!.uri.fsPath, mockTestItem.uri!.fsPath); + }); + + test('should handle missing traceback', () => { + const payload: ExecutionTestPayload = { + cwd: '/foo/bar', + status: 'success', + result: { + test1: { + test: 'test1', + outcome: 'error', + message: 'Error', + }, + }, + error: '', + }; + + testItemIndexMock + .setup((x) => x.getTestItem('test1', testControllerMock.object)) + .returns(() => mockTestItem); + + executionHandler.processExecution( + payload, + runInstanceMock.object, + testItemIndexMock.object, + testControllerMock.object, + ); + + runInstanceMock.verify((r) => r.errored(mockTestItem, typemoq.It.isAny()), typemoq.Times.once()); + }); + }); + + suite('handleTestFailure', () => { + test('should create failure message with traceback', () => { + const payload: ExecutionTestPayload = { + cwd: '/foo/bar', + status: 'success', + result: { + test1: { + test: 'test1', + outcome: 'failure', + message: 'Assertion failed', + traceback: 'AssertionError\nline1', + }, + }, + error: '', + }; + + testItemIndexMock + .setup((x) => x.getTestItem('test1', testControllerMock.object)) + .returns(() => mockTestItem); + + let capturedMessage: TestMessage | undefined; + runInstanceMock + .setup((r) => r.failed(mockTestItem, typemoq.It.isAny())) + .callback((_, message: TestMessage) => { + capturedMessage = message; + }); + + executionHandler.processExecution( + payload, + runInstanceMock.object, + testItemIndexMock.object, + testControllerMock.object, + ); + + assert.ok(capturedMessage); + const messageText = + capturedMessage!.message instanceof MarkdownString + ? capturedMessage!.message.value + : capturedMessage!.message; + assert.ok(messageText.includes('Assertion failed')); + assert.ok(messageText.includes('AssertionError')); + runInstanceMock.verify((r) => r.failed(mockTestItem, typemoq.It.isAny()), typemoq.Times.once()); + }); + + test('should handle passed-unexpected outcome', () => { + const payload: ExecutionTestPayload = { + cwd: '/foo/bar', + status: 'success', + result: { + test1: { + test: 'test1', + outcome: 'passed-unexpected', + message: 'Unexpected pass', + traceback: '', + }, + }, + error: '', + }; + + testItemIndexMock + .setup((x) => x.getTestItem('test1', testControllerMock.object)) + .returns(() => mockTestItem); + + executionHandler.processExecution( + payload, + runInstanceMock.object, + testItemIndexMock.object, + testControllerMock.object, + ); + + runInstanceMock.verify((r) => r.failed(mockTestItem, typemoq.It.isAny()), typemoq.Times.once()); + }); + }); + + suite('handleTestSuccess', () => { + test('should mark test as passed', () => { + const payload: ExecutionTestPayload = { + cwd: '/foo/bar', + status: 'success', + result: { + test1: { + test: 'test1', + outcome: 'success', + message: '', + traceback: '', + }, + }, + error: '', + }; + + testItemIndexMock + .setup((x) => x.getTestItem('test1', testControllerMock.object)) + .returns(() => mockTestItem); + + executionHandler.processExecution( + payload, + runInstanceMock.object, + testItemIndexMock.object, + testControllerMock.object, + ); + + runInstanceMock.verify((r) => r.passed(mockTestItem), typemoq.Times.once()); + }); + + test('should handle expected-failure outcome', () => { + const payload: ExecutionTestPayload = { + cwd: '/foo/bar', + status: 'success', + result: { + test1: { + test: 'test1', + outcome: 'expected-failure', + message: '', + traceback: '', + }, + }, + error: '', + }; + + testItemIndexMock + .setup((x) => x.getTestItem('test1', testControllerMock.object)) + .returns(() => mockTestItem); + + executionHandler.processExecution( + payload, + runInstanceMock.object, + testItemIndexMock.object, + testControllerMock.object, + ); + + runInstanceMock.verify((r) => r.passed(mockTestItem), typemoq.Times.once()); + }); + + test('should not call passed when test item not found', () => { + const payload: ExecutionTestPayload = { + cwd: '/foo/bar', + status: 'success', + result: { + test1: { + test: 'test1', + outcome: 'success', + message: '', + traceback: '', + }, + }, + error: '', + }; + + testItemIndexMock.setup((x) => x.getTestItem('test1', testControllerMock.object)).returns(() => undefined); + + executionHandler.processExecution( + payload, + runInstanceMock.object, + testItemIndexMock.object, + testControllerMock.object, + ); + + runInstanceMock.verify((r) => r.passed(typemoq.It.isAny()), typemoq.Times.never()); + }); + }); + + suite('handleTestSkipped', () => { + test('should mark test as skipped', () => { + const payload: ExecutionTestPayload = { + cwd: '/foo/bar', + status: 'success', + result: { + test1: { + test: 'test1', + outcome: 'skipped', + message: 'Test skipped', + traceback: '', + }, + }, + error: '', + }; + + testItemIndexMock + .setup((x) => x.getTestItem('test1', testControllerMock.object)) + .returns(() => mockTestItem); + + executionHandler.processExecution( + payload, + runInstanceMock.object, + testItemIndexMock.object, + testControllerMock.object, + ); + + runInstanceMock.verify((r) => r.skipped(mockTestItem), typemoq.Times.once()); + }); + }); + + suite('handleSubtestFailure', () => { + test('should create child test item for subtest', () => { + const payload: ExecutionTestPayload = { + cwd: '/foo/bar', + status: 'success', + result: { + 'parentTest (subtest1)': { + test: 'parentTest', + outcome: 'subtest-failure', + message: 'Subtest failed', + traceback: 'traceback', + subtest: 'subtest1', + }, + }, + error: '', + }; + + const mockSubtestItem = createMockTestItem('subtest1', 'Subtest 1'); + + testItemIndexMock + .setup((x) => x.getTestItem('parentTest', testControllerMock.object)) + .returns(() => mockParentItem); + testItemIndexMock.setup((x) => x.getSubtestStats('parentTest')).returns(() => undefined); + testItemIndexMock + .setup((x) => x.setSubtestStats('parentTest', typemoq.It.isAny())) + .returns(() => undefined); + testControllerMock + .setup((t) => t.createTestItem(typemoq.It.isAny(), typemoq.It.isAny(), typemoq.It.isAny())) + .returns(() => mockSubtestItem); + + executionHandler.processExecution( + payload, + runInstanceMock.object, + testItemIndexMock.object, + testControllerMock.object, + ); + + // Verify stats were set correctly + testItemIndexMock.verify( + (x) => + x.setSubtestStats( + 'parentTest', + typemoq.It.is((stats) => stats.failed === 1 && stats.passed === 0), + ), + typemoq.Times.once(), + ); + + runInstanceMock.verify((r) => r.started(mockSubtestItem), typemoq.Times.once()); + runInstanceMock.verify((r) => r.failed(mockSubtestItem, typemoq.It.isAny()), typemoq.Times.once()); + }); + + test('should update stats correctly for multiple subtests', () => { + const payload1: ExecutionTestPayload = { + cwd: '/foo/bar', + status: 'success', + result: { + 'parentTest (subtest1)': { + test: 'parentTest', + outcome: 'subtest-failure', + message: 'Failed', + traceback: '', + subtest: 'subtest1', + }, + }, + error: '', + }; + + const payload2: ExecutionTestPayload = { + cwd: '/foo/bar', + status: 'success', + result: { + 'parentTest (subtest2)': { + test: 'parentTest', + outcome: 'subtest-failure', + message: 'Failed', + traceback: '', + subtest: 'subtest2', + }, + }, + error: '', + }; + + const mockSubtest1 = createMockTestItem('subtest1', 'Subtest 1'); + const mockSubtest2 = createMockTestItem('subtest2', 'Subtest 2'); + + testItemIndexMock + .setup((x) => x.getTestItem('parentTest', testControllerMock.object)) + .returns(() => mockParentItem); + + // First subtest: no existing stats + testItemIndexMock.setup((x) => x.getSubtestStats('parentTest')).returns(() => undefined); + testItemIndexMock + .setup((x) => x.setSubtestStats('parentTest', typemoq.It.isAny())) + .returns(() => undefined); + + // Return different items based on call order + let callCount = 0; + testControllerMock + .setup((t) => t.createTestItem(typemoq.It.isAny(), typemoq.It.isAny(), typemoq.It.isAny())) + .returns(() => { + callCount++; + return callCount === 1 ? mockSubtest1 : mockSubtest2; + }); + + executionHandler.processExecution( + payload1, + runInstanceMock.object, + testItemIndexMock.object, + testControllerMock.object, + ); + + // Second subtest: should have existing stats from first + testItemIndexMock.reset(); + testItemIndexMock + .setup((x) => x.getTestItem('parentTest', testControllerMock.object)) + .returns(() => mockParentItem); + testItemIndexMock.setup((x) => x.getSubtestStats('parentTest')).returns(() => ({ failed: 1, passed: 0 })); + + executionHandler.processExecution( + payload2, + runInstanceMock.object, + testItemIndexMock.object, + testControllerMock.object, + ); + + // Verify the first subtest set initial stats + runInstanceMock.verify((r) => r.started(mockSubtest1), typemoq.Times.once()); + runInstanceMock.verify((r) => r.started(mockSubtest2), typemoq.Times.once()); + }); + + test('should throw error when parent test item not found', () => { + const payload: ExecutionTestPayload = { + cwd: '/foo/bar', + status: 'success', + result: { + 'parentTest (subtest1)': { + test: 'parentTest', + outcome: 'subtest-failure', + message: 'Failed', + traceback: '', + subtest: 'subtest1', + }, + }, + error: '', + }; + + testItemIndexMock + .setup((x) => x.getTestItem('parentTest', testControllerMock.object)) + .returns(() => undefined); + + assert.throws(() => { + executionHandler.processExecution( + payload, + runInstanceMock.object, + testItemIndexMock.object, + testControllerMock.object, + ); + }, /Parent test item not found/); + }); + }); + + suite('handleSubtestSuccess', () => { + test('should create passing subtest', () => { + const payload: ExecutionTestPayload = { + cwd: '/foo/bar', + status: 'success', + result: { + 'parentTest (subtest1)': { + test: 'parentTest', + outcome: 'subtest-success', + message: '', + traceback: '', + subtest: 'subtest1', + }, + }, + error: '', + }; + + const mockSubtestItem = createMockTestItem('subtest1', 'Subtest 1'); + + testItemIndexMock + .setup((x) => x.getTestItem('parentTest', testControllerMock.object)) + .returns(() => mockParentItem); + testItemIndexMock.setup((x) => x.getSubtestStats('parentTest')).returns(() => undefined); + testItemIndexMock + .setup((x) => x.setSubtestStats('parentTest', typemoq.It.isAny())) + .returns(() => undefined); + testControllerMock + .setup((t) => t.createTestItem(typemoq.It.isAny(), typemoq.It.isAny(), typemoq.It.isAny())) + .returns(() => mockSubtestItem); + + executionHandler.processExecution( + payload, + runInstanceMock.object, + testItemIndexMock.object, + testControllerMock.object, + ); + + // Verify stats were set correctly + testItemIndexMock.verify( + (x) => + x.setSubtestStats( + 'parentTest', + typemoq.It.is((stats) => stats.passed === 1 && stats.failed === 0), + ), + typemoq.Times.once(), + ); + + runInstanceMock.verify((r) => r.started(mockSubtestItem), typemoq.Times.once()); + runInstanceMock.verify((r) => r.passed(mockSubtestItem), typemoq.Times.once()); + }); + + test('should handle subtest with special characters in name', () => { + const payload: ExecutionTestPayload = { + cwd: '/foo/bar', + status: 'success', + result: { + 'parentTest [subtest with spaces and [brackets]]': { + test: 'parentTest', + outcome: 'subtest-success', + message: '', + traceback: '', + subtest: 'subtest with spaces and [brackets]', + }, + }, + error: '', + }; + + const mockSubtestItem = createMockTestItem('[subtest with spaces and [brackets]]', 'Subtest'); + + testItemIndexMock + .setup((x) => x.getTestItem('parentTest', testControllerMock.object)) + .returns(() => mockParentItem); + testItemIndexMock.setup((x) => x.getSubtestStats('parentTest')).returns(() => undefined); + testItemIndexMock + .setup((x) => x.setSubtestStats('parentTest', typemoq.It.isAny())) + .returns(() => undefined); + testControllerMock + .setup((t) => t.createTestItem(typemoq.It.isAny(), typemoq.It.isAny(), typemoq.It.isAny())) + .returns(() => mockSubtestItem); + + executionHandler.processExecution( + payload, + runInstanceMock.object, + testItemIndexMock.object, + testControllerMock.object, + ); + + runInstanceMock.verify((r) => r.passed(mockSubtestItem), typemoq.Times.once()); + }); + }); + + suite('Comprehensive Subtest Scenarios', () => { + test('should handle mixed passing and failing subtests in sequence', () => { + // Simulates unittest with subtests like: test_even with i=0,1,2,3,4,5 + const mockSubtest0 = createMockTestItem('(i=0)', '(i=0)'); + const mockSubtest1 = createMockTestItem('(i=1)', '(i=1)'); + const mockSubtest2 = createMockTestItem('(i=2)', '(i=2)'); + const mockSubtest3 = createMockTestItem('(i=3)', '(i=3)'); + const mockSubtest4 = createMockTestItem('(i=4)', '(i=4)'); + const mockSubtest5 = createMockTestItem('(i=5)', '(i=5)'); + + const subtestItems = [mockSubtest0, mockSubtest1, mockSubtest2, mockSubtest3, mockSubtest4, mockSubtest5]; + + testItemIndexMock + .setup((x) => x.getTestItem('test_even', testControllerMock.object)) + .returns(() => mockParentItem); + + let subtestCallCount = 0; + testControllerMock + .setup((t) => t.createTestItem(typemoq.It.isAny(), typemoq.It.isAny(), typemoq.It.isAny())) + .returns(() => subtestItems[subtestCallCount++]); + + // First subtest (i=0) - passes + testItemIndexMock.setup((x) => x.getSubtestStats('test_even')).returns(() => undefined); + testItemIndexMock.setup((x) => x.setSubtestStats('test_even', typemoq.It.isAny())).returns(() => undefined); + + const payload0: ExecutionTestPayload = { + cwd: '/foo/bar', + status: 'success', + result: { + 'test_even (i=0)': { + test: 'test_even', + outcome: 'subtest-success', + message: '', + traceback: '', + subtest: '(i=0)', + }, + }, + error: '', + }; + + executionHandler.processExecution( + payload0, + runInstanceMock.object, + testItemIndexMock.object, + testControllerMock.object, + ); + + // Verify first subtest created stats + testItemIndexMock.verify( + (x) => + x.setSubtestStats( + 'test_even', + typemoq.It.is((stats) => stats.passed === 1 && stats.failed === 0), + ), + typemoq.Times.once(), + ); + + // Second subtest (i=1) - fails + testItemIndexMock.reset(); + testItemIndexMock + .setup((x) => x.getTestItem('test_even', testControllerMock.object)) + .returns(() => mockParentItem); + testItemIndexMock.setup((x) => x.getSubtestStats('test_even')).returns(() => ({ passed: 1, failed: 0 })); + + const payload1: ExecutionTestPayload = { + cwd: '/foo/bar', + status: 'success', + result: { + 'test_even (i=1)': { + test: 'test_even', + outcome: 'subtest-failure', + message: '1 is not even', + traceback: 'AssertionError', + subtest: '(i=1)', + }, + }, + error: '', + }; + + executionHandler.processExecution( + payload1, + runInstanceMock.object, + testItemIndexMock.object, + testControllerMock.object, + ); + + // Third subtest (i=2) - passes + testItemIndexMock.reset(); + testItemIndexMock + .setup((x) => x.getTestItem('test_even', testControllerMock.object)) + .returns(() => mockParentItem); + testItemIndexMock.setup((x) => x.getSubtestStats('test_even')).returns(() => ({ passed: 1, failed: 1 })); + + const payload2: ExecutionTestPayload = { + cwd: '/foo/bar', + status: 'success', + result: { + 'test_even (i=2)': { + test: 'test_even', + outcome: 'subtest-success', + message: '', + traceback: '', + subtest: '(i=2)', + }, + }, + error: '', + }; + + executionHandler.processExecution( + payload2, + runInstanceMock.object, + testItemIndexMock.object, + testControllerMock.object, + ); + + // Verify all subtests were started and had outcomes + runInstanceMock.verify((r) => r.started(mockSubtest0), typemoq.Times.once()); + runInstanceMock.verify((r) => r.passed(mockSubtest0), typemoq.Times.once()); + runInstanceMock.verify((r) => r.started(mockSubtest1), typemoq.Times.once()); + runInstanceMock.verify((r) => r.failed(mockSubtest1, typemoq.It.isAny()), typemoq.Times.once()); + runInstanceMock.verify((r) => r.started(mockSubtest2), typemoq.Times.once()); + runInstanceMock.verify((r) => r.passed(mockSubtest2), typemoq.Times.once()); + }); + + test('should persist stats across multiple processExecution calls', () => { + // Test that stats persist in TestItemIndex across multiple processExecution calls + const mockSubtest1 = createMockTestItem('subtest1', 'Subtest 1'); + const mockSubtest2 = createMockTestItem('subtest2', 'Subtest 2'); + + testItemIndexMock + .setup((x) => x.getTestItem('parentTest', testControllerMock.object)) + .returns(() => mockParentItem); + testItemIndexMock.setup((x) => x.getSubtestStats('parentTest')).returns(() => undefined); + testItemIndexMock + .setup((x) => x.setSubtestStats('parentTest', typemoq.It.isAny())) + .returns(() => undefined); + + let callCount = 0; + testControllerMock + .setup((t) => t.createTestItem(typemoq.It.isAny(), typemoq.It.isAny(), typemoq.It.isAny())) + .returns(() => (callCount++ === 0 ? mockSubtest1 : mockSubtest2)); + + const payload1: ExecutionTestPayload = { + cwd: '/foo/bar', + status: 'success', + result: { + 'parentTest (subtest1)': { + test: 'parentTest', + outcome: 'subtest-success', + message: '', + traceback: '', + subtest: 'subtest1', + }, + }, + error: '', + }; + + // First call - no existing stats + executionHandler.processExecution( + payload1, + runInstanceMock.object, + testItemIndexMock.object, + testControllerMock.object, + ); + + // Simulate stats being stored in TestItemIndex + testItemIndexMock.reset(); + testItemIndexMock + .setup((x) => x.getTestItem('parentTest', testControllerMock.object)) + .returns(() => mockParentItem); + testItemIndexMock.setup((x) => x.getSubtestStats('parentTest')).returns(() => ({ passed: 1, failed: 0 })); + + const payload2: ExecutionTestPayload = { + cwd: '/foo/bar', + status: 'success', + result: { + 'parentTest (subtest2)': { + test: 'parentTest', + outcome: 'subtest-failure', + message: 'Failed', + traceback: '', + subtest: 'subtest2', + }, + }, + error: '', + }; + + // Second call - existing stats should be retrieved and updated + executionHandler.processExecution( + payload2, + runInstanceMock.object, + testItemIndexMock.object, + testControllerMock.object, + ); + + // Verify getSubtestStats was called to retrieve existing stats + testItemIndexMock.verify((x) => x.getSubtestStats('parentTest'), typemoq.Times.once()); + + // Verify both subtests were processed + runInstanceMock.verify((r) => r.passed(mockSubtest1), typemoq.Times.once()); + runInstanceMock.verify((r) => r.failed(mockSubtest2, typemoq.It.isAny()), typemoq.Times.once()); + }); + + test('should clear children only on first subtest when no existing stats', () => { + // When first subtest arrives, children should be cleared + // Subsequent subtests should NOT clear children + const mockSubtest1 = createMockTestItem('subtest1', 'Subtest 1'); + + testItemIndexMock + .setup((x) => x.getTestItem('parentTest', testControllerMock.object)) + .returns(() => mockParentItem); + testItemIndexMock.setup((x) => x.getSubtestStats('parentTest')).returns(() => undefined); + testItemIndexMock + .setup((x) => x.setSubtestStats('parentTest', typemoq.It.isAny())) + .returns(() => undefined); + testControllerMock + .setup((t) => t.createTestItem(typemoq.It.isAny(), typemoq.It.isAny(), typemoq.It.isAny())) + .returns(() => mockSubtest1); + + const payload: ExecutionTestPayload = { + cwd: '/foo/bar', + status: 'success', + result: { + 'parentTest (subtest1)': { + test: 'parentTest', + outcome: 'subtest-success', + message: '', + traceback: '', + subtest: 'subtest1', + }, + }, + error: '', + }; + + executionHandler.processExecution( + payload, + runInstanceMock.object, + testItemIndexMock.object, + testControllerMock.object, + ); + + // Verify setSubtestStats was called (which happens when creating new stats) + testItemIndexMock.verify((x) => x.setSubtestStats('parentTest', typemoq.It.isAny()), typemoq.Times.once()); + }); + }); +}); + +function createMockTestItem(id: string, label: string): TestItem { + const range = new Range(0, 0, 0, 0); + const mockChildren = typemoq.Mock.ofType(); + mockChildren.setup((x) => x.add(typemoq.It.isAny())).returns(() => undefined); + + const mockTestItem = ({ + id, + label, + canResolveChildren: false, + tags: [], + children: mockChildren.object, + range, + uri: Uri.file('/foo/bar/test.py'), + parent: undefined, + } as unknown) as TestItem; + + return mockTestItem; +} diff --git a/src/test/testing/testController/common/testItemIndex.unit.test.ts b/src/test/testing/testController/common/testItemIndex.unit.test.ts new file mode 100644 index 000000000000..6712d90ff667 --- /dev/null +++ b/src/test/testing/testController/common/testItemIndex.unit.test.ts @@ -0,0 +1,359 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { TestController, TestItem, Uri, Range, TestItemCollection } from 'vscode'; +import * as typemoq from 'typemoq'; +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { TestItemIndex } from '../../../../client/testing/testController/common/testItemIndex'; + +suite('TestItemIndex', () => { + let testItemIndex: TestItemIndex; + let testControllerMock: typemoq.IMock; + let mockTestItem1: TestItem; + let mockTestItem2: TestItem; + let mockParentItem: TestItem; + + setup(() => { + testItemIndex = new TestItemIndex(); + testControllerMock = typemoq.Mock.ofType(); + + // Create mock test items + mockTestItem1 = createMockTestItem('test1', 'Test 1'); + mockTestItem2 = createMockTestItem('test2', 'Test 2'); + mockParentItem = createMockTestItem('parent', 'Parent'); + }); + + teardown(() => { + sinon.restore(); + }); + + suite('registerTestItem', () => { + test('should store all three mappings correctly', () => { + const runId = 'test_file.py::test_example'; + const vsId = 'test_file.py::test_example'; + + testItemIndex.registerTestItem(runId, vsId, mockTestItem1); + + assert.strictEqual(testItemIndex.runIdToTestItemMap.get(runId), mockTestItem1); + assert.strictEqual(testItemIndex.runIdToVSidMap.get(runId), vsId); + assert.strictEqual(testItemIndex.vsIdToRunIdMap.get(vsId), runId); + }); + + test('should overwrite existing mappings', () => { + const runId = 'test_file.py::test_example'; + const vsId = 'test_file.py::test_example'; + + testItemIndex.registerTestItem(runId, vsId, mockTestItem1); + testItemIndex.registerTestItem(runId, vsId, mockTestItem2); + + assert.strictEqual(testItemIndex.runIdToTestItemMap.get(runId), mockTestItem2); + }); + + test('should handle different runId and vsId', () => { + const runId = 'test_file.py::TestClass::test_method'; + const vsId = 'different_id'; + + testItemIndex.registerTestItem(runId, vsId, mockTestItem1); + + assert.strictEqual(testItemIndex.runIdToVSidMap.get(runId), vsId); + assert.strictEqual(testItemIndex.vsIdToRunIdMap.get(vsId), runId); + }); + }); + + suite('getTestItem', () => { + test('should return item on direct lookup when valid', () => { + const runId = 'test_file.py::test_example'; + const vsId = 'test_file.py::test_example'; + + // Register the item + testItemIndex.registerTestItem(runId, vsId, mockTestItem1); + + // Mock the validation to return true + const isValidStub = sinon.stub(testItemIndex, 'isTestItemValid').returns(true); + + const result = testItemIndex.getTestItem(runId, testControllerMock.object); + + assert.strictEqual(result, mockTestItem1); + assert.ok(isValidStub.calledOnce); + }); + + test('should remove stale item and try vsId fallback', () => { + const runId = 'test_file.py::test_example'; + const vsId = 'test_file.py::test_example'; + + testItemIndex.registerTestItem(runId, vsId, mockTestItem1); + + // Mock validation to fail on first call (stale item) + const isValidStub = sinon.stub(testItemIndex, 'isTestItemValid').returns(false); + + // Setup controller to not find the item + const testItemCollectionMock = typemoq.Mock.ofType(); + testItemCollectionMock.setup((x) => x.forEach(typemoq.It.isAny())).returns(() => undefined); + testControllerMock.setup((t) => t.items).returns(() => testItemCollectionMock.object); + + const result = testItemIndex.getTestItem(runId, testControllerMock.object); + + // Should have removed the stale item + assert.strictEqual(testItemIndex.runIdToTestItemMap.get(runId), undefined); + assert.strictEqual(result, undefined); + assert.ok(isValidStub.calledOnce); + }); + + test('should perform vsId search when direct lookup is stale', () => { + const runId = 'test_file.py::test_example'; + const vsId = 'test_file.py::test_example'; + + // Create test item with correct ID + const searchableTestItem = createMockTestItem(vsId, 'Test Example'); + + testItemIndex.registerTestItem(runId, vsId, searchableTestItem); + + // First validation fails (stale), need to search by vsId + sinon.stub(testItemIndex, 'isTestItemValid').returns(false); + + // Setup controller to find item by vsId + const testItemCollectionMock = typemoq.Mock.ofType(); + testItemCollectionMock + .setup((x) => x.forEach(typemoq.It.isAny())) + .callback((callback) => { + callback(searchableTestItem); + }) + .returns(() => undefined); + testControllerMock.setup((t) => t.items).returns(() => testItemCollectionMock.object); + + const result = testItemIndex.getTestItem(runId, testControllerMock.object); + + // Should recache the found item + assert.strictEqual(testItemIndex.runIdToTestItemMap.get(runId), searchableTestItem); + assert.strictEqual(result, searchableTestItem); + }); + + test('should return undefined if not found anywhere', () => { + const runId = 'nonexistent'; + + const testItemCollectionMock = typemoq.Mock.ofType(); + testItemCollectionMock.setup((x) => x.forEach(typemoq.It.isAny())).returns(() => undefined); + testControllerMock.setup((t) => t.items).returns(() => testItemCollectionMock.object); + + const result = testItemIndex.getTestItem(runId, testControllerMock.object); + + assert.strictEqual(result, undefined); + }); + }); + + suite('getRunId and getVSId', () => { + test('getRunId should convert VS Code ID to Python run ID', () => { + const runId = 'test_file.py::test_example'; + const vsId = 'vscode_id'; + + testItemIndex.registerTestItem(runId, vsId, mockTestItem1); + + assert.strictEqual(testItemIndex.getRunId(vsId), runId); + }); + + test('getRunId should return undefined for unknown vsId', () => { + assert.strictEqual(testItemIndex.getRunId('unknown'), undefined); + }); + + test('getVSId should convert Python run ID to VS Code ID', () => { + const runId = 'test_file.py::test_example'; + const vsId = 'vscode_id'; + + testItemIndex.registerTestItem(runId, vsId, mockTestItem1); + + assert.strictEqual(testItemIndex.getVSId(runId), vsId); + }); + + test('getVSId should return undefined for unknown runId', () => { + assert.strictEqual(testItemIndex.getVSId('unknown'), undefined); + }); + }); + + suite('clear', () => { + test('should remove all mappings', () => { + testItemIndex.registerTestItem('runId1', 'vsId1', mockTestItem1); + testItemIndex.registerTestItem('runId2', 'vsId2', mockTestItem2); + + assert.strictEqual(testItemIndex.runIdToTestItemMap.size, 2); + assert.strictEqual(testItemIndex.runIdToVSidMap.size, 2); + assert.strictEqual(testItemIndex.vsIdToRunIdMap.size, 2); + + testItemIndex.clear(); + + assert.strictEqual(testItemIndex.runIdToTestItemMap.size, 0); + assert.strictEqual(testItemIndex.runIdToVSidMap.size, 0); + assert.strictEqual(testItemIndex.vsIdToRunIdMap.size, 0); + }); + + test('should handle clearing empty index', () => { + testItemIndex.clear(); + + assert.strictEqual(testItemIndex.runIdToTestItemMap.size, 0); + assert.strictEqual(testItemIndex.runIdToVSidMap.size, 0); + assert.strictEqual(testItemIndex.vsIdToRunIdMap.size, 0); + }); + }); + + suite('isTestItemValid', () => { + test('should return true for item with valid parent chain leading to controller', () => { + const childItem = createMockTestItem('child', 'Child'); + (childItem as any).parent = mockParentItem; + + const testItemCollectionMock = typemoq.Mock.ofType(); + testItemCollectionMock.setup((x) => x.get(mockParentItem.id)).returns(() => mockParentItem); + testControllerMock.setup((t) => t.items).returns(() => testItemCollectionMock.object); + + const result = testItemIndex.isTestItemValid(childItem, testControllerMock.object); + + assert.strictEqual(result, true); + }); + + test('should return false for orphaned item', () => { + const orphanedItem = createMockTestItem('orphaned', 'Orphaned'); + (orphanedItem as any).parent = mockParentItem; + + const testItemCollectionMock = typemoq.Mock.ofType(); + testItemCollectionMock.setup((x) => x.get(typemoq.It.isAny())).returns(() => undefined); + testControllerMock.setup((t) => t.items).returns(() => testItemCollectionMock.object); + + const result = testItemIndex.isTestItemValid(orphanedItem, testControllerMock.object); + + assert.strictEqual(result, false); + }); + + test('should return true for root item in controller', () => { + const testItemCollectionMock = typemoq.Mock.ofType(); + testItemCollectionMock.setup((x) => x.get(mockTestItem1.id)).returns(() => mockTestItem1); + testControllerMock.setup((t) => t.items).returns(() => testItemCollectionMock.object); + + const result = testItemIndex.isTestItemValid(mockTestItem1, testControllerMock.object); + + assert.strictEqual(result, true); + }); + + test('should return false for item not in controller and no parent', () => { + const testItemCollectionMock = typemoq.Mock.ofType(); + testItemCollectionMock.setup((x) => x.get(typemoq.It.isAny())).returns(() => undefined); + testControllerMock.setup((t) => t.items).returns(() => testItemCollectionMock.object); + + const result = testItemIndex.isTestItemValid(mockTestItem1, testControllerMock.object); + + assert.strictEqual(result, false); + }); + }); + + suite('cleanupStaleReferences', () => { + test('should remove items not in controller', () => { + const runId1 = 'test1'; + const runId2 = 'test2'; + const vsId1 = 'vs1'; + const vsId2 = 'vs2'; + + testItemIndex.registerTestItem(runId1, vsId1, mockTestItem1); + testItemIndex.registerTestItem(runId2, vsId2, mockTestItem2); + + // Mock validation: first item invalid, second valid + const isValidStub = sinon.stub(testItemIndex, 'isTestItemValid'); + isValidStub.onFirstCall().returns(false); // mockTestItem1 is invalid + isValidStub.onSecondCall().returns(true); // mockTestItem2 is valid + + testItemIndex.cleanupStaleReferences(testControllerMock.object); + + // First item should be removed + assert.strictEqual(testItemIndex.runIdToTestItemMap.get(runId1), undefined); + assert.strictEqual(testItemIndex.runIdToVSidMap.get(runId1), undefined); + assert.strictEqual(testItemIndex.vsIdToRunIdMap.get(vsId1), undefined); + + // Second item should remain + assert.strictEqual(testItemIndex.runIdToTestItemMap.get(runId2), mockTestItem2); + assert.strictEqual(testItemIndex.runIdToVSidMap.get(runId2), vsId2); + assert.strictEqual(testItemIndex.vsIdToRunIdMap.get(vsId2), runId2); + }); + + test('should keep all valid items', () => { + const runId1 = 'test1'; + const vsId1 = 'vs1'; + + testItemIndex.registerTestItem(runId1, vsId1, mockTestItem1); + + sinon.stub(testItemIndex, 'isTestItemValid').returns(true); + + testItemIndex.cleanupStaleReferences(testControllerMock.object); + + // Item should still be there + assert.strictEqual(testItemIndex.runIdToTestItemMap.get(runId1), mockTestItem1); + assert.strictEqual(testItemIndex.runIdToVSidMap.get(runId1), vsId1); + assert.strictEqual(testItemIndex.vsIdToRunIdMap.get(vsId1), runId1); + }); + + test('should handle empty index', () => { + testItemIndex.cleanupStaleReferences(testControllerMock.object); + + assert.strictEqual(testItemIndex.runIdToTestItemMap.size, 0); + }); + + test('should remove all items when all are invalid', () => { + testItemIndex.registerTestItem('test1', 'vs1', mockTestItem1); + testItemIndex.registerTestItem('test2', 'vs2', mockTestItem2); + + sinon.stub(testItemIndex, 'isTestItemValid').returns(false); + + testItemIndex.cleanupStaleReferences(testControllerMock.object); + + assert.strictEqual(testItemIndex.runIdToTestItemMap.size, 0); + assert.strictEqual(testItemIndex.runIdToVSidMap.size, 0); + assert.strictEqual(testItemIndex.vsIdToRunIdMap.size, 0); + }); + }); + + suite('Backward compatibility getters', () => { + test('runIdToTestItemMap should return the internal map', () => { + const runId = 'test1'; + testItemIndex.registerTestItem(runId, 'vs1', mockTestItem1); + + const map = testItemIndex.runIdToTestItemMap; + + assert.strictEqual(map.get(runId), mockTestItem1); + }); + + test('runIdToVSidMap should return the internal map', () => { + const runId = 'test1'; + const vsId = 'vs1'; + testItemIndex.registerTestItem(runId, vsId, mockTestItem1); + + const map = testItemIndex.runIdToVSidMap; + + assert.strictEqual(map.get(runId), vsId); + }); + + test('vsIdToRunIdMap should return the internal map', () => { + const runId = 'test1'; + const vsId = 'vs1'; + testItemIndex.registerTestItem(runId, vsId, mockTestItem1); + + const map = testItemIndex.vsIdToRunIdMap; + + assert.strictEqual(map.get(vsId), runId); + }); + }); +}); + +function createMockTestItem(id: string, label: string): TestItem { + const range = new Range(0, 0, 0, 0); + const mockChildren = typemoq.Mock.ofType(); + mockChildren.setup((x) => x.add(typemoq.It.isAny())).returns(() => undefined); + + const mockTestItem = ({ + id, + label, + canResolveChildren: false, + tags: [], + children: mockChildren.object, + range, + uri: Uri.file('/foo/bar'), + parent: undefined, + } as unknown) as TestItem; + + return mockTestItem; +} diff --git a/src/test/testing/testController/common/testProjectRegistry.unit.test.ts b/src/test/testing/testController/common/testProjectRegistry.unit.test.ts new file mode 100644 index 000000000000..5d04930d0e88 --- /dev/null +++ b/src/test/testing/testController/common/testProjectRegistry.unit.test.ts @@ -0,0 +1,440 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { expect } from 'chai'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { TestController, Uri } from 'vscode'; +import { IConfigurationService } from '../../../../client/common/types'; +import { IEnvironmentVariablesProvider } from '../../../../client/common/variables/types'; +import { IInterpreterService } from '../../../../client/interpreter/contracts'; +import { TestProjectRegistry } from '../../../../client/testing/testController/common/testProjectRegistry'; +import * as envExtApiInternal from '../../../../client/envExt/api.internal'; +import { PythonProject, PythonEnvironment } from '../../../../client/envExt/types'; + +suite('TestProjectRegistry', () => { + let sandbox: sinon.SinonSandbox; + let testController: TestController; + let configSettings: IConfigurationService; + let interpreterService: IInterpreterService; + let envVarsService: IEnvironmentVariablesProvider; + let registry: TestProjectRegistry; + + setup(() => { + sandbox = sinon.createSandbox(); + + // Create mock test controller + testController = ({ + items: { + get: sandbox.stub(), + add: sandbox.stub(), + delete: sandbox.stub(), + forEach: sandbox.stub(), + }, + createTestItem: sandbox.stub(), + dispose: sandbox.stub(), + } as unknown) as TestController; + + // Create mock config settings + configSettings = ({ + getSettings: sandbox.stub().returns({ + testing: { + pytestEnabled: true, + unittestEnabled: false, + }, + }), + } as unknown) as IConfigurationService; + + // Create mock interpreter service + interpreterService = ({ + getActiveInterpreter: sandbox.stub().resolves({ + displayName: 'Python 3.11', + path: '/usr/bin/python3', + version: { raw: '3.11.8' }, + sysPrefix: '/usr', + }), + } as unknown) as IInterpreterService; + + // Create mock env vars service + envVarsService = ({ + getEnvironmentVariables: sandbox.stub().resolves({}), + } as unknown) as IEnvironmentVariablesProvider; + + registry = new TestProjectRegistry(testController, configSettings, interpreterService, envVarsService); + }); + + teardown(() => { + sandbox.restore(); + }); + + suite('hasProjects', () => { + test('should return false for uninitialized workspace', () => { + const workspaceUri = Uri.file('/workspace'); + + const result = registry.hasProjects(workspaceUri); + + expect(result).to.be.false; + }); + + test('should return true after projects are registered', async () => { + const workspaceUri = Uri.file('/workspace'); + + // Mock useEnvExtension to return false to use default project path + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); + + await registry.discoverAndRegisterProjects(workspaceUri); + + const result = registry.hasProjects(workspaceUri); + + expect(result).to.be.true; + }); + }); + + suite('getProjectsArray', () => { + test('should return empty array for uninitialized workspace', () => { + const workspaceUri = Uri.file('/workspace'); + + const result = registry.getProjectsArray(workspaceUri); + + expect(result).to.be.an('array').that.is.empty; + }); + + test('should return projects after registration', async () => { + const workspaceUri = Uri.file('/workspace'); + + // Mock useEnvExtension to return false to use default project path + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); + + await registry.discoverAndRegisterProjects(workspaceUri); + + const result = registry.getProjectsArray(workspaceUri); + + expect(result).to.be.an('array').with.length(1); + expect(result[0].projectUri.fsPath).to.equal(workspaceUri.fsPath); + }); + }); + + suite('discoverAndRegisterProjects', () => { + test('should create default project when env extension not available', async () => { + const workspaceUri = Uri.file('/workspace/myproject'); + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); + + const projects = await registry.discoverAndRegisterProjects(workspaceUri); + + expect(projects).to.have.length(1); + expect(projects[0].projectUri.fsPath).to.equal(workspaceUri.fsPath); + expect(projects[0].testProvider).to.equal('pytest'); + }); + + test('should use unittest when configured', async () => { + const workspaceUri = Uri.file('/workspace/myproject'); + + (configSettings.getSettings as sinon.SinonStub).returns({ + testing: { + pytestEnabled: false, + unittestEnabled: true, + }, + }); + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); + + const projects = await registry.discoverAndRegisterProjects(workspaceUri); + + expect(projects).to.have.length(1); + expect(projects[0].testProvider).to.equal('unittest'); + }); + + test('should discover projects from Python Environments API', async () => { + const workspaceUri = Uri.file('/workspace'); + const projectUri = Uri.file('/workspace/project1'); + + const mockPythonProject: PythonProject = { + name: 'project1', + uri: projectUri, + }; + + const mockPythonEnv: PythonEnvironment = { + name: 'env1', + displayName: 'Python 3.11', + shortDisplayName: 'Python 3.11', + displayPath: '/usr/bin/python3', + version: '3.11.8', + environmentPath: Uri.file('/usr/bin/python3'), + sysPrefix: '/usr', + execInfo: { run: { executable: '/usr/bin/python3' } }, + envId: { id: 'env1', managerId: 'manager1' }, + }; + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); + sandbox.stub(envExtApiInternal, 'getEnvExtApi').resolves({ + getPythonProjects: () => [mockPythonProject], + getEnvironment: sandbox.stub().resolves(mockPythonEnv), + } as any); + + const projects = await registry.discoverAndRegisterProjects(workspaceUri); + + expect(projects).to.have.length(1); + expect(projects[0].projectName).to.include('project1'); + expect(projects[0].pythonEnvironment).to.deep.equal(mockPythonEnv); + }); + + test('should filter projects to current workspace', async () => { + const workspaceUri = Uri.file('/workspace1'); + const projectInWorkspace = Uri.file('/workspace1/project1'); + const projectOutsideWorkspace = Uri.file('/workspace2/project2'); + + const mockProjects: PythonProject[] = [ + { name: 'project1', uri: projectInWorkspace }, + { name: 'project2', uri: projectOutsideWorkspace }, + ]; + + const mockPythonEnv: PythonEnvironment = { + name: 'env1', + displayName: 'Python 3.11', + shortDisplayName: 'Python 3.11', + displayPath: '/usr/bin/python3', + version: '3.11.8', + environmentPath: Uri.file('/usr/bin/python3'), + sysPrefix: '/usr', + execInfo: { run: { executable: '/usr/bin/python3' } }, + envId: { id: 'env1', managerId: 'manager1' }, + }; + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); + sandbox.stub(envExtApiInternal, 'getEnvExtApi').resolves({ + getPythonProjects: () => mockProjects, + getEnvironment: sandbox.stub().resolves(mockPythonEnv), + } as any); + + const projects = await registry.discoverAndRegisterProjects(workspaceUri); + + expect(projects).to.have.length(1); + expect(projects[0].projectUri.fsPath).to.equal(projectInWorkspace.fsPath); + }); + + test('should fallback to default project when no projects found', async () => { + const workspaceUri = Uri.file('/workspace'); + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); + sandbox.stub(envExtApiInternal, 'getEnvExtApi').resolves({ + getPythonProjects: () => [], + } as any); + + const projects = await registry.discoverAndRegisterProjects(workspaceUri); + + expect(projects).to.have.length(1); + expect(projects[0].projectUri.fsPath).to.equal(workspaceUri.fsPath); + }); + + test('should fallback to default project on API error', async () => { + const workspaceUri = Uri.file('/workspace'); + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); + sandbox.stub(envExtApiInternal, 'getEnvExtApi').rejects(new Error('API error')); + + const projects = await registry.discoverAndRegisterProjects(workspaceUri); + + expect(projects).to.have.length(1); + expect(projects[0].projectUri.fsPath).to.equal(workspaceUri.fsPath); + }); + }); + + suite('configureNestedProjectIgnores', () => { + test('should not set ignores when no nested projects', async () => { + const workspaceUri = Uri.file('/workspace'); + const projectUri = Uri.file('/workspace/project1'); + + const mockPythonProject: PythonProject = { + name: 'project1', + uri: projectUri, + }; + + const mockPythonEnv: PythonEnvironment = { + name: 'env1', + displayName: 'Python 3.11', + shortDisplayName: 'Python 3.11', + displayPath: '/usr/bin/python3', + version: '3.11.8', + environmentPath: Uri.file('/usr/bin/python3'), + sysPrefix: '/usr', + execInfo: { run: { executable: '/usr/bin/python3' } }, + envId: { id: 'env1', managerId: 'manager1' }, + }; + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); + sandbox.stub(envExtApiInternal, 'getEnvExtApi').resolves({ + getPythonProjects: () => [mockPythonProject], + getEnvironment: sandbox.stub().resolves(mockPythonEnv), + } as any); + + await registry.discoverAndRegisterProjects(workspaceUri); + registry.configureNestedProjectIgnores(workspaceUri); + + const projects = registry.getProjectsArray(workspaceUri); + expect(projects[0].nestedProjectPathsToIgnore).to.be.undefined; + }); + + test('should configure ignore paths for nested projects', async () => { + const workspaceUri = Uri.file('/workspace'); + const parentProjectUri = Uri.file('/workspace/parent'); + const childProjectUri = Uri.file(path.join('/workspace/parent', 'child')); + + const mockProjects: PythonProject[] = [ + { name: 'parent', uri: parentProjectUri }, + { name: 'child', uri: childProjectUri }, + ]; + + const mockPythonEnv: PythonEnvironment = { + name: 'env1', + displayName: 'Python 3.11', + shortDisplayName: 'Python 3.11', + displayPath: '/usr/bin/python3', + version: '3.11.8', + environmentPath: Uri.file('/usr/bin/python3'), + sysPrefix: '/usr', + execInfo: { run: { executable: '/usr/bin/python3' } }, + envId: { id: 'env1', managerId: 'manager1' }, + }; + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); + sandbox.stub(envExtApiInternal, 'getEnvExtApi').resolves({ + getPythonProjects: () => mockProjects, + getEnvironment: sandbox.stub().resolves(mockPythonEnv), + } as any); + + await registry.discoverAndRegisterProjects(workspaceUri); + registry.configureNestedProjectIgnores(workspaceUri); + + const projects = registry.getProjectsArray(workspaceUri); + const parentProject = projects.find((p) => p.projectUri.fsPath === parentProjectUri.fsPath); + + expect(parentProject?.nestedProjectPathsToIgnore).to.include(childProjectUri.fsPath); + }); + + test('should not set child project as ignored for sibling projects', async () => { + const workspaceUri = Uri.file('/workspace'); + const project1Uri = Uri.file('/workspace/project1'); + const project2Uri = Uri.file('/workspace/project2'); + + const mockProjects: PythonProject[] = [ + { name: 'project1', uri: project1Uri }, + { name: 'project2', uri: project2Uri }, + ]; + + const mockPythonEnv: PythonEnvironment = { + name: 'env1', + displayName: 'Python 3.11', + shortDisplayName: 'Python 3.11', + displayPath: '/usr/bin/python3', + version: '3.11.8', + environmentPath: Uri.file('/usr/bin/python3'), + sysPrefix: '/usr', + execInfo: { run: { executable: '/usr/bin/python3' } }, + envId: { id: 'env1', managerId: 'manager1' }, + }; + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); + sandbox.stub(envExtApiInternal, 'getEnvExtApi').resolves({ + getPythonProjects: () => mockProjects, + getEnvironment: sandbox.stub().resolves(mockPythonEnv), + } as any); + + await registry.discoverAndRegisterProjects(workspaceUri); + registry.configureNestedProjectIgnores(workspaceUri); + + const projects = registry.getProjectsArray(workspaceUri); + projects.forEach((project) => { + expect(project.nestedProjectPathsToIgnore).to.be.undefined; + }); + }); + }); + + suite('clearWorkspace', () => { + test('should remove all projects for a workspace', async () => { + const workspaceUri = Uri.file('/workspace'); + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); + + await registry.discoverAndRegisterProjects(workspaceUri); + expect(registry.hasProjects(workspaceUri)).to.be.true; + + registry.clearWorkspace(workspaceUri); + + expect(registry.hasProjects(workspaceUri)).to.be.false; + expect(registry.getProjectsArray(workspaceUri)).to.be.empty; + }); + + test('should not affect other workspaces', async () => { + const workspace1Uri = Uri.file('/workspace1'); + const workspace2Uri = Uri.file('/workspace2'); + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); + + await registry.discoverAndRegisterProjects(workspace1Uri); + await registry.discoverAndRegisterProjects(workspace2Uri); + + registry.clearWorkspace(workspace1Uri); + + expect(registry.hasProjects(workspace1Uri)).to.be.false; + expect(registry.hasProjects(workspace2Uri)).to.be.true; + }); + }); + + suite('getWorkspaceProjects', () => { + test('should return undefined for uninitialized workspace', () => { + const workspaceUri = Uri.file('/workspace'); + + const result = registry.getWorkspaceProjects(workspaceUri); + + expect(result).to.be.undefined; + }); + + test('should return map after registration', async () => { + const workspaceUri = Uri.file('/workspace'); + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); + + await registry.discoverAndRegisterProjects(workspaceUri); + + const result = registry.getWorkspaceProjects(workspaceUri); + + expect(result).to.be.instanceOf(Map); + expect(result?.size).to.equal(1); + }); + }); + + suite('ProjectAdapter properties', () => { + test('should create adapter with correct test infrastructure', async () => { + const workspaceUri = Uri.file('/workspace/myproject'); + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); + + const projects = await registry.discoverAndRegisterProjects(workspaceUri); + const project = projects[0]; + + expect(project.projectName).to.be.a('string'); + expect(project.projectUri.fsPath).to.equal(workspaceUri.fsPath); + expect(project.workspaceUri.fsPath).to.equal(workspaceUri.fsPath); + expect(project.testProvider).to.equal('pytest'); + expect(project.discoveryAdapter).to.exist; + expect(project.executionAdapter).to.exist; + expect(project.resultResolver).to.exist; + expect(project.isDiscovering).to.be.false; + expect(project.isExecuting).to.be.false; + }); + + test('should include python environment details', async () => { + const workspaceUri = Uri.file('/workspace/myproject'); + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); + + const projects = await registry.discoverAndRegisterProjects(workspaceUri); + const project = projects[0]; + + expect(project.pythonEnvironment).to.exist; + expect(project.pythonProject).to.exist; + expect(project.pythonProject.name).to.equal('myproject'); + }); + }); +}); diff --git a/src/test/testing/testController/controller.unit.test.ts b/src/test/testing/testController/controller.unit.test.ts new file mode 100644 index 000000000000..feb5f36fc797 --- /dev/null +++ b/src/test/testing/testController/controller.unit.test.ts @@ -0,0 +1,344 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { TestController, Uri } from 'vscode'; + +import { PYTEST_PROVIDER, UNITTEST_PROVIDER } from '../../../client/testing/common/constants'; +import * as envExtApiInternal from '../../../client/envExt/api.internal'; +import * as projectUtils from '../../../client/testing/testController/common/projectUtils'; +import { PythonTestController } from '../../../client/testing/testController/controller'; +import { TestProjectRegistry } from '../../../client/testing/testController/common/testProjectRegistry'; + +function createStubTestController(): TestController { + const disposable = { dispose: () => undefined }; + + const controller = ({ + items: { + forEach: sinon.stub(), + get: sinon.stub(), + add: sinon.stub(), + replace: sinon.stub(), + delete: sinon.stub(), + size: 0, + [Symbol.iterator]: sinon.stub(), + }, + createRunProfile: sinon.stub().returns(disposable), + createTestItem: sinon.stub(), + dispose: sinon.stub(), + resolveHandler: undefined, + refreshHandler: undefined, + } as unknown) as TestController; + + return controller; +} + +suite('PythonTestController', () => { + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + }); + + teardown(() => { + sandbox.restore(); + }); + + function createController(options?: { unittestEnabled?: boolean; interpreter?: any }): any { + const unittestEnabled = options?.unittestEnabled ?? false; + const interpreter = + options?.interpreter ?? + ({ + displayName: 'Python 3.11', + path: '/usr/bin/python3', + version: { raw: '3.11.8' }, + sysPrefix: '/usr', + } as any); + + const workspaceService = ({ workspaceFolders: [] } as unknown) as any; + const configSettings = ({ + getSettings: sandbox.stub().returns({ + testing: { + unittestEnabled, + autoTestDiscoverOnSaveEnabled: false, + }, + }), + } as unknown) as any; + + const pytest = ({} as unknown) as any; + const unittest = ({} as unknown) as any; + const disposables: any[] = []; + const interpreterService = ({ + getActiveInterpreter: sandbox.stub().resolves(interpreter), + } as unknown) as any; + + const commandManager = ({ + registerCommand: sandbox.stub().returns({ dispose: () => undefined }), + } as unknown) as any; + const pythonExecFactory = ({} as unknown) as any; + const debugLauncher = ({} as unknown) as any; + const envVarsService = ({} as unknown) as any; + + return new PythonTestController( + workspaceService, + configSettings, + pytest, + unittest, + disposables, + interpreterService, + commandManager, + pythonExecFactory, + debugLauncher, + envVarsService, + ); + } + + suite('getTestProvider', () => { + test('returns unittest when enabled', () => { + const controller = createController({ unittestEnabled: true }); + const workspaceUri: Uri = vscode.Uri.file('/workspace'); + + const provider = (controller as any).getTestProvider(workspaceUri); + + assert.strictEqual(provider, UNITTEST_PROVIDER); + }); + + test('returns pytest when unittest not enabled', () => { + const controller = createController({ unittestEnabled: false }); + const workspaceUri: Uri = vscode.Uri.file('/workspace'); + + const provider = (controller as any).getTestProvider(workspaceUri); + + assert.strictEqual(provider, PYTEST_PROVIDER); + }); + }); + + suite('createDefaultProject (via TestProjectRegistry)', () => { + test('creates a single default project using active interpreter', async () => { + const workspaceUri: Uri = vscode.Uri.file('/workspace/myws'); + const interpreter = { + displayName: 'My Python', + path: '/opt/py/bin/python', + version: { raw: '3.12.1' }, + sysPrefix: '/opt/py', + }; + + const fakeDiscoveryAdapter = { kind: 'discovery' }; + const fakeExecutionAdapter = { kind: 'execution' }; + sandbox.stub(projectUtils, 'createTestAdapters').returns({ + discoveryAdapter: fakeDiscoveryAdapter, + executionAdapter: fakeExecutionAdapter, + } as any); + + // Stub useEnvExtension to return false so createDefaultProject is called + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); + + const interpreterService = { + getActiveInterpreter: sandbox.stub().resolves(interpreter), + } as any; + + const configSettings = { + getSettings: sandbox.stub().returns({ + testing: { unittestEnabled: false }, + }), + } as any; + + const testController = createStubTestController(); + const envVarsService = {} as any; + + const registry = new TestProjectRegistry( + testController, + configSettings, + interpreterService, + envVarsService, + ); + + const projects = await registry.discoverAndRegisterProjects(workspaceUri); + const project = projects[0]; + + assert.strictEqual(projects.length, 1); + assert.strictEqual(project.workspaceUri.toString(), workspaceUri.toString()); + assert.strictEqual(project.projectUri.toString(), workspaceUri.toString()); + assert.strictEqual(project.projectName, 'myws'); + + assert.strictEqual(project.testProvider, PYTEST_PROVIDER); + assert.strictEqual(project.discoveryAdapter, fakeDiscoveryAdapter); + assert.strictEqual(project.executionAdapter, fakeExecutionAdapter); + + assert.strictEqual(project.pythonProject.uri.toString(), workspaceUri.toString()); + assert.strictEqual(project.pythonProject.name, 'myws'); + + assert.strictEqual(project.pythonEnvironment.displayName, 'My Python'); + assert.strictEqual(project.pythonEnvironment.version, '3.12.1'); + assert.strictEqual(project.pythonEnvironment.execInfo.run.executable, '/opt/py/bin/python'); + }); + }); + + suite('discoverWorkspaceProjects (via TestProjectRegistry)', () => { + test('respects useEnvExtension() == false and falls back to single default project', async () => { + const workspaceUri: Uri = vscode.Uri.file('/workspace/a'); + + const useEnvExtensionStub = sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(false); + const getEnvExtApiStub = sandbox.stub(envExtApiInternal, 'getEnvExtApi'); + + const fakeDiscoveryAdapter = { kind: 'discovery' }; + const fakeExecutionAdapter = { kind: 'execution' }; + sandbox.stub(projectUtils, 'createTestAdapters').returns({ + discoveryAdapter: fakeDiscoveryAdapter, + executionAdapter: fakeExecutionAdapter, + } as any); + + const interpreterService = { + getActiveInterpreter: sandbox.stub().resolves({ + displayName: 'Python 3.11', + path: '/usr/bin/python3', + version: { raw: '3.11.8' }, + sysPrefix: '/usr', + }), + } as any; + + const configSettings = { + getSettings: sandbox.stub().returns({ + testing: { unittestEnabled: false }, + }), + } as any; + + const testController = createStubTestController(); + const envVarsService = {} as any; + + const registry = new TestProjectRegistry( + testController, + configSettings, + interpreterService, + envVarsService, + ); + + const projects = await registry.discoverAndRegisterProjects(workspaceUri); + + assert.strictEqual(useEnvExtensionStub.called, true); + assert.strictEqual(getEnvExtApiStub.notCalled, true); + assert.strictEqual(projects.length, 1); + assert.strictEqual(projects[0].projectUri.toString(), workspaceUri.toString()); + }); + + test('filters Python projects to workspace and creates adapters for each', async () => { + const workspaceUri: Uri = vscode.Uri.file('/workspace/root'); + + const pythonProjects = [ + { name: 'p1', uri: vscode.Uri.file('/workspace/root/p1') }, + { name: 'p2', uri: vscode.Uri.file('/workspace/root/nested/p2') }, + { name: 'other', uri: vscode.Uri.file('/other/root/p3') }, + ]; + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); + sandbox.stub(envExtApiInternal, 'getEnvExtApi').resolves({ + getPythonProjects: () => pythonProjects, + getEnvironment: sandbox.stub().resolves({ + name: 'env', + displayName: 'Python 3.11', + shortDisplayName: 'Python 3.11', + displayPath: '/usr/bin/python3', + version: '3.11.8', + environmentPath: vscode.Uri.file('/usr/bin/python3'), + sysPrefix: '/usr', + execInfo: { run: { executable: '/usr/bin/python3' } }, + envId: { id: 'test', managerId: 'test' }, + }), + } as any); + + const fakeDiscoveryAdapter = { kind: 'discovery' }; + const fakeExecutionAdapter = { kind: 'execution' }; + sandbox.stub(projectUtils, 'createTestAdapters').returns({ + discoveryAdapter: fakeDiscoveryAdapter, + executionAdapter: fakeExecutionAdapter, + } as any); + + const interpreterService = { + getActiveInterpreter: sandbox.stub().resolves(null), + } as any; + + const configSettings = { + getSettings: sandbox.stub().returns({ + testing: { unittestEnabled: false }, + }), + } as any; + + const testController = createStubTestController(); + const envVarsService = {} as any; + + const registry = new TestProjectRegistry( + testController, + configSettings, + interpreterService, + envVarsService, + ); + + const projects = await registry.discoverAndRegisterProjects(workspaceUri); + + // Should only create adapters for the 2 projects in the workspace (not 'other') + assert.strictEqual(projects.length, 2); + const projectUris = projects.map((p: { projectUri: { fsPath: string } }) => p.projectUri.fsPath); + const expectedInWorkspace = [ + vscode.Uri.file('/workspace/root/p1').fsPath, + vscode.Uri.file('/workspace/root/nested/p2').fsPath, + ]; + const expectedOutOfWorkspace = vscode.Uri.file('/other/root/p3').fsPath; + + expectedInWorkspace.forEach((expectedPath) => { + assert.ok(projectUris.includes(expectedPath)); + }); + assert.ok(!projectUris.includes(expectedOutOfWorkspace)); + }); + + test('falls back to default project when no projects are in the workspace', async () => { + const workspaceUri: Uri = vscode.Uri.file('/workspace/root'); + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); + sandbox.stub(envExtApiInternal, 'getEnvExtApi').resolves({ + getPythonProjects: () => [{ name: 'other', uri: vscode.Uri.file('/other/root/p3') }], + } as any); + + const fakeDiscoveryAdapter = { kind: 'discovery' }; + const fakeExecutionAdapter = { kind: 'execution' }; + sandbox.stub(projectUtils, 'createTestAdapters').returns({ + discoveryAdapter: fakeDiscoveryAdapter, + executionAdapter: fakeExecutionAdapter, + } as any); + + const interpreter = { + displayName: 'Python 3.11', + path: '/usr/bin/python3', + version: { raw: '3.11.8' }, + sysPrefix: '/usr', + }; + + const interpreterService = { + getActiveInterpreter: sandbox.stub().resolves(interpreter), + } as any; + + const configSettings = { + getSettings: sandbox.stub().returns({ + testing: { unittestEnabled: false }, + }), + } as any; + + const testController = createStubTestController(); + const envVarsService = {} as any; + + const registry = new TestProjectRegistry( + testController, + configSettings, + interpreterService, + envVarsService, + ); + + const projects = await registry.discoverAndRegisterProjects(workspaceUri); + + // Should fall back to default project since no projects are in the workspace + assert.strictEqual(projects.length, 1); + assert.strictEqual(projects[0].projectUri.toString(), workspaceUri.toString()); + }); + }); +}); diff --git a/src/test/testing/testController/pytest/pytestExecutionAdapter.unit.test.ts b/src/test/testing/testController/pytest/pytestExecutionAdapter.unit.test.ts index e0401edc7b41..40c701b22641 100644 --- a/src/test/testing/testController/pytest/pytestExecutionAdapter.unit.test.ts +++ b/src/test/testing/testController/pytest/pytestExecutionAdapter.unit.test.ts @@ -22,6 +22,7 @@ import { EXTENSION_ROOT_DIR } from '../../../../client/constants'; import { MockChildProcess } from '../../../mocks/mockChildProcess'; import { traceInfo } from '../../../../client/logging'; import * as extapi from '../../../../client/envExt/api.internal'; +import { createMockProjectAdapter } from '../testMocks'; suite('pytest test execution adapter', () => { let useEnvExtensionStub: sinon.SinonStub; @@ -325,4 +326,210 @@ suite('pytest test execution adapter', () => { typeMoq.Times.once(), ); }); + + // ===== PROJECT-BASED EXECUTION TESTS ===== + + suite('project-based execution', () => { + test('should set PROJECT_ROOT_PATH env var when project provided', async () => { + const deferred2 = createDeferred(); + const deferred3 = createDeferred(); + execFactory = typeMoq.Mock.ofType(); + execFactory + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) + .returns(() => { + deferred2.resolve(); + return Promise.resolve(execService.object); + }); + utilsWriteTestIdsFileStub.callsFake(() => { + deferred3.resolve(); + return Promise.resolve('testIdPipe-mockName'); + }); + const testRun = typeMoq.Mock.ofType(); + testRun.setup((t) => t.token).returns(() => ({ onCancellationRequested: () => undefined } as any)); + + const projectPath = path.join('/', 'workspace', 'myproject'); + const mockProject = createMockProjectAdapter({ + projectPath, + projectName: 'myproject', + pythonPath: '/custom/python/path', + }); + + const uri = Uri.file(myTestPath); + adapter = new PytestTestExecutionAdapter(configService); + adapter.runTests( + uri, + [], + TestRunProfileKind.Run, + testRun.object, + execFactory.object, + undefined, + undefined, + mockProject, + ); + + await deferred2.promise; + await deferred3.promise; + await deferred4.promise; + mockProc.trigger('close'); + + execService.verify( + (x) => + x.execObservable( + typeMoq.It.isAny(), + typeMoq.It.is((options) => { + assert.equal(options.env?.PROJECT_ROOT_PATH, projectPath); + return true; + }), + ), + typeMoq.Times.once(), + ); + }); + + test('should pass debugSessionName in LaunchOptions for debug mode with project', async () => { + const deferred3 = createDeferred(); + utilsWriteTestIdsFileStub.callsFake(() => Promise.resolve('testIdPipe-mockName')); + + debugLauncher + .setup((dl) => dl.launchDebugger(typeMoq.It.isAny(), typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns(async (_opts, callback) => { + traceInfo('stubs launch debugger'); + if (typeof callback === 'function') { + deferred3.resolve(); + callback(); + } + }); + + const testRun = typeMoq.Mock.ofType(); + testRun + .setup((t) => t.token) + .returns( + () => + ({ + onCancellationRequested: () => undefined, + } as any), + ); + + const projectPath = path.join('/', 'workspace', 'myproject'); + const mockProject = createMockProjectAdapter({ + projectPath, + projectName: 'myproject (Python 3.11)', + pythonPath: '/custom/python/path', + }); + + const uri = Uri.file(myTestPath); + adapter = new PytestTestExecutionAdapter(configService); + adapter.runTests( + uri, + [], + TestRunProfileKind.Debug, + testRun.object, + execFactory.object, + debugLauncher.object, + undefined, + mockProject, + ); + + await deferred3.promise; + + debugLauncher.verify( + (x) => + x.launchDebugger( + typeMoq.It.is((launchOptions) => { + // Project should be passed for project-based debugging + assert.ok(launchOptions.project, 'project should be defined'); + assert.equal(launchOptions.project?.name, 'myproject (Python 3.11)'); + assert.equal(launchOptions.project?.uri.fsPath, projectPath); + return true; + }), + typeMoq.It.isAny(), + typeMoq.It.isAny(), + ), + typeMoq.Times.once(), + ); + }); + + test('should not set PROJECT_ROOT_PATH when no project provided', async () => { + const deferred2 = createDeferred(); + const deferred3 = createDeferred(); + execFactory = typeMoq.Mock.ofType(); + execFactory + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) + .returns(() => { + deferred2.resolve(); + return Promise.resolve(execService.object); + }); + utilsWriteTestIdsFileStub.callsFake(() => { + deferred3.resolve(); + return Promise.resolve('testIdPipe-mockName'); + }); + const testRun = typeMoq.Mock.ofType(); + testRun.setup((t) => t.token).returns(() => ({ onCancellationRequested: () => undefined } as any)); + + const uri = Uri.file(myTestPath); + adapter = new PytestTestExecutionAdapter(configService); + // Call without project parameter + adapter.runTests(uri, [], TestRunProfileKind.Run, testRun.object, execFactory.object); + + await deferred2.promise; + await deferred3.promise; + await deferred4.promise; + mockProc.trigger('close'); + + execService.verify( + (x) => + x.execObservable( + typeMoq.It.isAny(), + typeMoq.It.is((options) => { + assert.equal(options.env?.PROJECT_ROOT_PATH, undefined); + return true; + }), + ), + typeMoq.Times.once(), + ); + }); + + test('should not set project in LaunchOptions when no project provided', async () => { + const deferred3 = createDeferred(); + utilsWriteTestIdsFileStub.callsFake(() => Promise.resolve('testIdPipe-mockName')); + + debugLauncher + .setup((dl) => dl.launchDebugger(typeMoq.It.isAny(), typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns(async (_opts, callback) => { + if (typeof callback === 'function') { + deferred3.resolve(); + callback(); + } + }); + + const testRun = typeMoq.Mock.ofType(); + testRun.setup((t) => t.token).returns(() => ({ onCancellationRequested: () => undefined } as any)); + + const uri = Uri.file(myTestPath); + adapter = new PytestTestExecutionAdapter(configService); + // Call without project parameter + adapter.runTests( + uri, + [], + TestRunProfileKind.Debug, + testRun.object, + execFactory.object, + debugLauncher.object, + ); + + await deferred3.promise; + + debugLauncher.verify( + (x) => + x.launchDebugger( + typeMoq.It.is((launchOptions) => { + assert.equal(launchOptions.project, undefined); + return true; + }), + typeMoq.It.isAny(), + typeMoq.It.isAny(), + ), + typeMoq.Times.once(), + ); + }); + }); }); diff --git a/src/test/testing/testController/resultResolver.unit.test.ts b/src/test/testing/testController/resultResolver.unit.test.ts index 05d2ee1dd0f3..e4b350a20750 100644 --- a/src/test/testing/testController/resultResolver.unit.test.ts +++ b/src/test/testing/testController/resultResolver.unit.test.ts @@ -93,12 +93,13 @@ suite('Result Resolver tests', () => { // assert the stub functions were called with the correct parameters // header of populateTestTree is (testController: TestController, testTreeData: DiscoveredTestNode, testRoot: TestItem | undefined, resultResolver: ITestResultResolver, token?: CancellationToken) + // After refactor, an inline object with testItemIndex maps is passed instead of resultResolver sinon.assert.calledWithMatch( populateTestTreeStub, testController, // testController tests, // testTreeData undefined, // testRoot - resultResolver, // resultResolver + sinon.match.has('runIdToTestItem'), // inline object with maps cancelationToken, // token ); }); @@ -182,12 +183,13 @@ suite('Result Resolver tests', () => { sinon.assert.calledWithMatch(createErrorTestItemStub, sinon.match.any, sinon.match.any); // also calls populateTestTree with the discovery test results + // After refactor, an inline object with testItemIndex maps is passed instead of resultResolver sinon.assert.calledWithMatch( populateTestTreeStub, testController, // testController tests, // testTreeData undefined, // testRoot - resultResolver, // resultResolver + sinon.match.has('runIdToTestItem'), // inline object with maps cancelationToken, // token ); }); @@ -327,6 +329,34 @@ suite('Result Resolver tests', () => { sinon.stub(testItemUtilities, 'clearAllChildren').callsFake(() => undefined); testProvider = 'unittest'; workspaceUri = Uri.file('/foo/bar'); + + // Create parent test item with correct ID + const mockParentItem = createMockTestItem('parentTest'); + + // Update testControllerMock to include parent item in its collection + const mockTestItems: [string, TestItem][] = [ + ['1', mockTestItem1], + ['2', mockTestItem2], + ['parentTest', mockParentItem], + ]; + const iterableMock = mockTestItems[Symbol.iterator](); + + const testItemCollectionMock = typemoq.Mock.ofType(); + testItemCollectionMock + .setup((x) => x.forEach(typemoq.It.isAny())) + .callback((callback) => { + let result = iterableMock.next(); + while (!result.done) { + callback(result.value[1]); + result = iterableMock.next(); + } + }) + .returns(() => mockTestItem1); + testItemCollectionMock.setup((x) => x.get('parentTest')).returns(() => mockParentItem); + + testControllerMock.reset(); + testControllerMock.setup((t) => t.items).returns(() => testItemCollectionMock.object); + resultResolver = new ResultResolver.PythonResultResolver( testControllerMock.object, testProvider, @@ -334,13 +364,16 @@ suite('Result Resolver tests', () => { ); const subtestName = 'parentTest [subTest with spaces and [brackets]]'; const mockSubtestItem = createMockTestItem(subtestName); + // add a mock test item to the map of known VSCode ids to run ids resultResolver.runIdToVSid.set('mockTestItem2', 'mockTestItem2'); // creates a mock test item with a space which will be used to split the runId resultResolver.runIdToVSid.set(subtestName, subtestName); + // Register parent test in testItemIndex so it can be found by getTestItem + resultResolver.runIdToVSid.set('parentTest', 'parentTest'); // add this mock test to the map of known test items - resultResolver.runIdToTestItem.set('parentTest', mockTestItem2); + resultResolver.runIdToTestItem.set('parentTest', mockParentItem); resultResolver.runIdToTestItem.set(subtestName, mockSubtestItem); let generatedId: string | undefined; @@ -563,15 +596,15 @@ suite('Result Resolver tests', () => { function createMockTestItem(id: string): TestItem { const range = new Range(0, 0, 0, 0); + const mockChildren = typemoq.Mock.ofType(); + mockChildren.setup((x) => x.add(typemoq.It.isAny())).returns(() => undefined); + mockChildren.setup((x) => x.forEach(typemoq.It.isAny())).returns(() => undefined); + const mockTestItem = ({ id, canResolveChildren: false, tags: [], - children: { - add: () => { - // empty - }, - }, + children: mockChildren.object, range, uri: Uri.file('/foo/bar'), } as unknown) as TestItem; diff --git a/src/test/testing/testController/testMocks.ts b/src/test/testing/testController/testMocks.ts new file mode 100644 index 000000000000..eb37d492f1d9 --- /dev/null +++ b/src/test/testing/testController/testMocks.ts @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Centralized mock utilities for testing testController components. + * Re-use these helpers across multiple test files for consistency. + */ + +import * as sinon from 'sinon'; +import * as typemoq from 'typemoq'; +import { TestItem, TestItemCollection, TestRun, Uri } from 'vscode'; +import { IPythonExecutionFactory } from '../../../client/common/process/types'; +import { ITestDebugLauncher } from '../../../client/testing/common/types'; +import { ProjectAdapter } from '../../../client/testing/testController/common/projectAdapter'; +import { ProjectExecutionDependencies } from '../../../client/testing/testController/common/projectTestExecution'; +import { TestProjectRegistry } from '../../../client/testing/testController/common/testProjectRegistry'; +import { ITestExecutionAdapter, ITestResultResolver } from '../../../client/testing/testController/common/types'; + +/** + * Creates a mock TestItem with configurable properties. + * @param id - The unique ID of the test item + * @param uriPath - The file path for the test item's URI + * @param children - Optional array of child test items + */ +export function createMockTestItem(id: string, uriPath: string, children?: TestItem[]): TestItem { + const childMap = new Map(); + children?.forEach((c) => childMap.set(c.id, c)); + + const mockChildren: TestItemCollection = { + size: childMap.size, + forEach: (callback: (item: TestItem, collection: TestItemCollection) => void) => { + childMap.forEach((item) => callback(item, mockChildren)); + }, + get: (itemId: string) => childMap.get(itemId), + add: () => {}, + delete: () => {}, + replace: () => {}, + [Symbol.iterator]: function* () { + for (const [key, value] of childMap) { + yield [key, value] as [string, TestItem]; + } + }, + } as TestItemCollection; + + return ({ + id, + uri: Uri.file(uriPath), + children: mockChildren, + label: id, + canResolveChildren: false, + busy: false, + tags: [], + range: undefined, + error: undefined, + parent: undefined, + } as unknown) as TestItem; +} + +/** + * Creates a mock TestItem without a URI. + * Useful for testing edge cases where test items have no associated file. + * @param id - The unique ID of the test item + */ +export function createMockTestItemWithoutUri(id: string): TestItem { + return ({ + id, + uri: undefined, + children: ({ size: 0, forEach: () => {} } as unknown) as TestItemCollection, + label: id, + } as unknown) as TestItem; +} + +export interface MockProjectAdapterConfig { + projectPath: string; + projectName: string; + pythonPath?: string; + testProvider?: 'pytest' | 'unittest'; +} + +export type MockProjectAdapter = ProjectAdapter & { executionAdapterStub: sinon.SinonStub }; + +/** + * Creates a mock ProjectAdapter for testing project-based test execution. + * @param config - Configuration object with project details + * @returns A mock ProjectAdapter with an exposed executionAdapterStub for verification + */ +export function createMockProjectAdapter(config: MockProjectAdapterConfig): MockProjectAdapter { + const runTestsStub = sinon.stub().resolves(); + const executionAdapter: ITestExecutionAdapter = ({ + runTests: runTestsStub, + } as unknown) as ITestExecutionAdapter; + + const resultResolverMock: ITestResultResolver = ({ + vsIdToRunId: new Map(), + runIdToVSid: new Map(), + runIdToTestItem: new Map(), + detailedCoverageMap: new Map(), + resolveDiscovery: () => Promise.resolve(), + resolveExecution: () => {}, + } as unknown) as ITestResultResolver; + + const adapter = ({ + projectUri: Uri.file(config.projectPath), + projectName: config.projectName, + workspaceUri: Uri.file(config.projectPath), + testProvider: config.testProvider ?? 'pytest', + pythonEnvironment: config.pythonPath + ? { + execInfo: { run: { executable: config.pythonPath } }, + } + : undefined, + pythonProject: { + name: config.projectName, + uri: Uri.file(config.projectPath), + }, + executionAdapter, + discoveryAdapter: {} as any, + resultResolver: resultResolverMock, + isDiscovering: false, + isExecuting: false, + // Expose the stub for testing + executionAdapterStub: runTestsStub, + } as unknown) as MockProjectAdapter; + + return adapter; +} + +/** + * Creates mock dependencies for project test execution. + * @returns An object containing mocked ProjectExecutionDependencies + */ +export function createMockDependencies(): ProjectExecutionDependencies { + return { + projectRegistry: typemoq.Mock.ofType().object, + pythonExecFactory: typemoq.Mock.ofType().object, + debugLauncher: typemoq.Mock.ofType().object, + }; +} + +/** + * Creates a mock TestRun with common setup methods. + * @returns A TypeMoq mock of TestRun + */ +export function createMockTestRun(): typemoq.IMock { + const runMock = typemoq.Mock.ofType(); + runMock.setup((r) => r.started(typemoq.It.isAny())); + runMock.setup((r) => r.passed(typemoq.It.isAny(), typemoq.It.isAny())); + runMock.setup((r) => r.failed(typemoq.It.isAny(), typemoq.It.isAny(), typemoq.It.isAny())); + runMock.setup((r) => r.skipped(typemoq.It.isAny())); + runMock.setup((r) => r.end()); + return runMock; +} diff --git a/src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts b/src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts index 4dae070bccbe..031f30afba8a 100644 --- a/src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts +++ b/src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts @@ -22,6 +22,7 @@ import { SpawnOptions, } from '../../../../client/common/process/types'; import * as extapi from '../../../../client/envExt/api.internal'; +import { ProjectAdapter } from '../../../../client/testing/testController/common/projectAdapter'; suite('Unittest test discovery adapter', () => { let configService: IConfigurationService; @@ -244,4 +245,98 @@ suite('Unittest test discovery adapter', () => { await discoveryPromise; assert.ok(true, 'Test resolves correctly when triggering a cancellation token in exec observable.'); }); + + test('DiscoverTests should set PROJECT_ROOT_PATH when project is provided', async () => { + const projectPath = path.join('/', 'workspace', 'myproject'); + const mockProject = ({ + projectId: 'file:///workspace/myproject', + projectUri: Uri.file(projectPath), + projectName: 'myproject', + workspaceUri: Uri.file('/workspace'), + } as unknown) as ProjectAdapter; + + const adapter = new UnittestTestDiscoveryAdapter(configService); + adapter.discoverTests(uri, execFactory.object, undefined, undefined, mockProject); + const script = path.join(EXTENSION_ROOT_DIR, 'python_files', 'unittestadapter', 'discovery.py'); + const argsExpected = [script, '--udiscovery', '-v', '-s', '.', '-p', 'test*']; + + // must await until the execObservable is called in order to verify it + await deferred.promise; + + execService.verify( + (x) => + x.execObservable( + typeMoq.It.is>((argsActual) => { + try { + assert.equal(argsActual.length, argsExpected.length); + assert.deepEqual(argsActual, argsExpected); + return true; + } catch (e) { + console.error(e); + throw e; + } + }), + typeMoq.It.is((options) => { + try { + // Verify PROJECT_ROOT_PATH is set when project is provided + assert.strictEqual( + options.env?.PROJECT_ROOT_PATH, + projectPath, + 'PROJECT_ROOT_PATH should be set to project URI path', + ); + assert.equal(options.cwd, expectedPath); + assert.equal(options.throwOnStdErr, true); + return true; + } catch (e) { + console.error(e); + throw e; + } + }), + ), + typeMoq.Times.once(), + ); + }); + + test('DiscoverTests should NOT set PROJECT_ROOT_PATH when no project is provided', async () => { + const adapter = new UnittestTestDiscoveryAdapter(configService); + adapter.discoverTests(uri, execFactory.object); + const script = path.join(EXTENSION_ROOT_DIR, 'python_files', 'unittestadapter', 'discovery.py'); + const argsExpected = [script, '--udiscovery', '-v', '-s', '.', '-p', 'test*']; + + // must await until the execObservable is called in order to verify it + await deferred.promise; + + execService.verify( + (x) => + x.execObservable( + typeMoq.It.is>((argsActual) => { + try { + assert.equal(argsActual.length, argsExpected.length); + assert.deepEqual(argsActual, argsExpected); + return true; + } catch (e) { + console.error(e); + throw e; + } + }), + typeMoq.It.is((options) => { + try { + // Verify PROJECT_ROOT_PATH is NOT set when no project is provided + assert.strictEqual( + options.env?.PROJECT_ROOT_PATH, + undefined, + 'PROJECT_ROOT_PATH should NOT be set when no project is provided', + ); + assert.equal(options.cwd, expectedPath); + assert.equal(options.throwOnStdErr, true); + return true; + } catch (e) { + console.error(e); + throw e; + } + }), + ), + typeMoq.Times.once(), + ); + }); }); diff --git a/src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts b/src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts index ab492736f0ad..8a86e9228567 100644 --- a/src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts +++ b/src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts @@ -22,6 +22,8 @@ import { MockChildProcess } from '../../../mocks/mockChildProcess'; import { traceInfo } from '../../../../client/logging'; import { UnittestTestExecutionAdapter } from '../../../../client/testing/testController/unittest/testExecutionAdapter'; import * as extapi from '../../../../client/envExt/api.internal'; +import { ProjectAdapter } from '../../../../client/testing/testController/common/projectAdapter'; +import { createMockProjectAdapter } from '../testMocks'; suite('Unittest test execution adapter', () => { let configService: IConfigurationService; @@ -321,4 +323,259 @@ suite('Unittest test execution adapter', () => { typeMoq.Times.once(), ); }); + + test('RunTests should set PROJECT_ROOT_PATH when project is provided', async () => { + const deferred2 = createDeferred(); + const deferred3 = createDeferred(); + execFactory = typeMoq.Mock.ofType(); + execFactory + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) + .returns(() => { + deferred2.resolve(); + return Promise.resolve(execService.object); + }); + utilsWriteTestIdsFileStub.callsFake(() => { + deferred3.resolve(); + return Promise.resolve('testIdPipe-mockName'); + }); + + const projectPath = path.join('/', 'workspace', 'myproject'); + const mockProject = ({ + projectId: 'file:///workspace/myproject', + projectUri: Uri.file(projectPath), + projectName: 'myproject', + workspaceUri: Uri.file('/workspace'), + } as unknown) as ProjectAdapter; + + const testRun = typeMoq.Mock.ofType(); + testRun.setup((t) => t.token).returns(() => ({ onCancellationRequested: () => undefined } as any)); + const uri = Uri.file(myTestPath); + adapter = new UnittestTestExecutionAdapter(configService); + adapter.runTests( + uri, + [], + TestRunProfileKind.Run, + testRun.object, + execFactory.object, + undefined, // debugLauncher + undefined, // interpreter + mockProject, + ); + + await deferred2.promise; + await deferred3.promise; + await deferred4.promise; + mockProc.trigger('close'); + + const pathToPythonFiles = path.join(EXTENSION_ROOT_DIR, 'python_files'); + const pathToExecutionScript = path.join(pathToPythonFiles, 'unittestadapter', 'execution.py'); + const expectedArgs = [pathToExecutionScript, '--udiscovery', '.']; + + execService.verify( + (x) => + x.execObservable( + expectedArgs, + typeMoq.It.is((options) => { + // Verify PROJECT_ROOT_PATH is set when project is provided + assert.strictEqual( + options.env?.PROJECT_ROOT_PATH, + projectPath, + 'PROJECT_ROOT_PATH should be set to project URI path', + ); + return true; + }), + ), + typeMoq.Times.once(), + ); + }); + + test('RunTests should NOT set PROJECT_ROOT_PATH when no project is provided', async () => { + const deferred2 = createDeferred(); + const deferred3 = createDeferred(); + execFactory = typeMoq.Mock.ofType(); + execFactory + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) + .returns(() => { + deferred2.resolve(); + return Promise.resolve(execService.object); + }); + utilsWriteTestIdsFileStub.callsFake(() => { + deferred3.resolve(); + return Promise.resolve('testIdPipe-mockName'); + }); + const testRun = typeMoq.Mock.ofType(); + testRun.setup((t) => t.token).returns(() => ({ onCancellationRequested: () => undefined } as any)); + const uri = Uri.file(myTestPath); + adapter = new UnittestTestExecutionAdapter(configService); + adapter.runTests(uri, [], TestRunProfileKind.Run, testRun.object, execFactory.object); + + await deferred2.promise; + await deferred3.promise; + await deferred4.promise; + mockProc.trigger('close'); + + const pathToPythonFiles = path.join(EXTENSION_ROOT_DIR, 'python_files'); + const pathToExecutionScript = path.join(pathToPythonFiles, 'unittestadapter', 'execution.py'); + const expectedArgs = [pathToExecutionScript, '--udiscovery', '.']; + + execService.verify( + (x) => + x.execObservable( + expectedArgs, + typeMoq.It.is((options) => { + // Verify PROJECT_ROOT_PATH is NOT set when no project is provided + assert.strictEqual( + options.env?.PROJECT_ROOT_PATH, + undefined, + 'PROJECT_ROOT_PATH should NOT be set when no project is provided', + ); + return true; + }), + ), + typeMoq.Times.once(), + ); + }); + + test('Debug mode with project should pass project.pythonProject to debug launcher', async () => { + const deferred3 = createDeferred(); + utilsWriteTestIdsFileStub.callsFake(() => Promise.resolve('testIdPipe-mockName')); + + debugLauncher + .setup((dl) => dl.launchDebugger(typeMoq.It.isAny(), typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns(async (_opts, callback) => { + traceInfo('stubs launch debugger'); + if (typeof callback === 'function') { + deferred3.resolve(); + callback(); + } + }); + + const testRun = typeMoq.Mock.ofType(); + testRun + .setup((t) => t.token) + .returns( + () => + ({ + onCancellationRequested: () => undefined, + } as any), + ); + + const projectPath = path.join('/', 'workspace', 'myproject'); + const mockProject = createMockProjectAdapter({ + projectPath, + projectName: 'myproject (Python 3.11)', + pythonPath: '/custom/python/path', + testProvider: 'unittest', + }); + + const uri = Uri.file(myTestPath); + adapter = new UnittestTestExecutionAdapter(configService); + adapter.runTests( + uri, + [], + TestRunProfileKind.Debug, + testRun.object, + execFactory.object, + debugLauncher.object, + undefined, + mockProject, + ); + + await deferred3.promise; + + debugLauncher.verify( + (x) => + x.launchDebugger( + typeMoq.It.is((launchOptions) => { + // Project should be passed for project-based debugging + assert.ok(launchOptions.project, 'project should be defined'); + assert.equal(launchOptions.project?.name, 'myproject (Python 3.11)'); + assert.equal(launchOptions.project?.uri.fsPath, projectPath); + return true; + }), + typeMoq.It.isAny(), + typeMoq.It.isAny(), + ), + typeMoq.Times.once(), + ); + }); + + test('useEnvExtension mode with project should use project pythonEnvironment', async () => { + // Enable the useEnvExtension path + useEnvExtensionStub.returns(true); + + utilsWriteTestIdsFileStub.callsFake(() => Promise.resolve('testIdPipe-mockName')); + + // Store the deferredTillServerClose so we can resolve it + let serverCloseDeferred: Deferred | undefined; + utilsStartRunResultNamedPipeStub.callsFake((_callback: unknown, deferred: Deferred, _token: unknown) => { + serverCloseDeferred = deferred; + return Promise.resolve('runResultPipe-mockName'); + }); + + const projectPath = path.join('/', 'workspace', 'myproject'); + const mockProject = createMockProjectAdapter({ + projectPath, + projectName: 'myproject (Python 3.11)', + pythonPath: '/custom/python/path', + testProvider: 'unittest', + }); + + // Stub runInBackground to capture which environment was used + const runInBackgroundStub = sinon.stub(extapi, 'runInBackground'); + const exitCallbacks: ((code: number, signal: string | null) => void)[] = []; + // Promise that resolves when the production code registers its onExit handler + const onExitRegistered = createDeferred(); + const mockProc2 = { + stdout: { on: sinon.stub() }, + stderr: { on: sinon.stub() }, + onExit: (cb: (code: number, signal: string | null) => void) => { + exitCallbacks.push(cb); + onExitRegistered.resolve(); + }, + kill: sinon.stub(), + }; + runInBackgroundStub.callsFake(() => Promise.resolve(mockProc2 as any)); + + const testRun = typeMoq.Mock.ofType(); + testRun + .setup((t) => t.token) + .returns( + () => + ({ + onCancellationRequested: () => undefined, + } as any), + ); + + const uri = Uri.file(myTestPath); + adapter = new UnittestTestExecutionAdapter(configService); + const runPromise = adapter.runTests( + uri, + [], + TestRunProfileKind.Run, + testRun.object, + execFactory.object, + debugLauncher.object, + undefined, + mockProject, + ); + + // Wait for production code to register its onExit handler + await onExitRegistered.promise; + + // Simulate process exit to complete the test + exitCallbacks.forEach((cb) => cb(0, null)); + + // Resolve the server close deferred to allow the runTests to complete + serverCloseDeferred?.resolve(); + + await runPromise; + + // Verify runInBackground was called with the project's Python environment + sinon.assert.calledOnce(runInBackgroundStub); + const envArg = runInBackgroundStub.firstCall.args[0]; + // The environment should be the project's pythonEnvironment + assert.ok(envArg, 'runInBackground should be called with an environment'); + assert.equal(envArg.execInfo?.run?.executable, '/custom/python/path'); + }); }); diff --git a/src/test/testing/testController/utils.unit.test.ts b/src/test/testing/testController/utils.unit.test.ts index c6d9a70831a9..3cba6fb697a5 100644 --- a/src/test/testing/testController/utils.unit.test.ts +++ b/src/test/testing/testController/utils.unit.test.ts @@ -393,16 +393,18 @@ suite('populateTestTree tests', () => { }; const rootChildrenAddStub = sandbox.stub(); + const rootChildrenGetStub = sandbox.stub().returns(undefined); const mockRootItem: TestItem = { - children: { add: rootChildrenAddStub }, + children: { add: rootChildrenAddStub, get: rootChildrenGetStub }, } as any; const nestedChildrenAddStub = sandbox.stub(); + const nestedChildrenGetStub = sandbox.stub().returns(undefined); const mockNestedNode: TestItem = { id: 'nested-id', canResolveChildren: true, tags: [], - children: { add: nestedChildrenAddStub }, + children: { add: nestedChildrenAddStub, get: nestedChildrenGetStub }, } as any; const mockNestedTestItem: TestItem = { @@ -460,14 +462,15 @@ suite('populateTestTree tests', () => { }; const rootChildrenAddStub = sandbox.stub(); - const mockRootItem: TestItem = { - children: { add: rootChildrenAddStub }, - } as any; - const existingChildrenAddStub = sandbox.stub(); + const existingChildrenGetStub = sandbox.stub().returns(undefined); const existingNode: TestItem = { id: 'existing-id', - children: { add: existingChildrenAddStub }, + children: { add: existingChildrenAddStub, get: existingChildrenGetStub }, + } as any; + const rootChildrenGetStub = sandbox.stub().withArgs('existing-id').returns(existingNode); + const mockRootItem: TestItem = { + children: { add: rootChildrenAddStub, get: rootChildrenGetStub }, } as any; const mockTestItem: TestItem = { @@ -597,14 +600,14 @@ suite('populateTestTree tests', () => { id: 'root-id', tags: [], canResolveChildren: true, - children: { add: sandbox.stub() }, + children: { add: sandbox.stub(), get: sandbox.stub().returns(undefined) }, } as any; const mockNestedNode: TestItem = { id: 'nested-id', tags: [], canResolveChildren: true, - children: { add: sandbox.stub() }, + children: { add: sandbox.stub(), get: sandbox.stub().returns(undefined) }, } as any; const mockTestItem: TestItem = { diff --git a/src/test/vscode-mock.ts b/src/test/vscode-mock.ts index 0605b1718166..b7ea2bc549a0 100644 --- a/src/test/vscode-mock.ts +++ b/src/test/vscode-mock.ts @@ -7,6 +7,7 @@ import * as vscode from 'vscode'; import * as vscodeMocks from './mocks/vsc'; import { vscMockTelemetryReporter } from './mocks/vsc/telemetryReporter'; import { anything, instance, mock, when } from 'ts-mockito'; +import { TestItem } from 'vscode'; const Module = require('module'); type VSCode = typeof vscode; @@ -134,3 +135,43 @@ mockedVSCode.LogLevel = vscodeMocks.LogLevel; (mockedVSCode as any).CancellationError = vscodeMocks.vscMockExtHostedTypes.CancellationError; (mockedVSCode as any).LSPCancellationError = vscodeMocks.vscMockExtHostedTypes.LSPCancellationError; mockedVSCode.TestRunProfileKind = vscodeMocks.TestRunProfileKind; +(mockedVSCode as any).TestCoverageCount = class TestCoverageCount { + constructor(public covered: number, public total: number) {} +}; +(mockedVSCode as any).FileCoverage = class FileCoverage { + constructor( + public uri: any, + public statementCoverage: any, + public branchCoverage?: any, + public declarationCoverage?: any, + ) {} +}; +(mockedVSCode as any).StatementCoverage = class StatementCoverage { + constructor(public executed: number | boolean, public location: any, public branches?: any) {} +}; + +// Mock TestController for vscode.tests namespace +function createMockTestController(): vscode.TestController { + const disposable = { dispose: () => undefined }; + return ({ + items: { + forEach: () => undefined, + get: () => undefined, + add: () => undefined, + replace: () => undefined, + delete: () => undefined, + size: 0, + [Symbol.iterator]: function* () {}, + }, + createRunProfile: () => disposable, + createTestItem: () => ({} as TestItem), + dispose: () => undefined, + resolveHandler: undefined, + refreshHandler: undefined, + } as unknown) as vscode.TestController; +} + +// Add tests namespace with createTestController +(mockedVSCode as any).tests = { + createTestController: (_id: string, _label: string) => createMockTestController(), +};