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..3f70e6b68594 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) { + 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}`; 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..8a86e9228567 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,147 @@ 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)[] = []; + // 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'); + }); });