Skip to content

Commit b2a3a8e

Browse files
committed
checkpoint- project test nodes
1 parent 7b81f07 commit b2a3a8e

File tree

5 files changed

+206
-15
lines changed

5 files changed

+206
-15
lines changed

python_files/vscode_pytest/__init__.py

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ def __init__(self, message):
7777
map_id_to_path = {}
7878
collected_tests_so_far = set()
7979
TEST_RUN_PIPE = os.getenv("TEST_RUN_PIPE")
80+
PROJECT_ROOT_PATH = os.getenv(
81+
"PROJECT_ROOT_PATH"
82+
) # Path to project root for multi-project workspaces
8083
SYMLINK_PATH = None
8184
INCLUDE_BRANCHES = False
8285

@@ -86,6 +89,20 @@ def __init__(self, message):
8689
_CACHED_CWD: pathlib.Path | None = None
8790

8891

92+
def get_test_root_path() -> pathlib.Path:
93+
"""Get the root path for the test tree.
94+
95+
For project-based testing, this returns PROJECT_ROOT_PATH (the project root).
96+
For legacy mode, this returns the current working directory.
97+
98+
Returns:
99+
pathlib.Path: The root path to use for the test tree.
100+
"""
101+
if PROJECT_ROOT_PATH:
102+
return pathlib.Path(PROJECT_ROOT_PATH)
103+
return pathlib.Path.cwd()
104+
105+
89106
def pytest_load_initial_conftests(early_config, parser, args): # noqa: ARG001
90107
has_pytest_cov = early_config.pluginmanager.hasplugin(
91108
"pytest_cov"
@@ -409,41 +426,43 @@ def pytest_sessionfinish(session, exitstatus):
409426
Exit code 4: pytest command line usage error
410427
Exit code 5: No tests were collected
411428
"""
412-
cwd = pathlib.Path.cwd()
429+
# Get the root path for the test tree structure (not the CWD for test execution)
430+
# This is PROJECT_ROOT_PATH in project-based mode, or cwd in legacy mode
431+
test_root_path = get_test_root_path()
413432
if SYMLINK_PATH:
414-
print("Plugin warning[vscode-pytest]: SYMLINK set, adjusting cwd.")
415-
cwd = pathlib.Path(SYMLINK_PATH)
433+
print("Plugin warning[vscode-pytest]: SYMLINK set, adjusting test root path.")
434+
test_root_path = pathlib.Path(SYMLINK_PATH)
416435

417436
if IS_DISCOVERY:
418437
if not (exitstatus == 0 or exitstatus == 1 or exitstatus == 5):
419438
error_node: TestNode = {
420439
"name": "",
421-
"path": cwd,
440+
"path": test_root_path,
422441
"type_": "error",
423442
"children": [],
424443
"id_": "",
425444
}
426-
send_discovery_message(os.fsdecode(cwd), error_node)
445+
send_discovery_message(os.fsdecode(test_root_path), error_node)
427446
try:
428447
session_node: TestNode | None = build_test_tree(session)
429448
if not session_node:
430449
raise VSCodePytestError(
431450
"Something went wrong following pytest finish, \
432451
no session node was created"
433452
)
434-
send_discovery_message(os.fsdecode(cwd), session_node)
453+
send_discovery_message(os.fsdecode(test_root_path), session_node)
435454
except Exception as e:
436455
ERRORS.append(
437456
f"Error Occurred, traceback: {(traceback.format_exc() if e.__traceback__ else '')}"
438457
)
439458
error_node: TestNode = {
440459
"name": "",
441-
"path": cwd,
460+
"path": test_root_path,
442461
"type_": "error",
443462
"children": [],
444463
"id_": "",
445464
}
446-
send_discovery_message(os.fsdecode(cwd), error_node)
465+
send_discovery_message(os.fsdecode(test_root_path), error_node)
447466
else:
448467
if exitstatus == 0 or exitstatus == 1:
449468
exitstatus_bool = "success"
@@ -454,7 +473,7 @@ def pytest_sessionfinish(session, exitstatus):
454473
exitstatus_bool = "error"
455474

456475
send_execution_message(
457-
os.fsdecode(cwd),
476+
os.fsdecode(test_root_path),
458477
exitstatus_bool,
459478
None,
460479
)
@@ -540,7 +559,7 @@ def pytest_sessionfinish(session, exitstatus):
540559

541560
payload: CoveragePayloadDict = CoveragePayloadDict(
542561
coverage=True,
543-
cwd=os.fspath(cwd),
562+
cwd=os.fspath(test_root_path),
544563
result=file_coverage_map,
545564
error=None,
546565
)
@@ -832,7 +851,11 @@ def create_session_node(session: pytest.Session) -> TestNode:
832851
Keyword arguments:
833852
session -- the pytest session.
834853
"""
835-
node_path = get_node_path(session)
854+
# Use PROJECT_ROOT_PATH if set (project-based testing), otherwise use session path (legacy)
855+
if PROJECT_ROOT_PATH:
856+
node_path = pathlib.Path(PROJECT_ROOT_PATH)
857+
else:
858+
node_path = get_node_path(session)
836859
return {
837860
"name": node_path.name,
838861
"path": node_path,

src/client/testing/testController/common/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { ITestDebugLauncher } from '../../common/types';
1717
import { IPythonExecutionFactory } from '../../../common/process/types';
1818
import { PythonEnvironment } from '../../../pythonEnvironments/info';
19+
import { ProjectAdapter } from './projectAdapter';
1920

2021
export enum TestDataKinds {
2122
Workspace,
@@ -160,6 +161,7 @@ export interface ITestDiscoveryAdapter {
160161
executionFactory: IPythonExecutionFactory,
161162
token?: CancellationToken,
162163
interpreter?: PythonEnvironment,
164+
project?: ProjectAdapter,
163165
): Promise<void>;
164166
}
165167

src/client/testing/testController/controller.ts

Lines changed: 144 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ import { ITestDebugLauncher } from '../common/types';
5353
import { PythonResultResolver } from './common/resultResolver';
5454
import { onDidSaveTextDocument } from '../../common/vscodeApis/workspaceApis';
5555
import { IEnvironmentVariablesProvider } from '../../common/variables/types';
56-
import { ProjectAdapter } from './common/projectAdapter';
56+
import { ProjectAdapter, WorkspaceDiscoveryState } from './common/projectAdapter';
5757
import { getProjectId, createProjectDisplayName } from './common/projectUtils';
5858
import { PythonProject, PythonEnvironment } from '../../envExt/types';
5959
import { getEnvExtApi, useEnvExtension } from '../../envExt/api.internal';
@@ -73,7 +73,7 @@ export class PythonTestController implements ITestController, IExtensionSingleAc
7373
* Set to true to enable multi-project testing support (Phases 2-4 must be complete).
7474
* Default: false (use legacy single-workspace mode)
7575
*/
76-
private readonly useProjectBasedTesting = false;
76+
private readonly useProjectBasedTesting = true;
7777

7878
// Legacy: Single workspace test adapter per workspace (backward compatibility)
7979
private readonly testAdapters: Map<Uri, WorkspaceTestAdapter> = new Map();
@@ -83,11 +83,13 @@ export class PythonTestController implements ITestController, IExtensionSingleAc
8383
// Note: Project URI strings match Python Environments extension's Map<string, PythonProject> keys
8484
private readonly workspaceProjects: Map<Uri, Map<string, ProjectAdapter>> = new Map();
8585

86-
// TODO: Phase 3-4 - Add these maps when implementing discovery and execution:
86+
// Temporary state for tracking overlaps during discovery (created/destroyed per refresh)
87+
private readonly workspaceDiscoveryState: Map<Uri, WorkspaceDiscoveryState> = new Map();
88+
89+
// TODO: Phase 3-4 - Add these maps when implementing execution:
8790
// - vsIdToProject: Map<string, ProjectAdapter> - Fast lookup for test execution
8891
// - fileUriToProject: Map<string, ProjectAdapter> - File watching and change detection
8992
// - projectToVsIds: Map<string, Set<string>> - Project cleanup and refresh
90-
// - workspaceDiscoveryState: Map<Uri, WorkspaceDiscoveryState> - Temporary overlap detection
9193

9294
private readonly triggerTypes: TriggerType[] = [];
9395

@@ -551,6 +553,13 @@ export class PythonTestController implements ITestController, IExtensionSingleAc
551553
// Ensure we send test telemetry if it gets disabled again
552554
this.sendTestDisabledTelemetry = true;
553555

556+
// Branch: Use project-based discovery if feature flag enabled and projects exist
557+
if (this.useProjectBasedTesting && this.workspaceProjects.has(workspace.uri)) {
558+
await this.refreshWorkspaceProjects(workspace.uri);
559+
return;
560+
}
561+
562+
// Legacy mode: Single workspace adapter
554563
if (settings.testing.pytestEnabled) {
555564
await this.discoverTestsForProvider(workspace.uri, 'pytest');
556565
} else if (settings.testing.unittestEnabled) {
@@ -560,6 +569,137 @@ export class PythonTestController implements ITestController, IExtensionSingleAc
560569
}
561570
}
562571

572+
/**
573+
* Phase 2: Discovers tests for all projects within a workspace (project-based testing).
574+
* Runs discovery in parallel for all projects and tracks file overlaps for Phase 3.
575+
* Each project populates its TestItems independently using the existing discovery flow.
576+
*/
577+
private async refreshWorkspaceProjects(workspaceUri: Uri): Promise<void> {
578+
const projectsMap = this.workspaceProjects.get(workspaceUri);
579+
if (!projectsMap || projectsMap.size === 0) {
580+
traceError(`[test-by-project] No projects found for workspace: ${workspaceUri.fsPath}`);
581+
return;
582+
}
583+
584+
const projects = Array.from(projectsMap.values());
585+
traceInfo(`[test-by-project] Starting discovery for ${projects.length} project(s) in workspace`);
586+
587+
// Initialize discovery state for overlap tracking
588+
const discoveryState: WorkspaceDiscoveryState = {
589+
workspaceUri,
590+
fileToProjects: new Map(),
591+
fileOwnership: new Map(),
592+
projectsCompleted: new Set(),
593+
totalProjects: projects.length,
594+
isComplete: false,
595+
};
596+
this.workspaceDiscoveryState.set(workspaceUri, discoveryState);
597+
598+
try {
599+
// Run discovery for all projects in parallel
600+
// Each project will populate TestItems independently via existing flow
601+
await Promise.all(projects.map((project) => this.discoverProject(project, discoveryState)));
602+
603+
// Mark discovery complete
604+
discoveryState.isComplete = true;
605+
traceInfo(
606+
`[test-by-project] Discovery complete: ${discoveryState.projectsCompleted.size}/${projects.length} projects succeeded`,
607+
);
608+
609+
// Log overlap information for debugging
610+
const overlappingFiles = Array.from(discoveryState.fileToProjects.entries()).filter(
611+
([, projects]) => projects.size > 1,
612+
);
613+
if (overlappingFiles.length > 0) {
614+
traceInfo(`[test-by-project] Found ${overlappingFiles.length} file(s) discovered by multiple projects`);
615+
}
616+
617+
// TODO: Phase 3 - Resolve overlaps and rebuild test tree with proper ownership
618+
// await this.resolveOverlapsAndAssignTests(workspaceUri);
619+
} finally {
620+
// Clean up temporary discovery state
621+
this.workspaceDiscoveryState.delete(workspaceUri);
622+
}
623+
}
624+
625+
/**
626+
* Phase 2: Runs test discovery for a single project.
627+
* Uses the existing discovery flow which populates TestItems automatically.
628+
* Tracks which files were discovered for overlap detection in Phase 3.
629+
*/
630+
private async discoverProject(project: ProjectAdapter, discoveryState: WorkspaceDiscoveryState): Promise<void> {
631+
try {
632+
traceInfo(`[test-by-project] Discovering tests for project: ${project.projectName}`);
633+
project.isDiscovering = true;
634+
635+
// Run discovery using project's adapter with project's interpreter
636+
// This will call the existing discovery flow which populates TestItems via result resolver
637+
// Note: The adapter expects the legacy PythonEnvironment type, but for now we can pass
638+
// the environment from the API. The adapters internally use execInfo which both types have.
639+
//
640+
// Pass the ProjectAdapter so discovery adapters can extract project.projectUri.fsPath
641+
// and set PROJECT_ROOT_PATH environment variable. This tells Python subprocess where to
642+
// trim the test tree, keeping test paths relative to project root instead of workspace root,
643+
// while preserving CWD for user's test configurations.
644+
//
645+
// TODO: Symlink consideration - If project.projectUri.fsPath contains symlinks,
646+
// Python's path resolution may differ from Node.js. Discovery adapters should consider
647+
// using fs.promises.realpath() to resolve symlinks before passing PROJECT_ROOT_PATH to Python,
648+
// similar to handleSymlinkAndRootDir() in pytest. This ensures PROJECT_ROOT_PATH matches
649+
// the resolved path Python will use.
650+
await project.discoveryAdapter.discoverTests(
651+
project.projectUri,
652+
this.pythonExecFactory,
653+
this.refreshCancellation.token,
654+
project.pythonEnvironment as any, // Type cast needed - API type vs legacy type
655+
project, // Pass project for access to projectUri and other project-specific data
656+
);
657+
658+
// Track which files this project discovered by inspecting created TestItems
659+
// This data will be used in Phase 3 for overlap resolution
660+
this.trackProjectDiscoveredFiles(project, discoveryState);
661+
662+
// Mark project as completed
663+
discoveryState.projectsCompleted.add(project.projectId);
664+
traceInfo(`[test-by-project] Project ${project.projectName} discovery completed`);
665+
} catch (error) {
666+
traceError(`[test-by-project] Discovery failed for project ${project.projectName}:`, error);
667+
// Individual project failures don't block others
668+
discoveryState.projectsCompleted.add(project.projectId); // Still mark as completed
669+
} finally {
670+
project.isDiscovering = false;
671+
}
672+
}
673+
674+
/**
675+
* Tracks which files a project discovered by inspecting its TestItems.
676+
* Populates the fileToProjects map for overlap detection in Phase 3.
677+
*/
678+
private trackProjectDiscoveredFiles(project: ProjectAdapter, discoveryState: WorkspaceDiscoveryState): void {
679+
// Get all test items for this project from its result resolver
680+
const testItems = project.resultResolver.runIdToTestItem;
681+
682+
// Extract unique file paths from test items
683+
const filePaths = new Set<string>();
684+
testItems.forEach((testItem) => {
685+
if (testItem.uri) {
686+
filePaths.add(testItem.uri.fsPath);
687+
}
688+
});
689+
690+
// Track which projects discovered each file
691+
filePaths.forEach((filePath) => {
692+
if (!discoveryState.fileToProjects.has(filePath)) {
693+
discoveryState.fileToProjects.set(filePath, new Set());
694+
}
695+
discoveryState.fileToProjects.get(filePath)!.add(project);
696+
});
697+
698+
traceVerbose(
699+
`[test-by-project] Project ${project.projectName} discovered ${filePaths.size} file(s) with ${testItems.size} test(s)`,
700+
);
701+
}
702+
563703
/**
564704
* Discovers tests for all workspaces in the workspace folders.
565705
*/

src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { PythonEnvironment } from '../../../pythonEnvironments/info';
1919
import { useEnvExtension, getEnvironment, runInBackground } from '../../../envExt/api.internal';
2020
import { buildPytestEnv as configureSubprocessEnv, handleSymlinkAndRootDir } from './pytestHelpers';
2121
import { cleanupOnCancellation, createProcessHandlers, setupDiscoveryPipe } from '../common/discoveryHelpers';
22+
import { ProjectAdapter } from '../common/projectAdapter';
2223

2324
/**
2425
* Configures the subprocess environment for pytest discovery.
@@ -53,6 +54,7 @@ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter {
5354
executionFactory: IPythonExecutionFactory,
5455
token?: CancellationToken,
5556
interpreter?: PythonEnvironment,
57+
project?: ProjectAdapter,
5658
): Promise<void> {
5759
// Setup discovery pipe and cancellation
5860
const {
@@ -84,6 +86,17 @@ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter {
8486
// Configure subprocess environment
8587
const mutableEnv = await configureDiscoveryEnv(this.envVarsService, uri, discoveryPipeName);
8688

89+
// Set PROJECT_ROOT_PATH for project-based testing
90+
// This tells Python where to trim the test tree, keeping test paths relative to project root
91+
// instead of workspace root, while preserving CWD for user's test configurations.
92+
// Using fsPath for cross-platform compatibility (handles Windows vs Unix paths).
93+
// TODO: Symlink consideration - PROJECT_ROOT_PATH may contain symlinks. If handleSymlinkAndRootDir()
94+
// resolves the CWD to a different path, PROJECT_ROOT_PATH might not match. Consider resolving
95+
// PROJECT_ROOT_PATH symlinks before passing, or adjust Python-side logic to handle both paths.
96+
if (project) {
97+
mutableEnv.PROJECT_ROOT_PATH = project.projectUri.fsPath;
98+
}
99+
87100
// Setup process handlers (shared by both execution paths)
88101
const handlers = createProcessHandlers('pytest', uri, cwd, this.resultResolver, deferredTillExecClose, [5]);
89102

src/client/testing/testController/unittest/testDiscoveryAdapter.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { PythonEnvironment } from '../../../pythonEnvironments/info';
1818
import { createTestingDeferred } from '../common/utils';
1919
import { buildDiscoveryCommand, buildUnittestEnv as configureSubprocessEnv } from './unittestHelpers';
2020
import { cleanupOnCancellation, createProcessHandlers, setupDiscoveryPipe } from '../common/discoveryHelpers';
21+
import { ProjectAdapter } from '../common/projectAdapter';
2122

2223
/**
2324
* Configures the subprocess environment for unittest discovery.
@@ -51,6 +52,7 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter {
5152
executionFactory: IPythonExecutionFactory,
5253
token?: CancellationToken,
5354
interpreter?: PythonEnvironment,
55+
project?: ProjectAdapter,
5456
): Promise<void> {
5557
// Setup discovery pipe and cancellation
5658
const {
@@ -78,6 +80,17 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter {
7880
// Configure subprocess environment
7981
const mutableEnv = await configureDiscoveryEnv(this.envVarsService, uri, discoveryPipeName);
8082

83+
// Set PROJECT_ROOT_PATH for project-based testing
84+
// This tells Python where to trim the test tree, keeping test paths relative to project root
85+
// instead of workspace root, while preserving CWD for user's test configurations.
86+
// Using fsPath for cross-platform compatibility (handles Windows vs Unix paths).
87+
// TODO: Symlink consideration - If CWD or PROJECT_ROOT_PATH contain symlinks, path matching
88+
// in Python may fail. Consider resolving symlinks before comparison, or using os.path.realpath()
89+
// on the Python side to normalize paths before building test tree.
90+
if (project) {
91+
mutableEnv.PROJECT_ROOT_PATH = project.projectUri.fsPath;
92+
}
93+
8194
// Setup process handlers (shared by both execution paths)
8295
const handlers = createProcessHandlers('unittest', uri, cwd, this.resultResolver, deferredTillExecClose);
8396

0 commit comments

Comments
 (0)