Skip to content
Closed
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
287 changes: 200 additions & 87 deletions src/client/testing/testController/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,28 @@ import { ITestDebugLauncher } from '../common/types';
import { PythonResultResolver } from './common/resultResolver';
import { onDidSaveTextDocument } from '../../common/vscodeApis/workspaceApis';
import { IEnvironmentVariablesProvider } from '../../common/variables/types';
import { useEnvExtension, getEnvExtApi } from '../../envExt/api.internal';
import { PythonProject } from '../../envExt/types';

// Types gymnastics to make sure that sendTriggerTelemetry only accepts the correct types.
type EventPropertyType = IEventNamePropertyMapping[EventName.UNITTEST_DISCOVERY_TRIGGER];
type TriggerKeyType = keyof EventPropertyType;
type TriggerType = EventPropertyType[TriggerKeyType];

/**
* Represents a project-specific test adapter with its project information
*/
interface ProjectTestAdapter {
adapter: WorkspaceTestAdapter;
project: PythonProject;
}

@injectable()
export class PythonTestController implements ITestController, IExtensionSingleActivationService {
public readonly supportedWorkspaceTypes = { untrustedWorkspace: false, virtualWorkspace: false };

private readonly testAdapters: Map<Uri, WorkspaceTestAdapter> = new Map();
// Map of workspace URI string -> array of project-specific test adapters
private readonly testAdapters: Map<string, ProjectTestAdapter[]> = new Map();

private readonly triggerTypes: TriggerType[] = [];

Expand Down Expand Up @@ -163,57 +174,68 @@ export class PythonTestController implements ITestController, IExtensionSingleAc
public async activate(): Promise<void> {
const workspaces: readonly WorkspaceFolder[] = this.workspaceService.workspaceFolders || [];
workspaces.forEach((workspace) => {
// Initialize with empty array - projects will be detected during discovery
this.testAdapters.set(workspace.uri.toString(), []);

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();
}
});
}

let discoveryAdapter: ITestDiscoveryAdapter;
let executionAdapter: ITestExecutionAdapter;
let testProvider: TestProvider;
let resultResolver: PythonResultResolver;
/**
* Creates a test adapter for a specific project within a workspace.
* @param project The project to create adapter for
* @param testProvider The test provider (pytest or unittest)
* @returns A ProjectTestAdapter instance
*/
private createProjectAdapter(project: PythonProject, testProvider: TestProvider): ProjectTestAdapter {
traceVerbose(`Creating ${testProvider} adapter for project at ${project.uri.fsPath}`);

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,
);
}
const resultResolver = new PythonResultResolver(this.testController, testProvider, project.uri);

let discoveryAdapter: ITestDiscoveryAdapter;
let executionAdapter: ITestExecutionAdapter;

const workspaceTestAdapter = new WorkspaceTestAdapter(
testProvider,
discoveryAdapter,
executionAdapter,
workspace.uri,
if (testProvider === UNITTEST_PROVIDER) {
discoveryAdapter = new UnittestTestDiscoveryAdapter(
this.configSettings,
resultResolver,
this.envVarsService,
);
executionAdapter = new UnittestTestExecutionAdapter(
this.configSettings,
resultResolver,
this.envVarsService,
);
} else {
discoveryAdapter = new PytestTestDiscoveryAdapter(
this.configSettings,
resultResolver,
this.envVarsService,
);
executionAdapter = new PytestTestExecutionAdapter(
this.configSettings,
resultResolver,
this.envVarsService,
);
}

this.testAdapters.set(workspace.uri, workspaceTestAdapter);
const workspaceTestAdapter = new WorkspaceTestAdapter(
testProvider,
discoveryAdapter,
executionAdapter,
project.uri, // Use project URI instead of workspace URI
resultResolver,
);

if (settings.testing.autoTestDiscoverOnSaveEnabled) {
traceVerbose(`Testing: Setting up watcher for ${workspace.uri.fsPath}`);
this.watchForSettingsChanges(workspace);
this.watchForTestContentChangeOnSave();
}
});
return {
adapter: workspaceTestAdapter,
project,
};
}

