Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/client/testing/common/debugLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -224,14 +224,17 @@ export class DebugLauncher implements ITestDebugLauncher {
cfg: LaunchRequestArguments,
workspaceFolder: WorkspaceFolder,
configSettings: IPythonSettings,
optionsCwd?: string,
) {
// cfg.pythonPath is handled by LaunchConfigurationResolver.

if (!cfg.console) {
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 = {};
Expand Down
24 changes: 23 additions & 1 deletion src/client/testing/testController/common/testDiscoveryHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}`;
Expand Down
2 changes: 1 addition & 1 deletion src/client/testing/testController/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
Expand Down
15 changes: 4 additions & 11 deletions src/client/testing/testController/unittest/testExecutionAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,18 +48,9 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter {
runInstance: TestRun,
executionFactory: IPythonExecutionFactory,
debugLauncher?: ITestDebugLauncher,
interpreter?: PythonEnvironment,
_interpreter?: PythonEnvironment,
project?: ProjectAdapter,
): Promise<void> {
// 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<void> = utils.createTestingDeferred();

Expand Down Expand Up @@ -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,
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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>();
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>((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<void> | undefined;
utilsStartRunResultNamedPipeStub.callsFake((_callback: unknown, deferred: Deferred<void>, _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<void>();
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>();
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');
});
});
Loading