2021-06-06 17:09:53 -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 .
* /
2021-09-23 11:56:39 -04:00
import { formatLocation , wrapInPromise , debugTest } from './util' ;
2021-06-06 17:09:53 -07:00
import * as crypto from 'crypto' ;
2022-01-10 20:25:56 -08:00
import { FixturesWithLocation , Location , WorkerInfo , TestInfo } from './types' ;
2021-12-20 16:19:21 -08:00
import { ManualPromise } from 'playwright-core/lib/utils/async' ;
2021-06-06 17:09:53 -07:00
2021-08-09 12:33:16 -07:00
type FixtureScope = 'test' | 'worker' ;
2021-12-22 09:59:58 -08:00
type FixtureOptions = { auto? : boolean , scope? : FixtureScope , option? : boolean } ;
type FixtureTuple = [ value : any , options : FixtureOptions ] ;
2021-06-06 17:09:53 -07:00
type FixtureRegistration = {
2021-12-22 09:59:58 -08:00
location : Location ; // Fixutre registration location.
2021-06-06 17:09:53 -07:00
name : string ;
scope : FixtureScope ;
fn : Function | any ; // Either a fixture function, or a fixture value.
auto : boolean ;
2021-12-22 09:59:58 -08:00
option : boolean ;
deps : string [ ] ; // Names of the dependencies, ({ foo, bar }) => {...}
id : string ; // Unique id, to differentiate between fixtures with the same name.
super ? : FixtureRegistration ; // A fixture override can use the previous version of the fixture.
2021-06-06 17:09:53 -07:00
} ;
class Fixture {
runner : FixtureRunner ;
registration : FixtureRegistration ;
usages : Set < Fixture > ;
value : any ;
2021-12-20 16:19:21 -08:00
_useFuncFinished : ManualPromise < void > | undefined ;
_selfTeardownComplete : Promise < void > | undefined ;
_teardownWithDepsComplete : Promise < void > | undefined ;
2021-06-06 17:09:53 -07:00
constructor ( runner : FixtureRunner , registration : FixtureRegistration ) {
this . runner = runner ;
this . registration = registration ;
this . usages = new Set ( ) ;
this . value = null ;
}
2021-08-09 13:26:33 -07:00
async setup ( workerInfo : WorkerInfo , testInfo : TestInfo | undefined ) {
2021-06-06 17:09:53 -07:00
if ( typeof this . registration . fn !== 'function' ) {
this . value = this . registration . fn ;
return ;
}
const params : { [ key : string ] : any } = { } ;
for ( const name of this . registration . deps ) {
2021-06-23 10:30:54 -07:00
const registration = this . runner . pool ! . resolveDependency ( this . registration , name ) ! ;
2021-08-09 13:26:33 -07:00
const dep = await this . runner . setupFixtureForRegistration ( registration , workerInfo , testInfo ) ;
2021-06-06 17:09:53 -07:00
dep . usages . add ( this ) ;
params [ name ] = dep . value ;
}
let called = false ;
2021-12-20 16:19:21 -08:00
const useFuncStarted = new ManualPromise < void > ( ) ;
2021-09-23 11:56:39 -04:00
debugTest ( ` setup ${ this . registration . name } ` ) ;
2021-12-20 16:19:21 -08:00
const useFunc = async ( value : any ) = > {
2021-06-06 17:09:53 -07:00
if ( called )
2021-06-23 10:30:54 -07:00
throw new Error ( ` Cannot provide fixture value for the second time ` ) ;
2021-06-06 17:09:53 -07:00
called = true ;
this . value = value ;
2021-12-20 16:19:21 -08:00
this . _useFuncFinished = new ManualPromise < void > ( ) ;
useFuncStarted . resolve ( ) ;
await this . _useFuncFinished ;
} ;
const info = this . registration . scope === 'worker' ? workerInfo : testInfo ;
this . _selfTeardownComplete = wrapInPromise ( this . registration . fn ( params , useFunc , info ) ) . catch ( ( e : any ) = > {
if ( ! useFuncStarted . isDone ( ) )
useFuncStarted . reject ( e ) ;
2021-06-06 17:09:53 -07:00
else
throw e ;
} ) ;
2021-12-20 16:19:21 -08:00
await useFuncStarted ;
2021-06-06 17:09:53 -07:00
}
async teardown() {
2021-12-20 16:19:21 -08:00
if ( ! this . _teardownWithDepsComplete )
this . _teardownWithDepsComplete = this . _teardownInternal ( ) ;
await this . _teardownWithDepsComplete ;
}
private async _teardownInternal() {
2021-06-06 17:09:53 -07:00
if ( typeof this . registration . fn !== 'function' )
return ;
for ( const fixture of this . usages )
await fixture . teardown ( ) ;
this . usages . clear ( ) ;
2021-12-20 16:19:21 -08:00
if ( this . _useFuncFinished ) {
2021-09-23 11:56:39 -04:00
debugTest ( ` teardown ${ this . registration . name } ` ) ;
2021-12-20 16:19:21 -08:00
this . _useFuncFinished . resolve ( ) ;
await this . _selfTeardownComplete ;
2021-06-06 17:09:53 -07:00
}
this . runner . instanceForId . delete ( this . registration . id ) ;
}
}
2021-12-22 09:59:58 -08:00
function isFixtureTuple ( value : any ) : value is FixtureTuple {
feat(test runner): replace declare/define with "options" (#10293)
1. Fixtures defined in test.extend() can now have `{ option: true }` configuration that makes them overridable in the config. Options support all other properties of fixtures - value/function, scope, auto.
```
const test = base.extend<MyOptions>({
foo: ['default', { option: true }],
});
```
2. test.declare() and project.define are removed.
3. project.use applies overrides to default option values and nothing else. Any test.extend() and test.use() calls take priority over config options.
Required user changes: if someone used to define fixture options with test.extend(), overriding them in config will stop working. The solution is to add `{ option: true }`.
```
// Old code
export const test = base.extend<{ myOption: number, myFixture: number }>({
myOption: 123,
myFixture: ({ myOption }, use) => use(2 * myOption),
});
// New code
export const test = base.extend<{ myOption: number, myFixture: number }>({
myOption: [123, { option: true }],
myFixture: ({ myOption }, use) => use(2 * myOption),
});
```
2021-11-18 15:45:52 -08:00
return Array . isArray ( value ) && typeof value [ 1 ] === 'object' && ( 'scope' in value [ 1 ] || 'auto' in value [ 1 ] || 'option' in value [ 1 ] ) ;
}
2021-12-22 09:59:58 -08:00
export function isFixtureOption ( value : any ) : value is FixtureTuple {
feat(test runner): replace declare/define with "options" (#10293)
1. Fixtures defined in test.extend() can now have `{ option: true }` configuration that makes them overridable in the config. Options support all other properties of fixtures - value/function, scope, auto.
```
const test = base.extend<MyOptions>({
foo: ['default', { option: true }],
});
```
2. test.declare() and project.define are removed.
3. project.use applies overrides to default option values and nothing else. Any test.extend() and test.use() calls take priority over config options.
Required user changes: if someone used to define fixture options with test.extend(), overriding them in config will stop working. The solution is to add `{ option: true }`.
```
// Old code
export const test = base.extend<{ myOption: number, myFixture: number }>({
myOption: 123,
myFixture: ({ myOption }, use) => use(2 * myOption),
});
// New code
export const test = base.extend<{ myOption: number, myFixture: number }>({
myOption: [123, { option: true }],
myFixture: ({ myOption }, use) => use(2 * myOption),
});
```
2021-11-18 15:45:52 -08:00
return isFixtureTuple ( value ) && ! ! value [ 1 ] . option ;
}
2021-06-06 17:09:53 -07:00
export class FixturePool {
2021-08-09 12:33:16 -07:00
readonly digest : string ;
2021-06-06 17:09:53 -07:00
readonly registrations : Map < string , FixtureRegistration > ;
2021-08-10 16:32:32 -07:00
constructor ( fixturesList : FixturesWithLocation [ ] , parentPool? : FixturePool , disallowWorkerFixtures? : boolean ) {
2021-06-06 17:09:53 -07:00
this . registrations = new Map ( parentPool ? parentPool . registrations : [ ] ) ;
for ( const { fixtures , location } of fixturesList ) {
2021-06-23 10:30:54 -07:00
for ( const entry of Object . entries ( fixtures ) ) {
const name = entry [ 0 ] ;
let value = entry [ 1 ] ;
2021-12-22 09:59:58 -08:00
let options : Required < FixtureOptions > | undefined ;
feat(test runner): replace declare/define with "options" (#10293)
1. Fixtures defined in test.extend() can now have `{ option: true }` configuration that makes them overridable in the config. Options support all other properties of fixtures - value/function, scope, auto.
```
const test = base.extend<MyOptions>({
foo: ['default', { option: true }],
});
```
2. test.declare() and project.define are removed.
3. project.use applies overrides to default option values and nothing else. Any test.extend() and test.use() calls take priority over config options.
Required user changes: if someone used to define fixture options with test.extend(), overriding them in config will stop working. The solution is to add `{ option: true }`.
```
// Old code
export const test = base.extend<{ myOption: number, myFixture: number }>({
myOption: 123,
myFixture: ({ myOption }, use) => use(2 * myOption),
});
// New code
export const test = base.extend<{ myOption: number, myFixture: number }>({
myOption: [123, { option: true }],
myFixture: ({ myOption }, use) => use(2 * myOption),
});
```
2021-11-18 15:45:52 -08:00
if ( isFixtureTuple ( value ) ) {
2021-06-23 10:30:54 -07:00
options = {
auto : ! ! value [ 1 ] . auto ,
2021-12-22 09:59:58 -08:00
scope : value [ 1 ] . scope || 'test' ,
option : ! ! value [ 1 ] . option ,
2021-06-23 10:30:54 -07:00
} ;
value = value [ 0 ] ;
2021-06-06 17:09:53 -07:00
}
2021-06-23 10:30:54 -07:00
const fn = value as ( Function | any ) ;
const previous = this . registrations . get ( name ) ;
if ( previous && options ) {
if ( previous . scope !== options . scope )
throw errorWithLocations ( ` Fixture " ${ name } " has already been registered as a { scope: ' ${ previous . scope } ' } fixture. ` , { location , name } , previous ) ;
if ( previous . auto !== options . auto )
throw errorWithLocations ( ` Fixture " ${ name } " has already been registered as a { auto: ' ${ previous . scope } ' } fixture. ` , { location , name } , previous ) ;
} else if ( previous ) {
2021-12-22 09:59:58 -08:00
options = { auto : previous.auto , scope : previous.scope , option : previous.option } ;
2021-06-23 10:30:54 -07:00
} else if ( ! options ) {
2021-12-22 09:59:58 -08:00
options = { auto : false , scope : 'test' , option : false } ;
2021-06-23 10:30:54 -07:00
}
2021-08-09 12:33:16 -07:00
if ( options . scope !== 'test' && options . scope !== 'worker' )
2021-08-04 21:11:02 -07:00
throw errorWithLocations ( ` Fixture " ${ name } " has unknown { scope: ' ${ options . scope } ' }. ` , { location , name } ) ;
2021-08-10 16:32:32 -07:00
if ( options . scope === 'worker' && disallowWorkerFixtures )
throw errorWithLocations ( ` Cannot use({ ${ name } }) in a describe group, because it forces a new worker. \ nMake it top-level in the test file or put in the configuration file. ` , { location , name } ) ;
2021-08-04 21:11:02 -07:00
2021-06-23 10:30:54 -07:00
const deps = fixtureParameterNames ( fn , location ) ;
2021-12-22 09:59:58 -08:00
const registration : FixtureRegistration = { id : '' , name , location , scope : options.scope , fn , auto : options.auto , option : options.option , deps , super : previous } ;
2021-06-23 10:30:54 -07:00
registrationId ( registration ) ;
this . registrations . set ( name , registration ) ;
2021-06-06 17:09:53 -07:00
}
}
this . digest = this . validate ( ) ;
}
private validate() {
const markers = new Map < FixtureRegistration , 'visiting' | 'visited' > ( ) ;
const stack : FixtureRegistration [ ] = [ ] ;
const visit = ( registration : FixtureRegistration ) = > {
markers . set ( registration , 'visiting' ) ;
stack . push ( registration ) ;
for ( const name of registration . deps ) {
const dep = this . resolveDependency ( registration , name ) ;
if ( ! dep ) {
if ( name === registration . name )
throw errorWithLocations ( ` Fixture " ${ registration . name } " references itself, but does not have a base implementation. ` , registration ) ;
else
throw errorWithLocations ( ` Fixture " ${ registration . name } " has unknown parameter " ${ name } ". ` , registration ) ;
}
2021-08-09 12:33:16 -07:00
if ( registration . scope === 'worker' && dep . scope === 'test' )
throw errorWithLocations ( ` Worker fixture " ${ registration . name } " cannot depend on a test fixture " ${ name } ". ` , registration , dep ) ;
2021-06-06 17:09:53 -07:00
if ( ! markers . has ( dep ) ) {
visit ( dep ) ;
} else if ( markers . get ( dep ) === 'visiting' ) {
const index = stack . indexOf ( dep ) ;
const regs = stack . slice ( index , stack . length ) ;
const names = regs . map ( r = > ` " ${ r . name } " ` ) ;
throw errorWithLocations ( ` Fixtures ${ names . join ( ' -> ' ) } -> " ${ dep . name } " form a dependency cycle. ` , . . . regs ) ;
}
}
markers . set ( registration , 'visited' ) ;
stack . pop ( ) ;
} ;
2021-08-09 12:33:16 -07:00
const hash = crypto . createHash ( 'sha1' ) ;
2021-06-06 17:09:53 -07:00
const names = Array . from ( this . registrations . keys ( ) ) . sort ( ) ;
for ( const name of names ) {
const registration = this . registrations . get ( name ) ! ;
visit ( registration ) ;
if ( registration . scope === 'worker' )
2021-08-09 12:33:16 -07:00
hash . update ( registration . id + ';' ) ;
2021-06-06 17:09:53 -07:00
}
2021-08-09 12:33:16 -07:00
return hash . digest ( 'hex' ) ;
2021-06-06 17:09:53 -07:00
}
2021-08-09 13:26:33 -07:00
validateFunction ( fn : Function , prefix : string , location : Location ) {
2021-06-06 17:09:53 -07:00
const visit = ( registration : FixtureRegistration ) = > {
for ( const name of registration . deps )
visit ( this . resolveDependency ( registration , name ) ! ) ;
} ;
for ( const name of fixtureParameterNames ( fn , location ) ) {
const registration = this . registrations . get ( name ) ;
if ( ! registration )
throw errorWithLocations ( ` ${ prefix } has unknown parameter " ${ name } ". ` , { location , name : prefix , quoted : false } ) ;
visit ( registration ) ;
}
}
resolveDependency ( registration : FixtureRegistration , name : string ) : FixtureRegistration | undefined {
if ( name === registration . name )
return registration . super ;
return this . registrations . get ( name ) ;
}
}
export class FixtureRunner {
private testScopeClean = true ;
pool : FixturePool | undefined ;
instanceForId = new Map < string , Fixture > ( ) ;
setPool ( pool : FixturePool ) {
if ( ! this . testScopeClean )
throw new Error ( 'Did not teardown test scope' ) ;
2021-08-09 12:33:16 -07:00
if ( this . pool && pool . digest !== this . pool . digest )
2021-06-06 17:09:53 -07:00
throw new Error ( 'Digests do not match' ) ;
this . pool = pool ;
}
2021-08-09 13:26:33 -07:00
async teardownScope ( scope : FixtureScope ) {
2021-08-12 09:08:56 -07:00
let error : Error | undefined ;
2021-08-09 13:26:33 -07:00
// Teardown fixtures in the reverse order.
const fixtures = Array . from ( this . instanceForId . values ( ) ) . reverse ( ) ;
for ( const fixture of fixtures ) {
2021-08-12 09:08:56 -07:00
if ( fixture . registration . scope === scope ) {
try {
await fixture . teardown ( ) ;
} catch ( e ) {
if ( error === undefined )
error = e ;
}
}
2021-06-06 17:09:53 -07:00
}
if ( scope === 'test' )
this . testScopeClean = true ;
2021-08-12 09:08:56 -07:00
if ( error !== undefined )
throw error ;
2021-06-06 17:09:53 -07:00
}
2022-01-10 20:25:56 -08:00
async resolveParametersForFunction ( fn : Function , workerInfo : WorkerInfo , testInfo : TestInfo | undefined ) : Promise < object > {
2021-06-06 17:09:53 -07:00
// Install all automatic fixtures.
for ( const registration of this . pool ! . registrations . values ( ) ) {
2021-08-09 13:26:33 -07:00
const shouldSkip = ! testInfo && registration . scope === 'test' ;
2021-08-09 12:33:16 -07:00
if ( registration . auto && ! shouldSkip )
2021-08-09 13:26:33 -07:00
await this . setupFixtureForRegistration ( registration , workerInfo , testInfo ) ;
2021-06-06 17:09:53 -07:00
}
// Install used fixtures.
const names = fixtureParameterNames ( fn , { file : '<unused>' , line : 1 , column : 1 } ) ;
const params : { [ key : string ] : any } = { } ;
for ( const name of names ) {
2021-06-23 10:30:54 -07:00
const registration = this . pool ! . registrations . get ( name ) ! ;
2021-08-09 13:26:33 -07:00
const fixture = await this . setupFixtureForRegistration ( registration , workerInfo , testInfo ) ;
2021-06-06 17:09:53 -07:00
params [ name ] = fixture . value ;
}
2022-01-10 20:25:56 -08:00
return params ;
}
2021-06-06 17:09:53 -07:00
2022-01-10 20:25:56 -08:00
async resolveParametersAndRunFunction ( fn : Function , workerInfo : WorkerInfo , testInfo : TestInfo | undefined ) {
const params = await this . resolveParametersForFunction ( fn , workerInfo , testInfo ) ;
2021-08-09 13:26:33 -07:00
return fn ( params , testInfo || workerInfo ) ;
2021-06-06 17:09:53 -07:00
}
2021-08-09 13:26:33 -07:00
async setupFixtureForRegistration ( registration : FixtureRegistration , workerInfo : WorkerInfo , testInfo : TestInfo | undefined ) : Promise < Fixture > {
2021-06-06 17:09:53 -07:00
if ( registration . scope === 'test' )
this . testScopeClean = false ;
let fixture = this . instanceForId . get ( registration . id ) ;
if ( fixture )
return fixture ;
fixture = new Fixture ( this , registration ) ;
this . instanceForId . set ( registration . id , fixture ) ;
2021-08-09 13:26:33 -07:00
await fixture . setup ( workerInfo , testInfo ) ;
2021-06-06 17:09:53 -07:00
return fixture ;
}
2021-07-02 15:49:05 -07:00
dependsOnWorkerFixturesOnly ( fn : Function , location : Location ) : boolean {
const names = fixtureParameterNames ( fn , location ) ;
for ( const name of names ) {
const registration = this . pool ! . registrations . get ( name ) ! ;
if ( registration . scope !== 'worker' )
return false ;
}
return true ;
}
2021-06-06 17:09:53 -07:00
}
const signatureSymbol = Symbol ( 'signature' ) ;
function fixtureParameterNames ( fn : Function | any , location : Location ) : string [ ] {
if ( typeof fn !== 'function' )
return [ ] ;
if ( ! fn [ signatureSymbol ] )
fn [ signatureSymbol ] = innerFixtureParameterNames ( fn , location ) ;
return fn [ signatureSymbol ] ;
}
function innerFixtureParameterNames ( fn : Function , location : Location ) : string [ ] {
const text = fn . toString ( ) ;
const match = text . match ( /(?:async)?(?:\s+function)?[^(]*\(([^)]*)/ ) ;
if ( ! match )
return [ ] ;
const trimmedParams = match [ 1 ] . trim ( ) ;
if ( ! trimmedParams )
return [ ] ;
const [ firstParam ] = splitByComma ( trimmedParams ) ;
if ( firstParam [ 0 ] !== '{' || firstParam [ firstParam . length - 1 ] !== '}' )
throw errorWithLocations ( 'First argument must use the object destructuring pattern: ' + firstParam , { location } ) ;
const props = splitByComma ( firstParam . substring ( 1 , firstParam . length - 1 ) ) . map ( prop = > {
const colon = prop . indexOf ( ':' ) ;
return colon === - 1 ? prop : prop.substring ( 0 , colon ) . trim ( ) ;
} ) ;
return props ;
}
function splitByComma ( s : string ) {
const result : string [ ] = [ ] ;
const stack : string [ ] = [ ] ;
let start = 0 ;
for ( let i = 0 ; i < s . length ; i ++ ) {
if ( s [ i ] === '{' || s [ i ] === '[' ) {
stack . push ( s [ i ] === '{' ? '}' : ']' ) ;
} else if ( s [ i ] === stack [ stack . length - 1 ] ) {
stack . pop ( ) ;
} else if ( ! stack . length && s [ i ] === ',' ) {
const token = s . substring ( start , i ) . trim ( ) ;
if ( token )
result . push ( token ) ;
start = i + 1 ;
}
}
const lastToken = s . substring ( start ) . trim ( ) ;
if ( lastToken )
result . push ( lastToken ) ;
return result ;
}
// name + superId, fn -> id
const registrationIdMap = new Map < string , Map < Function | any , string > > ( ) ;
let lastId = 0 ;
function registrationId ( registration : FixtureRegistration ) : string {
if ( registration . id )
return registration . id ;
const key = registration . name + '@@@' + ( registration . super ? registrationId ( registration . super ) : '' ) ;
let map = registrationIdMap . get ( key ) ;
if ( ! map ) {
map = new Map ( ) ;
registrationIdMap . set ( key , map ) ;
}
if ( ! map . has ( registration . fn ) )
map . set ( registration . fn , String ( lastId ++ ) ) ;
registration . id = map . get ( registration . fn ) ! ;
return registration . id ;
}
function errorWithLocations ( message : string , . . . defined : { location : Location , name? : string , quoted? : boolean } [ ] ) : Error {
for ( const { name , location , quoted } of defined ) {
let prefix = '' ;
if ( name && quoted === false )
prefix = name + ' ' ;
else if ( name )
prefix = ` " ${ name } " ` ;
message += ` \ n ${ prefix } defined at ${ formatLocation ( location ) } ` ;
}
2021-06-23 10:30:54 -07:00
return new Error ( message ) ;
}