Skip to content

Commit 2f26e4e

Browse files
authored
Merge pull request #957 from samithkavishke/issue-1942
[BI]Add support to display test cases for workspace projects
2 parents 9aa55e4 + 5ade39e commit 2f26e4e

File tree

2 files changed

+459
-88
lines changed

2 files changed

+459
-88
lines changed

workspaces/ballerina/ballerina-extension/src/features/test-explorer/discover.ts

Lines changed: 240 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -22,88 +22,187 @@ import { StateMachine } from "../../stateMachine";
2222
import { TestsDiscoveryRequest, TestsDiscoveryResponse, FunctionTreeNode } from "@wso2/ballerina-core";
2323
import { BallerinaExtension } from "../../core";
2424
import { Position, Range, TestController, Uri, TestItem, commands } from "vscode";
25-
import { getCurrentProjectRoot } from "../../utils/project-utils";
25+
import { getWorkspaceRoot, getCurrentProjectRoot } from "../../utils/project-utils";
2626

2727
let groups: string[] = [];
2828

2929
export async function discoverTestFunctionsInProject(ballerinaExtInstance: BallerinaExtension,
3030
testController: TestController) {
3131
groups.push(testController.id);
32+
33+
const workspaceRoot = getWorkspaceRoot();
34+
const projectInfo = await ballerinaExtInstance.langClient?.getProjectInfo({ projectPath: workspaceRoot });
3235

36+
// Handle workspace with multiple child projects
37+
if (projectInfo?.children?.length > 0) {
38+
await discoverTestsInWorkspace(projectInfo.children, ballerinaExtInstance, testController);
39+
return;
40+
}
41+
42+
// Handle single project
43+
await discoverTestsInSingleProject(ballerinaExtInstance, testController);
44+
}
45+
46+
async function discoverTestsInWorkspace(
47+
children: any[],
48+
ballerinaExtInstance: BallerinaExtension,
49+
testController: TestController
50+
) {
51+
// Iterate over project children sequentially to allow awaiting each request
52+
for (const child of children) {
53+
if (!child?.projectPath) {
54+
continue;
55+
}
56+
57+
const response: TestsDiscoveryResponse = await ballerinaExtInstance.langClient?.getProjectTestFunctions({
58+
projectPath: child.projectPath
59+
});
60+
61+
if (response) {
62+
createTests(response, testController, child.projectPath);
63+
setGroupsContext();
64+
}
65+
}
66+
}
67+
68+
async function discoverTestsInSingleProject(
69+
ballerinaExtInstance: BallerinaExtension,
70+
testController: TestController,
71+
) {
3372
const projectPath = await getCurrentProjectRoot();
3473

3574
if (!projectPath) {
75+
console.warn('No project root found for test discovery');
3676
return;
3777
}
3878

3979
const request: TestsDiscoveryRequest = { projectPath };
4080
const response: TestsDiscoveryResponse = await ballerinaExtInstance.langClient?.getProjectTestFunctions(request);
81+
4182
if (response) {
4283
createTests(response, testController);
4384
setGroupsContext();
4485
}
4586
}
4687

