From f53a64ad89fa4561dfb9371d9d2cf74630c77380 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:44:27 -0800 Subject: [PATCH 1/4] unittest execution 1 --- .../testing/testController/controller.ts | 2 +- .../pytest/pytestDiscoveryAdapter.ts | 2 +- .../unittest/testDiscoveryAdapter.ts | 2 +- .../unittest/testExecutionAdapter.ts | 15 +- .../testExecutionAdapter.unit.test.ts | 141 ++++++++++++++++++ 5 files changed, 148 insertions(+), 14 deletions(-) diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index e6801f8e668b..ac52f4d0cafe 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -794,7 +794,7 @@ export class PythonTestController implements ITestController, IExtensionSingleAc } // Check if we're in project-based mode and should use project-specific execution - if (this.projectRegistry.hasProjects(workspace.uri) && settings.testing.pytestEnabled) { + if (this.projectRegistry.hasProjects(workspace.uri)) { const projects = this.projectRegistry.getProjectsArray(workspace.uri); await executeTestsForProjects(projects, testItems, runInstance, request, token, { projectRegistry: this.projectRegistry, diff --git a/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts b/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts index f363821371cb..16e27635e66c 100644 --- a/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts +++ b/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts @@ -109,7 +109,7 @@ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter { // Execute using environment extension if available if (useEnvExtension()) { traceInfo(`Using environment extension for pytest discovery in workspace ${uri.fsPath}`); - const pythonEnv = await getEnvironment(uri); + const pythonEnv = project?.pythonEnvironment ?? (await getEnvironment(uri)); if (!pythonEnv) { traceError( `Python environment not found for workspace ${uri.fsPath}. Cannot proceed with test discovery.`, diff --git a/src/client/testing/testController/unittest/testDiscoveryAdapter.ts b/src/client/testing/testController/unittest/testDiscoveryAdapter.ts index 48c688839500..558e01f3514d 100644 --- a/src/client/testing/testController/unittest/testDiscoveryAdapter.ts +++ b/src/client/testing/testController/unittest/testDiscoveryAdapter.ts @@ -94,7 +94,7 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter { // Execute using environment extension if available if (useEnvExtension()) { traceInfo(`Using environment extension for unittest discovery in workspace ${uri.fsPath}`); - const pythonEnv = await getEnvironment(uri); + const pythonEnv = project?.pythonEnvironment ?? (await getEnvironment(uri)); if (!pythonEnv) { traceError( `Python environment not found for workspace ${uri.fsPath}. Cannot proceed with test discovery.`, diff --git a/src/client/testing/testController/unittest/testExecutionAdapter.ts b/src/client/testing/testController/unittest/testExecutionAdapter.ts index 967f9529ea2f..c7d21b768c5b 100644 --- a/src/client/testing/testController/unittest/testExecutionAdapter.ts +++ b/src/client/testing/testController/unittest/testExecutionAdapter.ts @@ -48,18 +48,9 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter { runInstance: TestRun, executionFactory: IPythonExecutionFactory, debugLauncher?: ITestDebugLauncher, - interpreter?: PythonEnvironment, + _interpreter?: PythonEnvironment, project?: ProjectAdapter, ): Promise { - // Note: project parameter is currently unused for unittest. - // Project-based unittest execution will be implemented in a future PR. - console.log( - 'interpreter, project parameters are currently unused in UnittestTestExecutionAdapter, they will be used in a future implementation of project-based unittest execution.:', - { - interpreter, - project, - }, - ); // deferredTillServerClose awaits named pipe server close const deferredTillServerClose: Deferred = utils.createTestingDeferred(); @@ -189,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, @@ -207,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/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts b/src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts index df104b01b548..338f1732fa7f 100644 --- a/src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts +++ b/src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts @@ -23,6 +23,7 @@ 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; @@ -434,4 +435,144 @@ suite('Unittest test execution adapter', () => { 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)[] = []; + const mockProc2 = { + stdout: { on: sinon.stub() }, + stderr: { on: sinon.stub() }, + onExit: (cb: (code: number, signal: string | null) => void) => { + exitCallbacks.push(cb); + }, + kill: sinon.stub(), + }; + runInBackgroundStub.resolves(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 the runInBackground to be called + await new Promise((resolve) => setTimeout(resolve, 10)); + + // 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'); + }); }); From e6b6cc25da6c60d56bb6b52b03861e83e20495cc Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Mon, 9 Feb 2026 09:51:42 -0800 Subject: [PATCH 2/4] updates --- src/client/testing/common/debugLauncher.ts | 7 ++++-- .../common/testDiscoveryHandler.ts | 24 ++++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/client/testing/common/debugLauncher.ts b/src/client/testing/common/debugLauncher.ts index d471565467b0..2c8b058e50e6 100644 --- a/src/client/testing/common/debugLauncher.ts +++ b/src/client/testing/common/debugLauncher.ts @@ -177,7 +177,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); } @@ -224,6 +224,7 @@ export class DebugLauncher implements ITestDebugLauncher { cfg: LaunchRequestArguments, workspaceFolder: WorkspaceFolder, configSettings: IPythonSettings, + optionsCwd?: string, ) { // cfg.pythonPath is handled by LaunchConfigurationResolver. @@ -231,7 +232,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 = {}; diff --git a/src/client/testing/testController/common/testDiscoveryHandler.ts b/src/client/testing/testController/common/testDiscoveryHandler.ts index d244f7e0a3d9..e6c1f6194c69 100644 --- a/src/client/testing/testController/common/testDiscoveryHandler.ts +++ b/src/client/testing/testController/common/testDiscoveryHandler.ts @@ -5,7 +5,7 @@ import { CancellationToken, TestController, Uri, MarkdownString } from 'vscode'; import * as util from 'util'; import { DiscoveredTestPayload } from './types'; import { TestProvider } from '../../types'; -import { traceError } from '../../../logging'; +import { traceError, traceWarn } from '../../../logging'; import { Testing } from '../../../common/utils/localize'; import { createErrorTestItem } from './testItemUtilities'; import { buildErrorNodeOptions, populateTestTree } from './utils'; @@ -93,6 +93,28 @@ export class TestDiscoveryHandler { 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) { + traceWarn( + `--- + [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. + ---`, + ); + } + } + const errorNodeId = projectId ? `${projectId}${PROJECT_ID_SEPARATOR}DiscoveryError:${workspacePath}` : `DiscoveryError:${workspacePath}`; From 62d89d4042640fd67a03150d3b4041420a1823a1 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:38:59 -0800 Subject: [PATCH 3/4] comments --- .../common/testDiscoveryHandler.ts | 16 ++++++++-------- .../unittest/testExecutionAdapter.unit.test.ts | 12 ++++++++++-- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/client/testing/testController/common/testDiscoveryHandler.ts b/src/client/testing/testController/common/testDiscoveryHandler.ts index e6c1f6194c69..3f70e6b68594 100644 --- a/src/client/testing/testController/common/testDiscoveryHandler.ts +++ b/src/client/testing/testController/common/testDiscoveryHandler.ts @@ -104,14 +104,14 @@ export class TestDiscoveryHandler { errorText.includes('No module named'); if (isImportError) { - traceWarn( - `--- - [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. - ---`, - ); + 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); } } diff --git a/src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts b/src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts index 338f1732fa7f..55d263983d61 100644 --- a/src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts +++ b/src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts @@ -534,6 +534,14 @@ suite('Unittest test execution adapter', () => { }; runInBackgroundStub.resolves(mockProc2 as any); + // Create a promise that resolves when runInBackground is called + const runInBackgroundCalled = new Promise((resolve) => { + runInBackgroundStub.callsFake(() => { + resolve(); + return Promise.resolve(mockProc2 as any); + }); + }); + const testRun = typeMoq.Mock.ofType(); testRun .setup((t) => t.token) @@ -557,8 +565,8 @@ suite('Unittest test execution adapter', () => { mockProject, ); - // Wait for the runInBackground to be called - await new Promise((resolve) => setTimeout(resolve, 10)); + // Wait for runInBackground to be called + await runInBackgroundCalled; // Simulate process exit to complete the test exitCallbacks.forEach((cb) => cb(0, null)); From e5481ce906b1e537986044a4327525d9ab4b6fd5 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:10:09 -0800 Subject: [PATCH 4/4] timeout fix --- .../unittest/testExecutionAdapter.unit.test.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts b/src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts index 55d263983d61..8a86e9228567 100644 --- a/src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts +++ b/src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts @@ -524,23 +524,18 @@ suite('Unittest test execution adapter', () => { // 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.resolves(mockProc2 as any); - - // Create a promise that resolves when runInBackground is called - const runInBackgroundCalled = new Promise((resolve) => { - runInBackgroundStub.callsFake(() => { - resolve(); - return Promise.resolve(mockProc2 as any); - }); - }); + runInBackgroundStub.callsFake(() => Promise.resolve(mockProc2 as any)); const testRun = typeMoq.Mock.ofType(); testRun @@ -565,8 +560,8 @@ suite('Unittest test execution adapter', () => { mockProject, ); - // Wait for runInBackground to be called - await runInBackgroundCalled; + // 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));