2023-03-24 16:41:20 -07:00
/ * *
* Copyright Microsoft Corporation . All rights reserved .
*
* Licensed under the Apache License , Version 2.0 ( the "License" ) ;
* you may not use this file except in compliance with the License .
* You may obtain a copy of the License at
*
* http : //www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing , software
* distributed under the License is distributed on an "AS IS" BASIS ,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND , either express or implied .
* See the License for the specific language governing permissions and
* limitations under the License .
* /
import readline from 'readline' ;
import { createGuid , ManualPromise } from 'playwright-core/lib/utils' ;
2023-04-07 09:54:01 -07:00
import type { FullConfigInternal , FullProjectInternal } from '../common/config' ;
2023-03-24 16:41:20 -07:00
import { Multiplexer } from '../reporters/multiplexer' ;
import { createFileMatcher , createFileMatcherFromArguments } from '../util' ;
import type { Matcher } from '../util' ;
2023-04-06 11:20:24 -07:00
import { TestRun , createTaskRunnerForWatch , createTaskRunnerForWatchSetup } from './tasks' ;
2023-03-24 16:41:20 -07:00
import { buildProjectsClosure , filterProjects } from './projectUtils' ;
import { clearCompilationCache , collectAffectedTestFiles } from '../common/compilationCache' ;
import type { FullResult } from 'packages/playwright-test/reporter' ;
import { chokidar } from '../utilsBundle' ;
import type { FSWatcher as CFSWatcher } from 'chokidar' ;
import { createReporter } from './reporters' ;
import { colors } from 'playwright-core/lib/utilsBundle' ;
import { enquirer } from '../utilsBundle' ;
import { separator } from '../reporters/base' ;
import { PlaywrightServer } from 'playwright-core/lib/remote/playwrightServer' ;
import ListReporter from '../reporters/list' ;
class FSWatcher {
private _dirtyTestFiles = new Map < FullProjectInternal , Set < string > > ( ) ;
private _notifyDirtyFiles : ( ( ) = > void ) | undefined ;
private _watcher : CFSWatcher | undefined ;
private _timer : NodeJS.Timeout | undefined ;
async update ( config : FullConfigInternal ) {
2023-04-07 09:54:01 -07:00
const commandLineFileMatcher = config . cliArgs . length ? createFileMatcherFromArguments ( config . cliArgs ) : ( ) = > true ;
const projects = filterProjects ( config . projects , config . cliProjectFilter ) ;
2023-03-24 16:41:20 -07:00
const projectClosure = buildProjectsClosure ( projects ) ;
const projectFilters = new Map < FullProjectInternal , Matcher > ( ) ;
2023-04-06 11:20:24 -07:00
for ( const [ project , type ] of projectClosure ) {
2023-04-07 09:54:01 -07:00
const testMatch = createFileMatcher ( project . project . testMatch ) ;
const testIgnore = createFileMatcher ( project . project . testIgnore ) ;
2023-03-24 16:41:20 -07:00
projectFilters . set ( project , file = > {
2023-04-07 09:54:01 -07:00
if ( ! file . startsWith ( project . project . testDir ) || ! testMatch ( file ) || testIgnore ( file ) )
2023-03-24 16:41:20 -07:00
return false ;
2023-04-06 11:20:24 -07:00
return type === 'dependency' || commandLineFileMatcher ( file ) ;
2023-03-24 16:41:20 -07:00
} ) ;
}
if ( this . _timer )
clearTimeout ( this . _timer ) ;
if ( this . _watcher )
await this . _watcher . close ( ) ;
2023-04-07 09:54:01 -07:00
this . _watcher = chokidar . watch ( [ . . . projectClosure . keys ( ) ] . map ( p = > p . project . testDir ) , { ignoreInitial : true } ) . on ( 'all' , async ( event , file ) = > {
2023-03-24 16:41:20 -07:00
if ( event !== 'add' && event !== 'change' )
return ;
const testFiles = new Set < string > ( ) ;
collectAffectedTestFiles ( file , testFiles ) ;
const testFileArray = [ . . . testFiles ] ;
let hasMatches = false ;
for ( const [ project , filter ] of projectFilters ) {
const filteredFiles = testFileArray . filter ( filter ) ;
if ( ! filteredFiles . length )
continue ;
let set = this . _dirtyTestFiles . get ( project ) ;
if ( ! set ) {
set = new Set ( ) ;
this . _dirtyTestFiles . set ( project , set ) ;
}
filteredFiles . map ( f = > set ! . add ( f ) ) ;
hasMatches = true ;
}
if ( ! hasMatches )
return ;
if ( this . _timer )
clearTimeout ( this . _timer ) ;
this . _timer = setTimeout ( ( ) = > {
this . _notifyDirtyFiles ? . ( ) ;
} , 250 ) ;
} ) ;
}
async onDirtyTestFiles ( ) : Promise < void > {
if ( this . _dirtyTestFiles . size )
return ;
await new Promise < void > ( f = > this . _notifyDirtyFiles = f ) ;
}
takeDirtyTestFiles ( ) : Map < FullProjectInternal , Set < string > > {
const result = this . _dirtyTestFiles ;
this . _dirtyTestFiles = new Map ( ) ;
return result ;
}
}
export async function runWatchModeLoop ( config : FullConfigInternal ) : Promise < FullResult [ 'status' ] > {
// Reset the settings that don't apply to watch.
2023-04-07 17:46:47 -07:00
config . cliPassWithNoTests = true ;
2023-03-24 16:41:20 -07:00
for ( const p of config . projects )
2023-04-07 09:54:01 -07:00
p . project . retries = 0 ;
2023-03-24 16:41:20 -07:00
// Perform global setup.
const reporter = await createReporter ( config , 'watch' ) ;
2023-04-06 11:20:24 -07:00
const testRun = new TestRun ( config , reporter ) ;
2023-03-24 16:41:20 -07:00
const taskRunner = createTaskRunnerForWatchSetup ( config , reporter ) ;
reporter . onConfigure ( config ) ;
2023-04-06 11:20:24 -07:00
const { status , cleanup : globalCleanup } = await taskRunner . runDeferCleanup ( testRun , 0 ) ;
2023-03-24 16:41:20 -07:00
if ( status !== 'passed' )
return await globalCleanup ( ) ;
// Prepare projects that will be watched, set up watcher.
const failedTestIdCollector = new Set < string > ( ) ;
2023-04-07 09:54:01 -07:00
const originalWorkers = config . config . workers ;
2023-03-24 16:41:20 -07:00
const fsWatcher = new FSWatcher ( ) ;
await fsWatcher . update ( config ) ;
let lastRun : { type : 'changed' | 'regular' | 'failed' , failedTestIds? : Set < string > , dirtyTestFiles? : Map < FullProjectInternal , Set < string > > } = { type : 'regular' } ;
let result : FullResult [ 'status' ] = 'passed' ;
// Enter the watch loop.
await runTests ( config , failedTestIdCollector ) ;
while ( true ) {
printPrompt ( ) ;
const readCommandPromise = readCommand ( ) ;
await Promise . race ( [
fsWatcher . onDirtyTestFiles ( ) ,
readCommandPromise ,
] ) ;
if ( ! readCommandPromise . isDone ( ) )
readCommandPromise . resolve ( 'changed' ) ;
const command = await readCommandPromise ;
if ( command === 'changed' ) {
const dirtyTestFiles = fsWatcher . takeDirtyTestFiles ( ) ;
// Resolve files that depend on the changed files.
await runChangedTests ( config , failedTestIdCollector , dirtyTestFiles ) ;
lastRun = { type : 'changed' , dirtyTestFiles } ;
continue ;
}
if ( command === 'run' ) {
// All means reset filters.
await runTests ( config , failedTestIdCollector ) ;
lastRun = { type : 'regular' } ;
continue ;
}
if ( command === 'project' ) {
const { projectNames } = await enquirer . prompt < { projectNames : string [ ] } > ( {
type : 'multiselect' ,
name : 'projectNames' ,
message : 'Select projects' ,
2023-04-07 09:54:01 -07:00
choices : config.projects.map ( p = > ( { name : p.project.name } ) ) ,
2023-03-24 16:41:20 -07:00
} ) . catch ( ( ) = > ( { projectNames : null } ) ) ;
if ( ! projectNames )
continue ;
2023-04-07 09:54:01 -07:00
config . cliProjectFilter = projectNames . length ? projectNames : undefined ;
2023-03-24 16:41:20 -07:00
await fsWatcher . update ( config ) ;
await runTests ( config , failedTestIdCollector ) ;
lastRun = { type : 'regular' } ;
continue ;
}
if ( command === 'file' ) {
const { filePattern } = await enquirer . prompt < { filePattern : string } > ( {
type : 'text' ,
name : 'filePattern' ,
message : 'Input filename pattern (regex)' ,
} ) . catch ( ( ) = > ( { filePattern : null } ) ) ;
if ( filePattern === null )
continue ;
if ( filePattern . trim ( ) )
2023-04-07 09:54:01 -07:00
config . cliArgs = filePattern . split ( ' ' ) ;
2023-03-24 16:41:20 -07:00
else
2023-04-07 09:54:01 -07:00
config . cliArgs = [ ] ;
2023-03-24 16:41:20 -07:00
await fsWatcher . update ( config ) ;
await runTests ( config , failedTestIdCollector ) ;
lastRun = { type : 'regular' } ;
continue ;
}
if ( command === 'grep' ) {
const { testPattern } = await enquirer . prompt < { testPattern : string } > ( {
type : 'text' ,
name : 'testPattern' ,
message : 'Input test name pattern (regex)' ,
} ) . catch ( ( ) = > ( { testPattern : null } ) ) ;
if ( testPattern === null )
continue ;
if ( testPattern . trim ( ) )
2023-04-07 09:54:01 -07:00
config . cliGrep = testPattern ;
2023-03-24 16:41:20 -07:00
else
2023-04-07 09:54:01 -07:00
config . cliGrep = undefined ;
2023-03-24 16:41:20 -07:00
await fsWatcher . update ( config ) ;
await runTests ( config , failedTestIdCollector ) ;
lastRun = { type : 'regular' } ;
continue ;
}
if ( command === 'failed' ) {
2023-04-07 09:54:01 -07:00
config . testIdMatcher = id = > failedTestIdCollector . has ( id ) ;
2023-03-24 16:41:20 -07:00
const failedTestIds = new Set ( failedTestIdCollector ) ;
await runTests ( config , failedTestIdCollector , { title : 'running failed tests' } ) ;
2023-04-07 09:54:01 -07:00
config . testIdMatcher = undefined ;
2023-03-24 16:41:20 -07:00
lastRun = { type : 'failed' , failedTestIds } ;
continue ;
}
if ( command === 'repeat' ) {
if ( lastRun . type === 'regular' ) {
await runTests ( config , failedTestIdCollector , { title : 're-running tests' } ) ;
continue ;
} else if ( lastRun . type === 'changed' ) {
await runChangedTests ( config , failedTestIdCollector , lastRun . dirtyTestFiles ! , 're-running tests' ) ;
} else if ( lastRun . type === 'failed' ) {
2023-04-07 09:54:01 -07:00
config . testIdMatcher = id = > lastRun . failedTestIds ! . has ( id ) ;
2023-03-24 16:41:20 -07:00
await runTests ( config , failedTestIdCollector , { title : 're-running tests' } ) ;
2023-04-07 09:54:01 -07:00
config . testIdMatcher = undefined ;
2023-03-24 16:41:20 -07:00
}
continue ;
}
if ( command === 'toggle-show-browser' ) {
await toggleShowBrowser ( config , originalWorkers ) ;
continue ;
}
if ( command === 'exit' )
break ;
if ( command === 'interrupted' ) {
result = 'interrupted' ;
break ;
}
}
return result === 'passed' ? await globalCleanup ( ) : result ;
}
async function runChangedTests ( config : FullConfigInternal , failedTestIdCollector : Set < string > , filesByProject : Map < FullProjectInternal , Set < string > > , title? : string ) {
const testFiles = new Set < string > ( ) ;
for ( const files of filesByProject . values ( ) )
files . forEach ( f = > testFiles . add ( f ) ) ;
// Collect all the affected projects, follow project dependencies.
// Prepare to exclude all the projects that do not depend on this file, as if they did not exist.
2023-04-07 09:54:01 -07:00
const projects = filterProjects ( config . projects , config . cliProjectFilter ) ;
2023-03-24 16:41:20 -07:00
const projectClosure = buildProjectsClosure ( projects ) ;
2023-04-06 11:20:24 -07:00
const affectedProjects = affectedProjectsClosure ( [ . . . projectClosure . keys ( ) ] , [ . . . filesByProject . keys ( ) ] ) ;
const affectsAnyDependency = [ . . . affectedProjects ] . some ( p = > projectClosure . get ( p ) === 'dependency' ) ;
2023-03-24 16:41:20 -07:00
// If there are affected dependency projects, do the full run, respect the original CLI.
// if there are no affected dependency projects, intersect CLI with dirty files
const additionalFileMatcher = affectsAnyDependency ? ( ) = > true : ( file : string ) = > testFiles . has ( file ) ;
2023-04-06 11:20:24 -07:00
await runTests ( config , failedTestIdCollector , { additionalFileMatcher , title : title || 'files changed' } ) ;
2023-03-24 16:41:20 -07:00
}
async function runTests ( config : FullConfigInternal , failedTestIdCollector : Set < string > , options ? : {
projectsToIgnore? : Set < FullProjectInternal > ,
additionalFileMatcher? : Matcher ,
title? : string ,
} ) {
printConfiguration ( config , options ? . title ) ;
const reporter = new Multiplexer ( [ new ListReporter ( ) ] ) ;
2023-04-06 11:20:24 -07:00
const taskRunner = createTaskRunnerForWatch ( config , reporter , options ? . additionalFileMatcher ) ;
const testRun = new TestRun ( config , reporter ) ;
2023-03-24 16:41:20 -07:00
clearCompilationCache ( ) ;
reporter . onConfigure ( config ) ;
2023-04-06 11:20:24 -07:00
const taskStatus = await taskRunner . run ( testRun , 0 ) ;
2023-03-24 16:41:20 -07:00
let status : FullResult [ 'status' ] = 'passed' ;
let hasFailedTests = false ;
2023-04-06 11:20:24 -07:00
for ( const test of testRun . rootSuite ? . allTests ( ) || [ ] ) {
2023-03-24 16:41:20 -07:00
if ( test . outcome ( ) === 'unexpected' ) {
failedTestIdCollector . add ( test . id ) ;
hasFailedTests = true ;
} else {
failedTestIdCollector . delete ( test . id ) ;
}
}
2023-04-06 11:20:24 -07:00
if ( testRun . phases . find ( p = > p . dispatcher . hasWorkerErrors ( ) ) || hasFailedTests )
2023-03-24 16:41:20 -07:00
status = 'failed' ;
if ( status === 'passed' && taskStatus !== 'passed' )
status = taskStatus ;
await reporter . onExit ( { status } ) ;
}
function affectedProjectsClosure ( projectClosure : FullProjectInternal [ ] , affected : FullProjectInternal [ ] ) : Set < FullProjectInternal > {
const result = new Set < FullProjectInternal > ( affected ) ;
for ( let i = 0 ; i < projectClosure . length ; ++ i ) {
for ( const p of projectClosure ) {
2023-04-07 09:54:01 -07:00
for ( const dep of p . deps ) {
2023-03-24 16:41:20 -07:00
if ( result . has ( dep ) )
result . add ( p ) ;
}
}
}
return result ;
}
function readCommand ( ) : ManualPromise < Command > {
const result = new ManualPromise < Command > ( ) ;
const rl = readline . createInterface ( { input : process.stdin , escapeCodeTimeout : 50 } ) ;
readline . emitKeypressEvents ( process . stdin , rl ) ;
if ( process . stdin . isTTY )
process . stdin . setRawMode ( true ) ;
const handler = ( text : string , key : any ) = > {
if ( text === '\x03' || text === '\x1B' || ( key && key . name === 'escape' ) || ( key && key . ctrl && key . name === 'c' ) ) {
result . resolve ( 'interrupted' ) ;
return ;
}
if ( process . platform !== 'win32' && key && key . ctrl && key . name === 'z' ) {
process . kill ( process . ppid , 'SIGTSTP' ) ;
process . kill ( process . pid , 'SIGTSTP' ) ;
}
const name = key ? . name ;
if ( name === 'q' ) {
result . resolve ( 'exit' ) ;
return ;
}
if ( name === 'h' ) {
process . stdout . write ( ` ${ separator ( ) }
Run tests
$ { colors . bold ( 'enter' ) } $ { colors . dim ( 'run tests' ) }
$ { colors . bold ( 'f' ) } $ { colors . dim ( 'run failed tests' ) }
$ { colors . bold ( 'r' ) } $ { colors . dim ( 'repeat last run' ) }
$ { colors . bold ( 'q' ) } $ { colors . dim ( 'quit' ) }
Change settings
$ { colors . bold ( 'c' ) } $ { colors . dim ( 'set project' ) }
$ { colors . bold ( 'p' ) } $ { colors . dim ( 'set file filter' ) }
$ { colors . bold ( 't' ) } $ { colors . dim ( 'set title filter' ) }
$ { colors . bold ( 's' ) } $ { colors . dim ( 'toggle show & reuse the browser' ) }
` );
return ;
}
switch ( name ) {
case 'return' : result . resolve ( 'run' ) ; break ;
case 'r' : result . resolve ( 'repeat' ) ; break ;
case 'c' : result . resolve ( 'project' ) ; break ;
case 'p' : result . resolve ( 'file' ) ; break ;
case 't' : result . resolve ( 'grep' ) ; break ;
case 'f' : result . resolve ( 'failed' ) ; break ;
case 's' : result . resolve ( 'toggle-show-browser' ) ; break ;
}
} ;
process . stdin . on ( 'keypress' , handler ) ;
result . finally ( ( ) = > {
process . stdin . off ( 'keypress' , handler ) ;
rl . close ( ) ;
if ( process . stdin . isTTY )
process . stdin . setRawMode ( false ) ;
} ) ;
return result ;
}
let showBrowserServer : PlaywrightServer | undefined ;
let seq = 0 ;
function printConfiguration ( config : FullConfigInternal , title? : string ) {
const tokens : string [ ] = [ ] ;
tokens . push ( 'npx playwright test' ) ;
2023-04-07 09:54:01 -07:00
tokens . push ( . . . ( config . cliProjectFilter || [ ] ) ? . map ( p = > colors . blue ( ` --project ${ p } ` ) ) ) ;
if ( config . cliGrep )
tokens . push ( colors . red ( ` --grep ${ config . cliGrep } ` ) ) ;
if ( config . cliArgs )
tokens . push ( . . . config . cliArgs . map ( a = > colors . bold ( a ) ) ) ;
2023-03-24 16:41:20 -07:00
if ( title )
tokens . push ( colors . dim ( ` ( ${ title } ) ` ) ) ;
if ( seq )
tokens . push ( colors . dim ( ` # ${ seq } ` ) ) ;
++ seq ;
const lines : string [ ] = [ ] ;
const sep = separator ( ) ;
lines . push ( '\x1Bc' + sep ) ;
lines . push ( ` ${ tokens . join ( ' ' ) } ` ) ;
lines . push ( ` ${ colors . dim ( 'Show & reuse browser:' ) } ${ colors . bold ( showBrowserServer ? 'on' : 'off' ) } ` ) ;
process . stdout . write ( lines . join ( '\n' ) ) ;
}
function printPrompt() {
const sep = separator ( ) ;
process . stdout . write ( `
$ { sep }
$ { colors . dim ( 'Waiting for file changes. Press' ) } $ { colors . bold ( 'enter' ) } $ { colors . dim ( 'to run tests' ) } , $ { colors . bold ( 'q' ) } $ { colors . dim ( 'to quit or' ) } $ { colors . bold ( 'h' ) } $ { colors . dim ( 'for more options.' ) }
` );
}
async function toggleShowBrowser ( config : FullConfigInternal , originalWorkers : number ) {
if ( ! showBrowserServer ) {
2023-04-07 09:54:01 -07:00
config . config . workers = 1 ;
2023-03-24 16:41:20 -07:00
showBrowserServer = new PlaywrightServer ( { path : '/' + createGuid ( ) , maxConnections : 1 } ) ;
const wsEndpoint = await showBrowserServer . listen ( ) ;
process . env . PW_TEST_REUSE_CONTEXT = '1' ;
process . env . PW_TEST_CONNECT_WS_ENDPOINT = wsEndpoint ;
process . stdout . write ( ` ${ colors . dim ( 'Show & reuse browser:' ) } ${ colors . bold ( 'on' ) } \ n ` ) ;
} else {
2023-04-07 09:54:01 -07:00
config . config . workers = originalWorkers ;
2023-03-24 16:41:20 -07:00
await showBrowserServer ? . close ( ) ;
showBrowserServer = undefined ;
delete process . env . PW_TEST_REUSE_CONTEXT ;
delete process . env . PW_TEST_CONNECT_WS_ENDPOINT ;
process . stdout . write ( ` ${ colors . dim ( 'Show & reuse browser:' ) } ${ colors . bold ( 'off' ) } \ n ` ) ;
}
}
type Command = 'run' | 'failed' | 'repeat' | 'changed' | 'project' | 'file' | 'grep' | 'exit' | 'interrupted' | 'toggle-show-browser' ;