Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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) {
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}`;
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,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>();
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)[] = [];
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>();
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');
});
});
Loading