import * as vscode from 'vscode' ; import { extensionId } from '../appGlobals' ; import { JestRunEvent } from '../JestExt' ; import { TestSuiteResult } from '../TestResults' ; import * as path from 'path' ; import { JestExtRequestType } from '../JestExt/process-session' ; import { ItBlock , TestAssertionStatus } from 'jest-editor-support' ; import { ContainerNode , DataNode , NodeType , ROOT_NODE_NAME } from '../TestResults/match-node' ; import { Logging } from '../logging' ; import { TestSuitChangeEvent } from '../TestResults/test-result-events' ; import { Debuggable , TestItemData , TestItemRun } from './types' ; import { JestTestProviderContext } from './test-provider-context' ; import { JestProcessInfo , JestProcessRequest } from '../JestProcessManagement' ; interface JestRunable { getJestRunRequest : ( ) => JestExtRequestType ; } interface WithUri { uri : vscode . Uri ; } type TestItemRunRequest = JestExtRequestType & { itemRun : TestItemRun ; } ; const isTestItemRunRequest = ( arg ) : arg is TestItemRunRequest => arg . itemRun ?. item && arg . itemRun ?. run && arg . itemRun ?. end ; const deepItemState = ( item , setState ) : void => { setState ( item ) ; item . children . forEach ( ( child ) => deepItemState ( child , setState ) ) ; } ; abstract class TestItemDataBase implements TestItemData , JestRunable , WithUri { item ! : vscode . TestItem ; log : Logging ; constructor ( public context , name ) { this . log = context . ext . loggingFactory . create ( name ) ; } get uri ( ) : vscode . Uri { return this . item . uri ! ; } scheduleTest ( run , end ) { const jestRequest = this . getJestRunRequest ( ) ; const itemRun = { item : this . item , run , end } ; deepItemState ( this . item , run . enqueued ) ; const process = this . context . ext . session . scheduleProcess ( { ... jestRequest , itemRun , } ) ; if ( ! process ) { const msg = `failed to schedule test for ${ this . item . id }` ; run . errored ( this . item , new vscode . TestMessage ( msg ) ) ; this . context . appendOutput ( msg , run , true , 'red' ) ; end ( ) ; } } abstract getJestRunRequest ( ) ; } /**
 * Goal of this class is to manage the TestItem hierarchy reflects DocumentRoot path. It is responsible
 * to create DocumentRoot for each test file by listening to the TestResultEvents.
 */ export class WorkspaceRoot extends TestItemDataBase { private testDocuments : Map < string , TestDocumentRoot > ; private listeners : vscode . Disposable [ ] ; private cachedRun : Map < string , TestItemRun > ; constructor ( context ) { super ( context , 'WorkspaceRoot' ) ; this . item = this . createTestItem ( ) ; this . testDocuments = new Map ( ) ; this . listeners = [ ] ; this . cachedRun = new Map ( ) ; this . registerEvents ( ) ; } createTestItem ( ) { const item = this . context . createTestItem ( `${ extensionId }:${ this . context . ext . workspace . name }` , this . context . ext . workspace . name , this . context . ext . workspace . uri , this , undefined , [ 'run' ] ) ; item . description = `(${ this . context . ext . autoRun . mode })` ; item . canResolveChildren = true ; return item ; } getJestRunRequest ( ) { const transform = ( request ) => { request . schedule . queue = 'blocking-2' ; return request ; } ; return { type : 'all-tests' , transform } ; } discoverTest ( run ) { const testList = this . context . ext . testResolveProvider . getTestList ( ) ; if ( testList . length > 0 ) { this . onTestListUpdated ( testList , run ) ; } else { run . end ( ) ; this . item . canResolveChildren = false ; } } private registerEvents = ( ) : void => { this . listeners = [ this . context . ext . testResolveProvider . events . testListUpdated . event ( this . onTestListUpdated ) , this . context . ext . testResolveProvider . events . testSuiteChanged . event ( this . onTestSuiteChanged ) , this . context . ext . sessionEvents . onRunEvent . event ( this . onRunEvent ) , ] ; } ; private unregisterEvents = ( ) : void => { this . listeners . forEach ( ( l ) => l . dispose ( ) ) ; this . listeners . length = 0 ; } ; private createRun = ( name ? , item ? ) : vscode . TestRun => { const target = item ?? this . item ; return this . context . createTestRun ( new vscode . TestRunRequest ( [ target ] ) , name ?? target . id ) ; } ; private addFolder = ( parent , folderName ) : FolderData => { const p = parent ?? this ; const uri = FolderData . makeUri ( p . item , folderName ) ; return ( this . context . getChildData < FolderData > ( p . item , uri . fsPath ) ?? new FolderData ( this . context , folderName , p . item ) ) ; } ; private addPath = ( absoluteFileName ) : FolderData | undefined => { const relativePath = path . relative ( this . context . ext . workspace . uri . fsPath , absoluteFileName ) ; const folders = relativePath . split ( path . sep ) . slice ( 0 , - 1 ) ; return folders . reduce ( this . addFolder , undefined ) ; } ; /**
     * create a test item hierarchy for the given the test file based on its reltive path. If the file is not
     * a test file, exception will be thrown.
     */ private addTestFile = ( absoluteFileName , onTestDocument ) : TestDocumentRoot => { const parent = this . addPath ( absoluteFileName ) ?? this ; let docRoot = this . context . getChildData < TestDocumentRoot > ( parent . item , absoluteFileName ) ; if ( ! docRoot ) { docRoot = this . testDocuments . get ( absoluteFileName ) ; if ( docRoot ) { parent . item . children . add ( docRoot . item ) ; } else { docRoot = new TestDocumentRoot ( this . context , vscode . Uri . file ( absoluteFileName ) , parent . item ) ; } } this . testDocuments . set ( absoluteFileName , docRoot ) ; onTestDocument ( docRoot ) ; return docRoot ; } ; /**
     * When test list updated, rebuild the whole testItem tree for all the test files (DocumentRoot).
     * But will reuse the existing test document from cache (testDocuments) to preserve info
     * such as parsed result that could be stored before test list update
     */ private onTestListUpdated = ( absoluteFileNames , run ? ) : void => { this . item . children . replace ( [ ] ) ; const testRoots = [ ] ; const aRun = run ?? this . createRun ( ) ; try { absoluteFileNames ?. forEach ( ( f ) => this . addTestFile ( f , ( testRoot ) => { testRoot . updateResultState ( aRun ) ; testRoots . push ( testRoot ) ; } ) ) ; this . testDocuments . clear ( ) ; testRoots . forEach ( ( t ) => this . testDocuments . set ( t . item . id , t ) ) ; } catch ( e ) { this . log ( 'error' , `[WorkspaceRoot] "${ this . item . id }" onTestListUpdated failed:` , e ) ; } finally { aRun . end ( ) ; } this . item . canResolveChildren = false ; } ; param event private onTestSuiteChanged = ( event ) : void => { switch ( event . type ) { case 'assertions-updated' : { const itemRun = this . getItemRun ( event . process ) ; const run = itemRun ?. run ?? this . createRun ( event . process . id ) ; this . context . appendOutput ( `update status from run "${ event . process . id }": ${ event . files . length } files` , run ) ; try { event . files . forEach ( ( f ) => this . addTestFile ( f , ( testRoot ) => testRoot . discoverTest ( run ) ) ) ; } catch ( e ) { this . log ( 'error' , `"${ this . item . id }" onTestSuiteChanged: assertions-updated failed:` , e ) ; } finally { ( itemRun ?? run ) . end ( ) ; } break ; } case 'result-matched' : { this . addTestFile ( event . file , ( testRoot ) => testRoot . onTestMatched ( ) ) ; break ; } case 'test-parsed' : { this . addTestFile ( event . file , ( testRoot ) => testRoot . discoverTest ( undefined , event . testContainer ) ) ; } } } ; /** get test item from jest process. If running tests from source file, will return undefined */ private getItemFromProcess = ( process ) : vscode . TestItem | undefined => { let fileName ; switch ( process . request . type ) { case 'watch-tests' : case 'watch-all-tests' : case 'all-tests' : return this . item ; case 'by-file' : fileName = process . request . testFileName ; break ; case 'by-file-pattern' : fileName = process . request . testFileNamePattern ; break ; default : throw new Error ( `unsupported external process type ${ process . request . type }` ) ; } return this . testDocuments . get ( fileName ) ?. item ; } ; private createTestItemRun = ( event ) : TestItemRun => { const item = this . getItemFromProcess ( event . process ) ?? this . item ; const run = this . createRun ( `${ event . type }:${ event . process . id }` , item ) ; const end = ( ) => { this . cachedRun . delete ( event . process . id ) ; run . end ( ) ; } ; const itemRun = { item , run , end } ; this . cachedRun . set ( event . process . id , itemRun ) ; return itemRun ; } ; private getItemRun = ( process ) : TestItemRun | undefined => isTestItemRunRequest ( process . request ) ? process . request . itemRun : this . cachedRun . get ( process . id ) ; private onRunEvent = ( event ) => { if ( event . process . request . type === 'not-test' ) { return ; } let itemRun = this . getItemRun ( event . process ) ; try { switch ( event . type ) { case 'scheduled' : { if ( ! itemRun ) { itemRun = this . createTestItemRun ( event ) ; const text = `Scheduled test run "${ event . process . id }" for "${ itemRun . item . id }"` ; this . context . appendOutput ( text , itemRun . run ) ; deepItemState ( itemRun . item , itemRun . run . enqueued ) ; } break ; } case 'data' : { itemRun = itemRun ?? this . createTestItemRun ( event ) ; const text = event . raw ?? event . text ; const color = event . isError ? 'red' : undefined ; this . context . appendOutput ( text , itemRun . run , event . newLine ?? false , color ) ; break ; } case 'start' : { itemRun = itemRun ?? this . createTestItemRun ( event ) ; deepItemState ( itemRun . item , itemRun . run . started ) ; break ; } case 'end' : { itemRun ?. end ( ) ; break ; } case 'exit' : { if ( event . error ) { if ( ! itemRun || itemRun . run . token . isCancellationRequested ) { itemRun = this . createTestItemRun ( event ) ; } this . context . appendOutput ( event . error , itemRun . run , true , 'red' ) ; itemRun . run . errored ( itemRun . item , new vscode . TestMessage ( event . error ) ) ; } itemRun ?. end ( ) ; break ; } } } catch ( err ) { this . log ( 'error' , `<onRunEvent> ${ event . type } failed:` , err ) ; } } ; dispose ( ) { this . unregisterEvents ( ) ; this . cachedRun . forEach ( ( run ) => run . end ( ) ) ; } } export class FolderData extends TestItemDataBase { static makeUri = ( parent , folderName ) : vscode . Uri => { return vscode . Uri . joinPath ( parent . uri ! , folderName ) ; } ; constructor ( readonly context , readonly name , parent ) { super ( context , 'FolderData' ) ; this . item = this . createTestItem ( name , parent ) ; } private createTestItem ( name , parent ) { const uri = FolderData . makeUri ( parent , name ) ; const item = this . context . createTestItem ( uri . fsPath , name , uri , this , parent , [ 'run' ] ) ; item . canResolveChildren = false ; return item ; } getJestRunRequest ( ) { return { type : 'by-file-pattern' , testFileNamePattern : this . uri . fsPath , } ; } } type ItemNodeType = NodeType < ItBlock | TestAssertionStatus > ; type ItemDataNodeType = DataNode < ItBlock | TestAssertionStatus > ; const isDataNode = ( arg ) : arg is ItemDataNodeType => ( arg as any ) . data != null ; const isAssertDataNode = ( arg ) : arg is DataNode < TestAssertionStatus > => isDataNode ( arg ) && ( arg . data as any ) . fullName ; abstract class TestResultData extends TestItemDataBase { constructor ( readonly context , name ) { super ( context , name ) ; } updateItemState ( run , result ? , errorLocation ? ) { if ( ! result ) { return ; } const status = result . status ; switch ( status ) { case 'KnownSuccess' : run . passed ( this . item ) ; break ; case 'KnownSkip' : case 'KnownTodo' : run . skipped ( this . item ) ; break ; case 'KnownFail' : { if ( this . context . ext . settings . testExplorer . enabled && this . context . ext . settings . testExplorer . showInlineError ) { const message = new vscode . TestMessage ( result . message ) ; if ( errorLocation ) { message . location = errorLocation ; } run . failed ( this . item , message ) ; } else { run . failed ( this . item , [ ] ) ; } break ; } } } makeTestId ( fileUri , target ? , extra ? ) { const parts = [ fileUri . fsPath ] ; if ( target && target . name !== ROOT_NODE_NAME ) { parts . push ( target . fullName ) ; } if ( extra ) { parts . push ( extra ) ; } return parts . join ( '#' ) ; } isSameId ( id1 , id2 ) { if ( id1 === id2 ) { return true ; } const truncateExtra = ( id ) : string => id . replace ( /(.*)(#[0-9]+$)/ , '$1' ) ; return truncateExtra ( id1 ) === truncateExtra ( id2 ) ; } syncChildNodes ( node ) { const testId = this . makeTestId ( this . uri , node ) ; if ( ! this . isSameId ( testId , this . item . id ) ) { this . item . error = 'invalid node' ; return ; } this . item . error = undefined ; if ( ! isDataNode ( node ) ) { const idMap = [ ... node . childContainers , ... node . childData ] . flatMap ( ( n ) => n . getAll ( ) as ItemDataNodeType [ ] ) . reduce ( ( map , node ) => { const id = this . makeTestId ( this . uri , node ) ; map . set ( id , map . get ( id ) ?. concat ( node ) ?? [ node ] ) ; return map ; } , new Map < string , ItemDataNodeType [ ] > ( ) ) ; const newItems = [ ] ; idMap . forEach ( ( nodes , id ) => { if ( nodes . length > 1 ) { nodes . forEach ( ( n , idx ) => { newItems . push ( new TestData ( this . context , this . uri , n , this . item , `${ idx }` ) . item ) ; } ) ; return ; } let cItem = this . item . children . get ( id ) ; if ( cItem ) { this . context . getData < TestData > ( cItem ) ?. updateNode ( nodes [ 0 ] ) ; } else { cItem = new TestData ( this . context , this . uri , nodes [ 0 ] , this . item ) . item ; } newItems . push ( cItem ) ; } ) ; this . item . children . replace ( newItems ) ; } else { this . item . children . replace ( [ ] ) ; } } createLocation ( uri , zeroBasedLine = 0 ) { return new vscode . Location ( uri , new vscode . Range ( new vscode . Position ( zeroBasedLine , 0 ) , new vscode . Position ( zeroBasedLine , 0 ) ) ) ; } } export class TestDocumentRoot extends TestResultData { constructor ( readonly context , fileUri , parent ) { super ( context , 'TestDocumentRoot' ) ; this . item = this . createTestItem ( fileUri , parent ) ; } private createTestItem ( fileUri , parent ) { const item = this . context . createTestItem ( this . makeTestId ( fileUri ) , path . basename ( fileUri . fsPath ) , fileUri , this , parent ) ; item . canResolveChildren = true ; return item ; } discoverTest = ( run ? , parsedRoot ? ) : void => { this . createChildItems ( parsedRoot ) ; if ( run ) { this . updateResultState ( run ) ; } } ; private createChildItems = ( parsedRoot ? ) : void => { try { const container = parsedRoot ?? this . context . ext . testResolveProvider . getTestSuiteResult ( this . item . id ) ?. assertionContainer ; if ( ! container ) { this . item . children . replace ( [ ] ) ; } else { this . syncChildNodes ( container ) ; } } catch ( e ) { this . log ( 'error' , `[TestDocumentRoot] "${ this . item . id }" createChildItems failed:` , e ) ; } finally { this . item . canResolveChildren = false ; } } ; public updateResultState ( run ) { const suiteResult = this . context . ext . testResolveProvider . getTestSuiteResult ( this . item . id ) ; this . updateItemState ( run , suiteResult ) ; this . item . children . forEach ( ( childItem ) => this . context . getData < TestData > ( childItem ) ?. updateResultState ( run ) ) ; } getJestRunRequest = ( ) : JestExtRequestType => { return { type : 'by-file-pattern' , testFileNamePattern : this . uri . fsPath , } ; } ; getDebugInfo ( ) { return { fileName : this . uri . fsPath } ; } public onTestMatched = ( ) : void => { this . item . children . forEach ( ( childItem ) => this . context . getData < TestData > ( childItem ) ?. onTestMatched ( ) ) ; } ; } export class TestData extends TestResultData implements Debuggable { constructor ( readonly context , fileUri , private node , parent , extraId ? ) { super ( context , 'TestData' ) ; this . item = this . createTestItem ( fileUri , parent , extraId ) ; this . updateNode ( node ) ; } private createTestItem ( fileUri , parent , extraId ? ) { const item = this . context . createTestItem ( this . makeTestId ( fileUri , this . node , extraId ) , this . node . name , fileUri , this , parent ) ; item . canResolveChildren = false ; return item ; } getJestRunRequest ( ) { return { type : 'by-file-test-pattern' , testFileNamePattern : this . uri . fsPath , testNamePattern : this . node . fullName , } ; } getDebugInfo ( ) { return { fileName : this . uri . fsPath , testNamePattern : this . node . fullName } ; } private updateItemRange ( ) { if ( this . node . attrs . range ) { const pos = [ this . node . attrs . range . start . line , this . node . attrs . range . start . column , this . node . attrs . range . end . line , this . node . attrs . range . end . column , ] ; if ( pos . every ( ( n ) => n >= 0 ) ) { this . item . range = new vscode . Range ( pos [ 0 ] , pos [ 1 ] , pos [ 2 ] , pos [ 3 ] ) ; return ; } } this . item . range = undefined ; } updateNode ( node ) { this . node = node ; this . updateItemRange ( ) ; this . syncChildNodes ( node ) ; } public onTestMatched ( ) { this . updateItemRange ( ) ; this . item . children . forEach ( ( childItem ) => this . context . getData < TestData > ( childItem ) ?. onTestMatched ( ) ) ; } public updateResultState ( run ) { if ( this . node && isAssertDataNode ( this . node ) ) { const assertion = this . node . data ; const errorLine = assertion . line != null ? this . createLocation ( this . uri , assertion . line - 1 ) : undefined ; this . updateItemState ( run , assertion , errorLine ) ; } this . item . children . forEach ( ( childItem ) => this . context . getData < TestData > ( childItem ) ?. updateResultState ( run ) ) ; } }