47-
function createTests(response: TestsDiscoveryResponse, testController: TestController) {
48-
if (response.result) {
49-
// Check if the result is a Map or a plain object
50-
const isMap = response.result instanceof Map;
88+
function createTests(response: TestsDiscoveryResponse, testController: TestController, projectPath?: string) {
89+
if (!response.result) {
90+
return;
91+
}
5192

52-
// Convert the result to an iterable format
53-
const entries = isMap
54-
? Array.from(response.result.entries()) // If it's a Map, convert to an array of entries
55-
: Object.entries(response.result); // If it's a plain object, use Object.entries
93+
// Check if the result is a Map or a plain object
94+
const isMap = response.result instanceof Map;
5695

57-
// Iterate over the result map
58-
for (const [group, testFunctions] of entries) {
59-
// Create a test item for the group
60-
const groupId = `group:${group}`;
61-
let groupItem: TestItem = testController.items.get(groupId);
96+
// Convert the result to an iterable format
97+
const entries = isMap
98+
? Array.from(response.result.entries()) // If it's a Map, convert to an array of entries
99+
: Object.entries(response.result); // If it's a plain object, use Object.entries
100+
101+
// Determine if we're in a workspace context (multiple projects)
102+
const isWorkspaceContext = projectPath !== undefined;
103+
104+
// Get or create the project-level group for workspace scenarios
105+
let projectGroupItem: TestItem | undefined;
106+
if (isWorkspaceContext) {
107+
const projectName = path.basename(projectPath);
108+
const projectGroupId = `project:${projectName}`;
109+
110+
projectGroupItem = testController.items.get(projectGroupId);
111+
if (!projectGroupItem) {
112+
projectGroupItem = testController.createTestItem(projectGroupId, projectName);
113+
testController.items.add(projectGroupItem);
114+
groups.push(projectGroupId);
115+
}
116+
}
117+
118+
// Iterate over the result map (test groups)
119+
for (const [group, testFunctions] of entries) {
120+
let groupItem: TestItem | undefined;
62121

122+
// For workspace context with DEFAULT_GROUP, skip the group level and add tests directly to project
123+
if (isWorkspaceContext && group === 'DEFAULT_GROUP' && projectGroupItem) {
124+
groupItem = projectGroupItem;
125+
} else if (isWorkspaceContext && projectGroupItem) {
126+
// For workspace context with named groups, create test group under project
127+
const groupId = `group:${path.basename(projectPath)}:${group}`;
128+
groupItem = projectGroupItem.children.get(groupId);
129+
if (!groupItem) {
130+
groupItem = testController.createTestItem(groupId, group);
131+
projectGroupItem.children.add(groupItem);
132+
groups.push(groupId);
133+
}
134+
} else {
135+
// Single project - create group at root level (including DEFAULT_GROUP)
136+
const groupId = `group:${group}`;
137+
groupItem = testController.items.get(groupId);
63138
if (!groupItem) {
64-
// If the group doesn't exist, create it
65139
groupItem = testController.createTestItem(groupId, group);
66140
testController.items.add(groupItem);
67141
groups.push(groupId);
68142
}
143+
}
69144

70-
// Ensure testFunctions is iterable (convert to an array if necessary)
71-
const testFunctionsArray = Array.isArray(testFunctions)
72-
? testFunctions // If it's already an array, use it directly
73-
: Object.values(testFunctions); // If it's an object, convert to an array
74-
75-
// Iterate over the test functions in the group
76-
for (const tf of testFunctionsArray) {
77-
const testFunc: FunctionTreeNode = tf as FunctionTreeNode;
78-
// Generate a unique ID for the test item using the function name
79-
const fileName: string = testFunc.lineRange.fileName;
80-
const fileUri = Uri.file(path.join(StateMachine.context().projectPath, fileName));
81-
const testId = `test:${path.basename(fileUri.path)}:${testFunc.functionName}`;
82-
83-
// Create a test item for the test function
84-
const testItem = testController.createTestItem(testId, testFunc.functionName);
85-
86-
// Set the range for the test (optional, for navigation)
87-
const startPosition = new Position(
88-
testFunc.lineRange.startLine.line, // Convert to 0-based line number
89-
testFunc.lineRange.startLine.offset
90-
);
91-
const endPosition = new Position(
92-
testFunc.lineRange.endLine.line, // Convert to 0-based line number
93-
testFunc.lineRange.endLine.offset
94-
);
95-
testItem.range = new Range(startPosition, endPosition);
96-
97-
// Add the test item to the group
98-
groupItem.children.add(testItem);
99-
}
145+
// Ensure testFunctions is iterable (convert to an array if necessary)
146+
const testFunctionsArray = Array.isArray(testFunctions)
147+
? testFunctions // If it's already an array, use it directly
148+
: Object.values(testFunctions); // If it's an object, convert to an array
149+
150+
// Iterate over the test functions in the group
151+
for (const tf of testFunctionsArray) {
152+
const testFunc: FunctionTreeNode = tf as FunctionTreeNode;
153+
// Generate a unique ID for the test item using the function name
154+
const fileName: string = testFunc.lineRange.fileName;
155+
const resolvedProjectPath = projectPath || StateMachine.context().projectPath;
156+
const fileUri = Uri.file(path.join(resolvedProjectPath, fileName));
157+
const testId = `test:${resolvedProjectPath}:${path.basename(fileUri.path)}:${testFunc.functionName}`;
158+
159+
// Create a test item for the test function
160+
const testItem = testController.createTestItem(testId, testFunc.functionName, fileUri);
161+
162+
// Set the range for the test (optional, for navigation)
163+
const startPosition = new Position(
164+
testFunc.lineRange.startLine.line, // Convert to 0-based line number
165+
testFunc.lineRange.startLine.offset
166+
);
167+
const endPosition = new Position(
168+
testFunc.lineRange.endLine.line, // Convert to 0-based line number
169+
testFunc.lineRange.endLine.offset
170+
);
171+
testItem.range = new Range(startPosition, endPosition);
172+
173+
groupItem.children.add(testItem);
100174
}
101175
}
102176
}
103177

104178

105179
export async function handleFileChange(ballerinaExtInstance: BallerinaExtension,
106180
uri: Uri, testController: TestController) {
181+
// Determine which project this file belongs to
182+
const projectInfo = StateMachine.context().projectInfo;
183+
let targetProjectPath: string | undefined;
184+
const isWorkspace = projectInfo?.children?.length > 0;
185+
186+
// Check if this file belongs to a child project in a workspace
187+
if (isWorkspace) {
188+
for (const child of projectInfo.children) {
189+
if (uri.path.startsWith(child.projectPath)) {
190+
targetProjectPath = child.projectPath;
191+
break;
192+
}
193+
}
194+
}
195+
196+
// If not found in children, use the main project path
197+
if (!targetProjectPath) {
198+
targetProjectPath = await getCurrentProjectRoot();
199+
}
200+
201+
if (!targetProjectPath) {
202+
console.warn('Could not determine project path for file change:', uri.path);
203+
return;
204+
}
205+
107206
const request: TestsDiscoveryRequest = {
108207
projectPath: uri.path
109208
};
@@ -112,40 +211,115 @@ export async function handleFileChange(ballerinaExtInstance: BallerinaExtension,
112211
return;
113212
}
114213

115-
handleFileDelete(uri, testController);
116-
createTests(response, testController);
214+
await handleFileDelete(uri, testController);
215+
createTests(response, testController, isWorkspace ? targetProjectPath : undefined);
117216
setGroupsContext();
118217
}
119218

120219
export async function handleFileDelete(uri: Uri, testController: TestController) {
121-
const filePath = path.basename(uri.path);
220+
// Determine which project this file belongs to
221+
const projectInfo = StateMachine.context().projectInfo;
222+
let targetProjectPath: string | undefined;
223+
224+
// Check if this file belongs to a child project in a workspace
225+
if (projectInfo?.children?.length > 0) {
226+
for (const child of projectInfo.children) {
227+
if (uri.path.startsWith(child.projectPath)) {
228+
targetProjectPath = child.projectPath;
229+
break;
230+
}
231+
}
232+
}
233+
234+
// If not found in children, use the main project path
235+
if (!targetProjectPath) {
236+
targetProjectPath = await getCurrentProjectRoot();
237+
}
238+
239+
if (!targetProjectPath) {
240+
console.warn('Could not determine project path for file deletion:', uri.path);
241+
return;
242+
}
243+
244+
const fileName = path.basename(uri.path);
245+
246+
// Helper function to check if a test belongs to the specific file in the specific project
247+
const belongsToFile = (testItem: TestItem): boolean => {
248+
// Test ID format: test:${projectPath}:${fileName}:${functionName}
249+
// We need to match both the project path and the filename
250+
return testItem.id.startsWith(`test:${targetProjectPath}:${fileName}:`);
251+
};
252+
253+
// Helper function to delete tests from a test group item
254+
const deleteTestsFromGroup = (groupItem: TestItem) => {
255+
const childrenToDelete: TestItem[] = [];
256+
groupItem.children.forEach((child) => {
257+
if (belongsToFile(child)) {
258+
childrenToDelete.push(child);
259+
}
260+
});
261+
262+
// Remove the matching test function items
263+
childrenToDelete.forEach((child) => {
264+
groupItem.children.delete(child.id);
265+
});
266+
267+
return groupItem.children.size === 0;
268+
};
122269

123270
// Iterate over all root-level items in the Test Explorer
124271
testController.items.forEach((item) => {
125-
if (isTestFunctionItem(item)) {
126-
// If the item is a test function, check if it belongs to the deleted file
127-
if (item.id.startsWith(`test:${filePath}:`)) {
128-
testController.items.delete(item.id);
272+
if (isProjectGroupItem(item)) {
273+
// Only process this project group if it matches our target project
274+
const projectName = path.basename(targetProjectPath);
275+
if (item.id !== `project:${projectName}`) {
276+
return; // Skip this project, it's not the one we're looking for
129277
}
130-
} else if (isTestGroupItem(item)) {
131-
// If the item is a test group, iterate over its children
132-
const childrenToDelete: TestItem[] = [];
278+
279+
// Project group can contain either test groups or tests directly (when DEFAULT_GROUP is skipped)
280+
const groupsToDelete: TestItem[] = [];
281+
const testsToDelete: TestItem[] = [];
282+
133283
item.children.forEach((child) => {
134-
if (child.id.startsWith(`test:${filePath}:`)) {
135-
childrenToDelete.push(child);
284+
if (isTestFunctionItem(child)) {
285+
// Test added directly to project (DEFAULT_GROUP was skipped)
286+
if (belongsToFile(child)) {
287+
testsToDelete.push(child);
288+
}
289+
} else if (isTestGroupItem(child)) {
290+
// Test group - check if it becomes empty after deletion
291+
const isEmpty = deleteTestsFromGroup(child);
292+
if (isEmpty) {
293+
groupsToDelete.push(child);
294+
}
136295
}
137296
});
138297

139-
// Remove the matching test function items
140-
childrenToDelete.forEach((child) => {
141-
item.children.delete(child.id);
298+
// Remove tests that belong to the file
299+
testsToDelete.forEach((test) => {
300+
item.children.delete(test.id);
142301
});
143302

144-
// If the group is empty after deletion, remove it
303+
// Remove empty test groups
304+
groupsToDelete.forEach((groupItem) => {
305+
item.children.delete(groupItem.id);
306+
groups = groups.filter((group) => group !== groupItem.id);
307+
});
308+
309+
// If the project group is empty after deletion, remove it
145310
if (item.children.size === 0) {
146311
testController.items.delete(item.id);
147312
groups = groups.filter((group) => group !== item.id);
148313
}
314+
} else if (isTestGroupItem(item)) {
315+
// Single project scenario - test group at root level
316+
const isEmpty = deleteTestsFromGroup(item);
317+
318+
// If the group is empty after deletion, remove it
319+
if (isEmpty) {
320+
testController.items.delete(item.id);
321+
groups = groups.filter((group) => group !== item.id);
322+
}
149323
}
150324
});
151325
}
@@ -160,6 +334,11 @@ export function isTestGroupItem(item: TestItem): boolean {
160334
return item.id.startsWith('group:');
161335
}
162336

337+
export function isProjectGroupItem(item: TestItem): boolean {
338+
// Project group items have IDs starting with "project:"
339+
return item.id.startsWith('project:');
340+
}
341+
163342
function setGroupsContext() {
164343
commands.executeCommand('setContext', 'testGroups', groups);
165344
}

0 commit comments

Comments
 (0)