@@ -22,88 +22,187 @@ import { StateMachine } from "../../stateMachine";
2222import { TestsDiscoveryRequest , TestsDiscoveryResponse , FunctionTreeNode } from "@wso2/ballerina-core" ;
2323import { BallerinaExtension } from "../../core" ;
2424import { Position , Range , TestController , Uri , TestItem , commands } from "vscode" ;
25- import { getCurrentProjectRoot } from "../../utils/project-utils" ;
25+ import { getWorkspaceRoot , getCurrentProjectRoot } from "../../utils/project-utils" ;
2626
2727let groups : string [ ] = [ ] ;
2828
2929export 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
105179export 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
120219export 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+
163342function setGroupsContext ( ) {
164343 commands . executeCommand ( 'setContext' , 'testGroups' , groups ) ;
165344}
0 commit comments