11// SPDX-License-Identifier: MIT
22// Copyright © 2026 TON Core
33
4+ import * as fs from "node:fs/promises"
45import * as path from "node:path"
56
67import * as vscode from "vscode"
78
9+ import { FileCoverageDetail } from "vscode"
10+
811import { Acton } from "./Acton"
912import { TestCommand , TestMode } from "./ActonCommand"
1013
1114export class ActonTestController implements Disposable {
1215 private readonly controller : vscode . TestController
1316 private readonly outputChannel : vscode . OutputChannel
17+ private readonly coverageDetails : Map < string , vscode . StatementCoverage [ ] > = new Map ( )
1418
1519 public constructor ( ) {
1620 this . controller = vscode . tests . createTestController ( "actonTests" , "Acton Tests" )
@@ -25,6 +29,22 @@ export class ActonTestController implements Disposable {
2529 true ,
2630 )
2731
32+ const coverageProfile = this . controller . createRunProfile (
33+ "Coverage" ,
34+ vscode . TestRunProfileKind . Coverage ,
35+ async ( request , token ) => {
36+ await this . runHandler ( request , token , true )
37+ } ,
38+ true ,
39+ )
40+
41+ coverageProfile . loadDetailedCoverage = (
42+ _testRun ,
43+ fileCoverage ,
44+ ) : Thenable < FileCoverageDetail [ ] > => {
45+ return Promise . resolve ( this . coverageDetails . get ( fileCoverage . uri . toString ( ) ) ?? [ ] )
46+ }
47+
2848 this . controller . resolveHandler = async item => {
2949 await ( item ? this . discoverTestsInItem ( item ) : this . discoverAllTests ( ) )
3050 }
@@ -135,7 +155,11 @@ export class ActonTestController implements Disposable {
135155 private async runHandler (
136156 request : vscode . TestRunRequest ,
137157 token : vscode . CancellationToken ,
158+ isCoverage : boolean = false ,
138159 ) : Promise < void > {
160+ if ( isCoverage ) {
161+ this . coverageDetails . clear ( )
162+ }
139163 const run = this . controller . createTestRun ( request )
140164 const queue : vscode . TestItem [ ] = [ ]
141165
@@ -154,7 +178,7 @@ export class ActonTestController implements Disposable {
154178 break
155179 }
156180
157- await this . runTestItem ( test , run , token )
181+ await this . runTestItem ( test , run , token , isCoverage )
158182 }
159183
160184 run . end ( )
@@ -164,6 +188,7 @@ export class ActonTestController implements Disposable {
164188 item : vscode . TestItem ,
165189 run : vscode . TestRun ,
166190 token : vscode . CancellationToken ,
191+ isCoverage : boolean = false ,
167192 ) : Promise < void > {
168193 if ( token . isCancellationRequested ) {
169194 return
@@ -181,10 +206,27 @@ export class ActonTestController implements Disposable {
181206 const workingDir = tomlUri ? path . dirname ( tomlUri . fsPath ) : path . dirname ( uri . fsPath )
182207 const relativePath = path . relative ( workingDir , uri . fsPath )
183208
209+ const coverageFile = isCoverage ? path . join ( workingDir , "lcov.info" ) : ""
184210 const command =
185211 item . children . size > 0
186- ? new TestCommand ( TestMode . FILE , relativePath )
187- : new TestCommand ( TestMode . FUNCTION , relativePath , item . label )
212+ ? new TestCommand (
213+ TestMode . FILE ,
214+ relativePath ,
215+ "" ,
216+ false ,
217+ isCoverage ,
218+ "lcov" ,
219+ coverageFile ,
220+ )
221+ : new TestCommand (
222+ TestMode . FUNCTION ,
223+ relativePath ,
224+ item . label ,
225+ false ,
226+ isCoverage ,
227+ "lcov" ,
228+ coverageFile ,
229+ )
188230
189231 try {
190232 const { exitCode, stdout, stderr} = await Acton . getInstance ( ) . spawn (
@@ -199,6 +241,9 @@ export class ActonTestController implements Disposable {
199241
200242 if ( exitCode === 0 ) {
201243 run . passed ( item )
244+ if ( isCoverage && coverageFile ) {
245+ await this . processCoverage ( coverageFile , run , workingDir )
246+ }
202247 } else {
203248 const rawMessage = stderr || stdout || "Test failed"
204249
@@ -220,6 +265,69 @@ export class ActonTestController implements Disposable {
220265 }
221266 }
222267
268+ private async processCoverage (
269+ coverageFile : string ,
270+ run : vscode . TestRun ,
271+ workingDir : string ,
272+ ) : Promise < void > {
273+ try {
274+ const content = await fs . readFile ( coverageFile , "utf8" )
275+ const files = this . parseLcov ( content )
276+
277+ for ( const fileData of files ) {
278+ const absolutePath = path . isAbsolute ( fileData . file )
279+ ? fileData . file
280+ : path . join ( workingDir , fileData . file )
281+ const uri = vscode . Uri . file ( absolutePath )
282+
283+ const statementCoverage : vscode . StatementCoverage [ ] = fileData . lines . map ( line => {
284+ return new vscode . StatementCoverage (
285+ line . count ,
286+ new vscode . Range ( line . line - 1 , 0 , line . line - 1 , 0 ) ,
287+ )
288+ } )
289+
290+ this . coverageDetails . set ( uri . toString ( ) , statementCoverage )
291+
292+ const coveredLines = fileData . lines . filter ( l => l . count > 0 ) . length
293+ const totalLines = fileData . lines . length
294+
295+ const fileCoverage = new vscode . FileCoverage (
296+ uri ,
297+ new vscode . TestCoverageCount ( coveredLines , totalLines ) ,
298+ )
299+
300+ run . addCoverage ( fileCoverage )
301+ }
302+ } catch ( error ) {
303+ console . error ( "Failed to process coverage" , error )
304+ }
305+ }
306+
307+ private parseLcov ( content : string ) : { file : string ; lines : { line : number ; count : number } [ ] } [ ] {
308+ const files : { file : string ; lines : { line : number ; count : number } [ ] } [ ] = [ ]
309+ let currentFile : { file : string ; lines : { line : number ; count : number } [ ] } | null = null
310+
311+ for ( const line of content . split ( / \r ? \n / ) ) {
312+ if ( line . startsWith ( "SF:" ) ) {
313+ currentFile = { file : line . slice ( 3 ) . trim ( ) , lines : [ ] }
314+ } else if ( line . startsWith ( "DA:" ) && currentFile ) {
315+ const parts = line . slice ( 3 ) . split ( "," )
316+ if ( parts . length >= 2 ) {
317+ const lineNum = Number . parseInt ( parts [ 0 ] , 10 )
318+ const count = Number . parseInt ( parts [ 1 ] , 10 )
319+ if ( ! Number . isNaN ( lineNum ) && ! Number . isNaN ( count ) ) {
320+ currentFile . lines . push ( { line : lineNum , count} )
321+ }
322+ }
323+ } else if ( line . startsWith ( "end_of_record" ) && currentFile ) {
324+ files . push ( currentFile )
325+ currentFile = null
326+ }
327+ }
328+ return files
329+ }
330+
223331 private extractErrorLocation ( cleanMessage : string ) : vscode . Location | undefined {
224332 const locationMatch = / a t \s + ( .+ ) : ( \d + ) : ( \d + ) / . exec ( cleanMessage )
225333 if ( ! locationMatch ) return undefined
0 commit comments