public refreshTestData(uri?: Resource, options?: TestRefreshOptions): Promise<void> {
Expand Down Expand Up @@ -311,32 +333,87 @@ export class PythonTestController implements ITestController, IExtensionSingleAc

/**
* Discovers tests for a specific test provider (pytest or unittest).
* Validates that the adapter's provider matches the expected provider.
* Detects all projects within the workspace and runs discovery for each project.
*/
private async discoverTestsForProvider(workspaceUri: Uri, expectedProvider: TestProvider): Promise<void> {
const testAdapter = this.testAdapters.get(workspaceUri);
const workspaceKey = workspaceUri.toString();

// Step 1: Get Python projects from the environment extension
let projects: PythonProject[] = [];

if (useEnvExtension()) {
try {
const envExtApi = await getEnvExtApi();
const allProjects = envExtApi.getPythonProjects();

// Filter projects that belong to this workspace
projects = allProjects.filter((project) => {
const projectWorkspace = this.workspaceService.getWorkspaceFolder(project.uri);
return projectWorkspace?.uri.toString() === workspaceKey;
});

traceVerbose(`Found ${projects.length} Python project(s) in workspace ${workspaceUri.fsPath}`);
} catch (error) {
traceError(`Error getting projects from environment extension: ${error}`);
// Fall back to using workspace as single project
projects = [];
}
}

if (!testAdapter) {
traceError('Unable to find test adapter for workspace.');
return;
// If no projects found or env extension not available, treat workspace as single project
if (projects.length === 0) {
traceVerbose(`No projects detected, using workspace root as single project: ${workspaceUri.fsPath}`);
projects = [{
name: this.workspaceService.getWorkspaceFolder(workspaceUri)?.name || 'workspace',
uri: workspaceUri,
}];
}

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,
// Step 2: Create or reuse adapters for each project
const existingAdapters = this.testAdapters.get(workspaceKey) || [];
const newAdapters: ProjectTestAdapter[] = [];

for (const project of projects) {
// Check if we already have an adapter for this project
const existingAdapter = existingAdapters.find(
(a) => a.project.uri.toString() === project.uri.toString() &&
a.adapter.getTestProvider() === expectedProvider,
);
return;

if (existingAdapter) {
traceVerbose(`Reusing existing adapter for project at ${project.uri.fsPath}`);
newAdapters.push(existingAdapter);
} else {
// Create new adapter for this project
const projectAdapter = this.createProjectAdapter(project, expectedProvider);
newAdapters.push(projectAdapter);
}
}

await testAdapter.discoverTests(
this.testController,
this.pythonExecFactory,
this.refreshCancellation.token,
await this.interpreterService.getActiveInterpreter(workspaceUri),
// Update the adapters map
this.testAdapters.set(workspaceKey, newAdapters);

// Step 3: Run discovery for each project
const interpreter = await this.interpreterService.getActiveInterpreter(workspaceUri);
await Promise.all(
newAdapters.map(async ({ adapter, project }) => {
traceVerbose(`Running ${expectedProvider} discovery for project at ${project.uri.fsPath}`);
try {
await adapter.discoverTests(
this.testController,
this.pythonExecFactory,
this.refreshCancellation.token,
interpreter,
);
} catch (error) {
traceError(`Error during ${expectedProvider} discovery for project at ${project.uri.fsPath}:`, error);
this.surfaceErrorNode(
project.uri,
`Error discovering tests in project at ${project.uri.fsPath}: ${error}`,
expectedProvider,
);
}
}),
);
}

Expand Down Expand Up @@ -448,6 +525,10 @@ export class PythonTestController implements ITestController, IExtensionSingleAc
/**
* Runs tests for a single workspace.
*/
/**
* Runs tests for a single workspace.
* Groups tests by project and executes them using the appropriate project adapter.
*/
private async runTestsForWorkspace(
workspace: WorkspaceFolder,
request: TestRunRequest,
Expand All @@ -472,34 +553,66 @@ export class PythonTestController implements ITestController, IExtensionSingleAc
return;
}

const testAdapter =
this.testAdapters.get(workspace.uri) || (this.testAdapters.values().next().value as WorkspaceTestAdapter);

this.setupCoverageIfNeeded(request, testAdapter);
// Get project adapters for this workspace
const workspaceKey = workspace.uri.toString();
const projectAdapters = this.testAdapters.get(workspaceKey) || [];

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 {
if (projectAdapters.length === 0) {
traceError('No test adapters found for workspace. Tests may not have been discovered yet.');
unconfiguredWorkspaces.push(workspace);
return;
}

// Group test items by project
const testItemsByProject = new Map<string, TestItem[]>();
for (const testItem of testItems) {
// Find which project this test belongs to
const projectAdapter = projectAdapters.find(({ project }) => {
const testPath = testItem.uri?.fsPath;
if (!testPath) {
return false;
}
// Check if test path is within project path
return testPath === project.uri.fsPath || testPath.startsWith(project.uri.fsPath + '/');
});

if (projectAdapter) {
const projectKey = projectAdapter.project.uri.toString();
if (!testItemsByProject.has(projectKey)) {
testItemsByProject.set(projectKey, []);
}
testItemsByProject.get(projectKey)!.push(testItem);
} else {
traceError(`Could not find project adapter for test: ${testItem.id}`);
}
}

// Execute tests for each project
const testProvider = settings.testing.pytestEnabled ? 'pytest' : 'unittest';
await Promise.all(
Array.from(testItemsByProject.entries()).map(async ([projectPath, projectTestItems]) => {
const projectAdapter = projectAdapters.find(
({ project }) => project.uri.toString() === projectPath,
);

if (!projectAdapter) {
traceError(`Project adapter not found for project at ${projectPath}`);
return;
}

this.setupCoverageIfNeeded(request, projectAdapter.adapter);

await this.executeTestsForProvider(
workspace,
projectAdapter.adapter,
projectTestItems,
runInstance,
request,
token,
testProvider,
);
}),
);
}

/**
Expand Down
Loading