diff --git a/src/test/cli.ts b/src/test/cli.ts index b4cfdae229..1aa604e4d6 100644 --- a/src/test/cli.ts +++ b/src/test/cli.ts @@ -64,7 +64,7 @@ export function addTestCommand(program: commander.CommanderStatic) { try { await runTests(args, opts); } catch (e) { - console.error(e.toString()); + console.error(e); process.exit(1); } }); diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts index dd018a3696..bd95657291 100644 --- a/src/test/fixtures.ts +++ b/src/test/fixtures.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { errorWithCallLocation, formatLocation, prependErrorMessage, wrapInPromise } from './util'; +import { wrapInPromise } from './util'; import * as crypto from 'crypto'; import { FixturesWithLocation, Location } from './types'; @@ -56,9 +56,7 @@ class Fixture { const params: { [key: string]: any } = {}; for (const name of this.registration.deps) { - const registration = this.runner.pool!.resolveDependency(this.registration, name); - if (!registration) - throw errorWithCallLocation(`Unknown fixture "${name}"`); + const registration = this.runner.pool!.resolveDependency(this.registration, name)!; const dep = await this.runner.setupFixtureForRegistration(registration, info); dep.usages.add(this); params[name] = dep.value; @@ -71,7 +69,7 @@ class Fixture { const teardownFence = new Promise(f => this._teardownFenceCallback = f); this._tearDownComplete = wrapInPromise(this.registration.fn(params, async (value: any) => { if (called) - throw errorWithCallLocation(`Cannot provide fixture value for the second time`); + throw new Error(`Cannot provide fixture value for the second time`); called = true; this.value = value; setupFenceFulfill(); @@ -111,40 +109,35 @@ export class FixturePool { this.registrations = new Map(parentPool ? parentPool.registrations : []); for (const { fixtures, location } of fixturesList) { - try { - for (const entry of Object.entries(fixtures)) { - const name = entry[0]; - let value = entry[1]; - let options: { auto: boolean, scope: FixtureScope } | undefined; - if (Array.isArray(value) && typeof value[1] === 'object' && ('scope' in value[1] || 'auto' in value[1])) { - options = { - auto: !!value[1].auto, - scope: value[1].scope || 'test' - }; - value = value[0]; - } - 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) { - options = { auto: previous.auto, scope: previous.scope }; - } else if (!options) { - options = { auto: false, scope: 'test' }; - } - - const deps = fixtureParameterNames(fn, location); - const registration: FixtureRegistration = { id: '', name, location, scope: options.scope, fn, auto: options.auto, deps, super: previous }; - registrationId(registration); - this.registrations.set(name, registration); + for (const entry of Object.entries(fixtures)) { + const name = entry[0]; + let value = entry[1]; + let options: { auto: boolean, scope: FixtureScope } | undefined; + if (Array.isArray(value) && typeof value[1] === 'object' && ('scope' in value[1] || 'auto' in value[1])) { + options = { + auto: !!value[1].auto, + scope: value[1].scope || 'test' + }; + value = value[0]; } - } catch (e) { - prependErrorMessage(e, `Error processing fixtures at ${formatLocation(location)}:\n`); - throw e; + 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) { + options = { auto: previous.auto, scope: previous.scope }; + } else if (!options) { + options = { auto: false, scope: 'test' }; + } + + const deps = fixtureParameterNames(fn, location); + const registration: FixtureRegistration = { id: '', name, location, scope: options.scope, fn, auto: options.auto, deps, super: previous }; + registrationId(registration); + this.registrations.set(name, registration); } } @@ -247,9 +240,7 @@ export class FixtureRunner { const names = fixtureParameterNames(fn, { file: '', line: 1, column: 1 }); const params: { [key: string]: any } = {}; for (const name of names) { - const registration = this.pool!.registrations.get(name); - if (!registration) - throw errorWithCallLocation('Unknown fixture: ' + name); + const registration = this.pool!.registrations.get(name)!; const fixture = await this.setupFixtureForRegistration(registration, info); params[name] = fixture.value; } @@ -355,7 +346,9 @@ function errorWithLocations(message: string, ...defined: { location: Location, n prefix = `"${name}" `; message += `\n ${prefix}defined at ${formatLocation(location)}`; } - const error = new Error(message); - error.stack = 'Error: ' + message + '\n'; - return error; + return new Error(message); +} + +function formatLocation(location: Location) { + return location.file + ':' + location.line + ':' + location.column; } diff --git a/src/test/loader.ts b/src/test/loader.ts index 2c44b0c40a..9b99e29975 100644 --- a/src/test/loader.ts +++ b/src/test/loader.ts @@ -16,7 +16,7 @@ import { installTransform } from './transform'; import type { FullConfig, Config, FullProject, Project, ReporterDescription, PreserveOutput } from './types'; -import { errorWithCallLocation, isRegExp, mergeObjects, prependErrorMessage } from './util'; +import { isRegExp, mergeObjects } from './util'; import { setCurrentlyLoadingFileSuite } from './globals'; import { Suite } from './test'; import { SerializedLoaderData } from './ipc'; @@ -61,9 +61,6 @@ export class Loader { const rawConfig = { ...config }; this._processConfigObject(path.dirname(file)); return rawConfig; - } catch (e) { - prependErrorMessage(e, `Error while reading ${file}:\n`); - throw e; } finally { revertBabelRequire(); } @@ -75,7 +72,7 @@ export class Loader { } private _processConfigObject(rootDir: string) { - validateConfig(this._config); + validateConfig(this._configFile || '', this._config); // Resolve script hooks relative to the root dir. if (this._config.globalSetup) @@ -123,9 +120,6 @@ export class Loader { require(file); this._fileSuites.set(file, suite); return suite; - } catch (e) { - prependErrorMessage(e, `Error while reading ${file}:\n`); - throw e; } finally { revertBabelRequire(); setCurrentlyLoadingFileSuite(undefined); @@ -139,11 +133,8 @@ export class Loader { if (hook && typeof hook === 'object' && ('default' in hook)) hook = hook['default']; if (typeof hook !== 'function') - throw errorWithCallLocation(`${name} file must export a single function.`); + throw errorWithFile(file, `${name} file must export a single function.`); return hook; - } catch (e) { - prependErrorMessage(e, `Error while reading ${file}:\n`); - throw e; } finally { revertBabelRequire(); } @@ -156,11 +147,8 @@ export class Loader { if (func && typeof func === 'object' && ('default' in func)) func = func['default']; if (typeof func !== 'function') - throw errorWithCallLocation(`Reporter file "${file}" must export a single class.`); + throw errorWithFile(file, `reporter file must export a single class.`); return func; - } catch (e) { - prependErrorMessage(e, `Error while reading ${file}:\n`); - throw e; } finally { revertBabelRequire(); } @@ -227,40 +215,44 @@ function toReporters(reporters: 'dot' | 'line' | 'list' | 'junit' | 'json' | 'nu return reporters; } -function validateConfig(config: Config) { - if (typeof config !== 'object' || !config) - throw new Error(`Configuration file must export a single object`); +function errorWithFile(file: string, message: string) { + return new Error(`${file}: ${message}`); +} - validateProject(config, 'config'); +function validateConfig(file: string, config: Config) { + if (typeof config !== 'object' || !config) + throw errorWithFile(file, `Configuration file must export a single object`); + + validateProject(file, config, 'config'); if ('forbidOnly' in config && config.forbidOnly !== undefined) { if (typeof config.forbidOnly !== 'boolean') - throw new Error(`config.forbidOnly must be a boolean`); + throw errorWithFile(file, `config.forbidOnly must be a boolean`); } if ('globalSetup' in config && config.globalSetup !== undefined) { if (typeof config.globalSetup !== 'string') - throw new Error(`config.globalSetup must be a string`); + throw errorWithFile(file, `config.globalSetup must be a string`); } if ('globalTeardown' in config && config.globalTeardown !== undefined) { if (typeof config.globalTeardown !== 'string') - throw new Error(`config.globalTeardown must be a string`); + throw errorWithFile(file, `config.globalTeardown must be a string`); } if ('globalTimeout' in config && config.globalTimeout !== undefined) { if (typeof config.globalTimeout !== 'number' || config.globalTimeout < 0) - throw new Error(`config.globalTimeout must be a non-negative number`); + throw errorWithFile(file, `config.globalTimeout must be a non-negative number`); } if ('grep' in config && config.grep !== undefined) { if (Array.isArray(config.grep)) { config.grep.forEach((item, index) => { if (!isRegExp(item)) - throw new Error(`config.grep[${index}] must be a RegExp`); + throw errorWithFile(file, `config.grep[${index}] must be a RegExp`); }); } else if (!isRegExp(config.grep)) { - throw new Error(`config.grep must be a RegExp`); + throw errorWithFile(file, `config.grep must be a RegExp`); } } @@ -268,115 +260,115 @@ function validateConfig(config: Config) { if (Array.isArray(config.grepInvert)) { config.grepInvert.forEach((item, index) => { if (!isRegExp(item)) - throw new Error(`config.grepInvert[${index}] must be a RegExp`); + throw errorWithFile(file, `config.grepInvert[${index}] must be a RegExp`); }); } else if (!isRegExp(config.grepInvert)) { - throw new Error(`config.grep must be a RegExp`); + throw errorWithFile(file, `config.grep must be a RegExp`); } } if ('maxFailures' in config && config.maxFailures !== undefined) { if (typeof config.maxFailures !== 'number' || config.maxFailures < 0) - throw new Error(`config.maxFailures must be a non-negative number`); + throw errorWithFile(file, `config.maxFailures must be a non-negative number`); } if ('preserveOutput' in config && config.preserveOutput !== undefined) { if (typeof config.preserveOutput !== 'string' || !['always', 'never', 'failures-only'].includes(config.preserveOutput)) - throw new Error(`config.preserveOutput must be one of "always", "never" or "failures-only"`); + throw errorWithFile(file, `config.preserveOutput must be one of "always", "never" or "failures-only"`); } if ('projects' in config && config.projects !== undefined) { if (!Array.isArray(config.projects)) - throw new Error(`config.projects must be an array`); + throw errorWithFile(file, `config.projects must be an array`); config.projects.forEach((project, index) => { - validateProject(project, `config.projects[${index}]`); + validateProject(file, project, `config.projects[${index}]`); }); } if ('quiet' in config && config.quiet !== undefined) { if (typeof config.quiet !== 'boolean') - throw new Error(`config.quiet must be a boolean`); + throw errorWithFile(file, `config.quiet must be a boolean`); } if ('reporter' in config && config.reporter !== undefined) { if (Array.isArray(config.reporter)) { config.reporter.forEach((item, index) => { if (!Array.isArray(item) || item.length <= 0 || item.length > 2 || typeof item[0] !== 'string') - throw new Error(`config.reporter[${index}] must be a tuple [name, optionalArgument]`); + throw errorWithFile(file, `config.reporter[${index}] must be a tuple [name, optionalArgument]`); }); } else { const builtinReporters = ['dot', 'line', 'list', 'junit', 'json', 'null']; if (typeof config.reporter !== 'string' || !builtinReporters.includes(config.reporter)) - throw new Error(`config.reporter must be one of ${builtinReporters.map(name => `"${name}"`).join(', ')}`); + throw errorWithFile(file, `config.reporter must be one of ${builtinReporters.map(name => `"${name}"`).join(', ')}`); } } if ('reportSlowTests' in config && config.reportSlowTests !== undefined && config.reportSlowTests !== null) { if (!config.reportSlowTests || typeof config.reportSlowTests !== 'object') - throw new Error(`config.reportSlowTests must be an object`); + throw errorWithFile(file, `config.reportSlowTests must be an object`); if (!('max' in config.reportSlowTests) || typeof config.reportSlowTests.max !== 'number' || config.reportSlowTests.max < 0) - throw new Error(`config.reportSlowTests.max must be a non-negative number`); + throw errorWithFile(file, `config.reportSlowTests.max must be a non-negative number`); if (!('threshold' in config.reportSlowTests) || typeof config.reportSlowTests.threshold !== 'number' || config.reportSlowTests.threshold < 0) - throw new Error(`config.reportSlowTests.threshold must be a non-negative number`); + throw errorWithFile(file, `config.reportSlowTests.threshold must be a non-negative number`); } if ('shard' in config && config.shard !== undefined && config.shard !== null) { if (!config.shard || typeof config.shard !== 'object') - throw new Error(`config.shard must be an object`); + throw errorWithFile(file, `config.shard must be an object`); if (!('total' in config.shard) || typeof config.shard.total !== 'number' || config.shard.total < 1) - throw new Error(`config.shard.total must be a positive number`); + throw errorWithFile(file, `config.shard.total must be a positive number`); if (!('current' in config.shard) || typeof config.shard.current !== 'number' || config.shard.current < 1 || config.shard.current > config.shard.total) - throw new Error(`config.shard.current must be a positive number, not greater than config.shard.total`); + throw errorWithFile(file, `config.shard.current must be a positive number, not greater than config.shard.total`); } if ('updateSnapshots' in config && config.updateSnapshots !== undefined) { if (typeof config.updateSnapshots !== 'string' || !['all', 'none', 'missing'].includes(config.updateSnapshots)) - throw new Error(`config.updateSnapshots must be one of "all", "none" or "missing"`); + throw errorWithFile(file, `config.updateSnapshots must be one of "all", "none" or "missing"`); } if ('workers' in config && config.workers !== undefined) { if (typeof config.workers !== 'number' || config.workers <= 0) - throw new Error(`config.workers must be a positive number`); + throw errorWithFile(file, `config.workers must be a positive number`); } } -function validateProject(project: Project, title: string) { +function validateProject(file: string, project: Project, title: string) { if (typeof project !== 'object' || !project) - throw new Error(`${title} must be an object`); + throw errorWithFile(file, `${title} must be an object`); if ('define' in project && project.define !== undefined) { if (Array.isArray(project.define)) { project.define.forEach((item, index) => { - validateDefine(item, `${title}.define[${index}]`); + validateDefine(file, item, `${title}.define[${index}]`); }); } else { - validateDefine(project.define, `${title}.define`); + validateDefine(file, project.define, `${title}.define`); } } if ('name' in project && project.name !== undefined) { if (typeof project.name !== 'string') - throw new Error(`${title}.name must be a string`); + throw errorWithFile(file, `${title}.name must be a string`); } if ('outputDir' in project && project.outputDir !== undefined) { if (typeof project.outputDir !== 'string') - throw new Error(`${title}.outputDir must be a string`); + throw errorWithFile(file, `${title}.outputDir must be a string`); } if ('repeatEach' in project && project.repeatEach !== undefined) { if (typeof project.repeatEach !== 'number' || project.repeatEach < 0) - throw new Error(`${title}.repeatEach must be a non-negative number`); + throw errorWithFile(file, `${title}.repeatEach must be a non-negative number`); } if ('retries' in project && project.retries !== undefined) { if (typeof project.retries !== 'number' || project.retries < 0) - throw new Error(`${title}.retries must be a non-negative number`); + throw errorWithFile(file, `${title}.retries must be a non-negative number`); } if ('testDir' in project && project.testDir !== undefined) { if (typeof project.testDir !== 'string') - throw new Error(`${title}.testDir must be a string`); + throw errorWithFile(file, `${title}.testDir must be a string`); } for (const prop of ['testIgnore', 'testMatch'] as const) { @@ -385,28 +377,28 @@ function validateProject(project: Project, title: string) { if (Array.isArray(value)) { value.forEach((item, index) => { if (typeof item !== 'string' && !isRegExp(item)) - throw new Error(`${title}.${prop}[${index}] must be a string or a RegExp`); + throw errorWithFile(file, `${title}.${prop}[${index}] must be a string or a RegExp`); }); } else if (typeof value !== 'string' && !isRegExp(value)) { - throw new Error(`${title}.${prop} must be a string or a RegExp`); + throw errorWithFile(file, `${title}.${prop} must be a string or a RegExp`); } } } if ('timeout' in project && project.timeout !== undefined) { if (typeof project.timeout !== 'number' || project.timeout < 0) - throw new Error(`${title}.timeout must be a non-negative number`); + throw errorWithFile(file, `${title}.timeout must be a non-negative number`); } if ('use' in project && project.use !== undefined) { if (!project.use || typeof project.use !== 'object') - throw new Error(`${title}.use must be an object`); + throw errorWithFile(file, `${title}.use must be an object`); } } -function validateDefine(define: any, title: string) { +function validateDefine(file: string, define: any, title: string) { if (!define || typeof define !== 'object' || !define.test || !define.fixtures) - throw new Error(`${title} must be an object with "test" and "fixtures" properties`); + throw errorWithFile(file, `${title} must be an object with "test" and "fixtures" properties`); } const baseFullConfig: FullConfig = { diff --git a/src/test/testType.ts b/src/test/testType.ts index a496f4541f..bbd1745e92 100644 --- a/src/test/testType.ts +++ b/src/test/testType.ts @@ -17,12 +17,10 @@ import { expect } from './expect'; import { currentlyLoadingFileSuite, currentTestInfo, setCurrentlyLoadingFileSuite } from './globals'; import { Spec, Suite } from './test'; -import { callLocation, errorWithCallLocation } from './util'; +import { wrapFunctionWithLocation } from './transform'; import { Fixtures, FixturesWithLocation, Location, TestInfo, TestType } from './types'; import { inheritFixtureParameterNames } from './fixtures'; -Error.stackTraceLimit = 15; - const countByFile = new Map(); export class DeclaredFixtures { @@ -37,31 +35,30 @@ export class TestTypeImpl { constructor(fixtures: (FixturesWithLocation | DeclaredFixtures)[]) { this.fixtures = fixtures; - const test: any = this._spec.bind(this, 'default'); + const test: any = wrapFunctionWithLocation(this._spec.bind(this, 'default')); test.expect = expect; - test.only = this._spec.bind(this, 'only'); - test.describe = this._describe.bind(this, 'default'); - test.describe.only = this._describe.bind(this, 'only'); - test.beforeEach = this._hook.bind(this, 'beforeEach'); - test.afterEach = this._hook.bind(this, 'afterEach'); - test.beforeAll = this._hook.bind(this, 'beforeAll'); - test.afterAll = this._hook.bind(this, 'afterAll'); - test.skip = this._modifier.bind(this, 'skip'); - test.fixme = this._modifier.bind(this, 'fixme'); - test.fail = this._modifier.bind(this, 'fail'); - test.slow = this._modifier.bind(this, 'slow'); + test.only = wrapFunctionWithLocation(this._spec.bind(this, 'only')); + test.describe = wrapFunctionWithLocation(this._describe.bind(this, 'default')); + test.describe.only = wrapFunctionWithLocation(this._describe.bind(this, 'only')); + test.beforeEach = wrapFunctionWithLocation(this._hook.bind(this, 'beforeEach')); + test.afterEach = wrapFunctionWithLocation(this._hook.bind(this, 'afterEach')); + test.beforeAll = wrapFunctionWithLocation(this._hook.bind(this, 'beforeAll')); + test.afterAll = wrapFunctionWithLocation(this._hook.bind(this, 'afterAll')); + test.skip = wrapFunctionWithLocation(this._modifier.bind(this, 'skip')); + test.fixme = wrapFunctionWithLocation(this._modifier.bind(this, 'fixme')); + test.fail = wrapFunctionWithLocation(this._modifier.bind(this, 'fail')); + test.slow = wrapFunctionWithLocation(this._modifier.bind(this, 'slow')); test.setTimeout = this._setTimeout.bind(this); - test.use = this._use.bind(this); - test.extend = this._extend.bind(this); - test.declare = this._declare.bind(this); + test.use = wrapFunctionWithLocation(this._use.bind(this)); + test.extend = wrapFunctionWithLocation(this._extend.bind(this)); + test.declare = wrapFunctionWithLocation(this._declare.bind(this)); this.test = test; } - private _spec(type: 'default' | 'only', title: string, fn: Function) { + private _spec(type: 'default' | 'only', location: Location, title: string, fn: Function) { const suite = currentlyLoadingFileSuite(); if (!suite) - throw errorWithCallLocation(`test() can only be called in a test file`); - const location = callLocation(suite.file); + throw new Error(`test() can only be called in a test file`); const ordinalInFile = countByFile.get(suite._requireFile) || 0; countByFile.set(suite._requireFile, ordinalInFile + 1); @@ -77,11 +74,10 @@ export class TestTypeImpl { spec._only = true; } - private _describe(type: 'default' | 'only', title: string, fn: Function) { + private _describe(type: 'default' | 'only', location: Location, title: string, fn: Function) { const suite = currentlyLoadingFileSuite(); if (!suite) - throw errorWithCallLocation(`describe() can only be called in a test file`); - const location = callLocation(suite.file); + throw new Error(`describe() can only be called in a test file`); const child = new Suite(title); child._requireFile = suite._requireFile; @@ -98,17 +94,16 @@ export class TestTypeImpl { setCurrentlyLoadingFileSuite(suite); } - private _hook(name: 'beforeEach' | 'afterEach' | 'beforeAll' | 'afterAll', fn: Function) { + private _hook(name: 'beforeEach' | 'afterEach' | 'beforeAll' | 'afterAll', location: Location, fn: Function) { const suite = currentlyLoadingFileSuite(); if (!suite) - throw errorWithCallLocation(`${name} hook can only be called in a test file`); - suite._hooks.push({ type: name, fn, location: callLocation() }); + throw new Error(`${name} hook can only be called in a test file`); + suite._hooks.push({ type: name, fn, location }); } - private _modifier(type: 'skip' | 'fail' | 'fixme' | 'slow', ...modiferAgs: [arg?: any | Function, description?: string]) { + private _modifier(type: 'skip' | 'fail' | 'fixme' | 'slow', location: Location, ...modiferAgs: [arg?: any | Function, description?: string]) { const suite = currentlyLoadingFileSuite(); if (suite) { - const location = callLocation(); if (typeof modiferAgs[0] === 'function') { const [conditionFn, description] = modiferAgs; const fn = (args: any, testInfo: TestInfo) => testInfo[type](conditionFn(args), description!); @@ -136,24 +131,21 @@ export class TestTypeImpl { testInfo.setTimeout(timeout); } - private _use(fixtures: Fixtures) { + private _use(location: Location, fixtures: Fixtures) { const suite = currentlyLoadingFileSuite(); if (!suite) - throw errorWithCallLocation(`test.use() can only be called in a test file`); + throw new Error(`test.use() can only be called in a test file`); suite._fixtureOverrides = { ...suite._fixtureOverrides, ...fixtures }; } - private _extend(fixtures: Fixtures) { - const fixturesWithLocation = { - fixtures, - location: callLocation(), - }; + private _extend(location: Location, fixtures: Fixtures) { + const fixturesWithLocation = { fixtures, location }; return new TestTypeImpl([...this.fixtures, fixturesWithLocation]).test; } - private _declare() { + private _declare(location: Location) { const declared = new DeclaredFixtures(); - declared.location = callLocation(); + declared.location = location; const child = new TestTypeImpl([...this.fixtures, declared]); declared.testType = child; return child.test; diff --git a/src/test/transform.ts b/src/test/transform.ts index 72ce0c6502..84ce9fc116 100644 --- a/src/test/transform.ts +++ b/src/test/transform.ts @@ -21,11 +21,15 @@ import * as fs from 'fs'; import * as pirates from 'pirates'; import * as babel from '@babel/core'; import * as sourceMapSupport from 'source-map-support'; +import type { Location } from './types'; const version = 4; const cacheDir = process.env.PWTEST_CACHE_DIR || path.join(os.tmpdir(), 'playwright-transform-cache'); const sourceMaps: Map = new Map(); +const kStackTraceLimit = 15; +Error.stackTraceLimit = kStackTraceLimit; + sourceMapSupport.install({ environment: 'node', handleUncaughtExceptions: false, @@ -97,3 +101,24 @@ export function installTransform(): () => void { exts: ['.ts'] }); } + +export function wrapFunctionWithLocation(func: (location: Location, ...args: A) => R): (...args: A) => R { + return (...args) => { + const oldPrepareStackTrace = Error.prepareStackTrace; + Error.prepareStackTrace = (error, stackFrames) => { + const frame: NodeJS.CallSite = sourceMapSupport.wrapCallSite(stackFrames[1]); + return { + file: frame.getFileName(), + line: frame.getLineNumber(), + column: frame.getColumnNumber(), + }; + }; + Error.stackTraceLimit = 2; + const obj: { stack: Location } = {} as any; + Error.captureStackTrace(obj); + const location = obj.stack; + Error.stackTraceLimit = kStackTraceLimit; + Error.prepareStackTrace = oldPrepareStackTrace; + return func(location, ...args); + }; +} diff --git a/src/test/util.ts b/src/test/util.ts index 6937179d15..18190e758d 100644 --- a/src/test/util.ts +++ b/src/test/util.ts @@ -14,19 +14,10 @@ * limitations under the License. */ -import path from 'path'; import util from 'util'; -import StackUtils from 'stack-utils'; -import type { Location, TestError } from './types'; +import type { TestError } from './types'; import { default as minimatch } from 'minimatch'; -const TEST_RUNNER_DIRS = [ - path.join('@playwright', 'test', 'lib'), - path.join(__dirname, '..', '..', 'src', 'test'), -]; -const cwd = process.cwd(); -const stackUtils = new StackUtils({ cwd }); - export class DeadlineRunner { private _timer: NodeJS.Timer | undefined; private _done = false; @@ -89,50 +80,11 @@ export function serializeError(error: Error | any): TestError { }; } -function callFrames(): string[] { - const obj = { stack: '' }; - Error.captureStackTrace(obj); - const frames = obj.stack.split('\n').slice(1); - while (frames.length && TEST_RUNNER_DIRS.some(dir => frames[0].includes(dir))) - frames.shift(); - return frames; -} - -export function callLocation(fallbackFile?: string): Location { - const frames = callFrames(); - if (!frames.length) - return {file: fallbackFile || '', line: 1, column: 1}; - const location = stackUtils.parseLine(frames[0])!; - return { - file: path.resolve(cwd, location.file || ''), - line: location.line || 0, - column: location.column || 0, - }; -} - -export function errorWithCallLocation(message: string): Error { - const frames = callFrames(); - const error = new Error(message); - error.stack = 'Error: ' + message + '\n' + frames.join('\n'); - return error; -} - export function monotonicTime(): number { const [seconds, nanoseconds] = process.hrtime(); return seconds * 1000 + (nanoseconds / 1000000 | 0); } -export function prependErrorMessage(e: Error, message: string) { - let stack = e.stack || ''; - if (stack.includes(e.message)) - stack = stack.substring(stack.indexOf(e.message) + e.message.length); - let m = e.message; - if (m.startsWith('Error:')) - m = m.substring('Error:'.length); - e.message = message + m; - e.stack = e.message + stack; -} - export function isRegExp(e: any): e is RegExp { return e && typeof e === 'object' && (e instanceof RegExp || Object.prototype.toString.call(e) === '[object RegExp]'); } @@ -184,10 +136,6 @@ export async function wrapInPromise(value: any) { return value; } -export function formatLocation(location: Location) { - return location.file + ':' + location.line + ':' + location.column; -} - export function forceRegExp(pattern: string): RegExp { const match = pattern.match(/^\/(.*)\/([gi]*)$/); if (match) diff --git a/src/utils/stackTrace.ts b/src/utils/stackTrace.ts index 3adb05716b..faea322f41 100644 --- a/src/utils/stackTrace.ts +++ b/src/utils/stackTrace.ts @@ -21,11 +21,6 @@ import { isUnderTest } from './utils'; const stackUtils = new StackUtils(); -export function getCallerFilePath(ignorePrefix: string): string | null { - const frame = captureStackTrace().frames.find(f => !f.file.startsWith(ignorePrefix)); - return frame ? frame.file : null; -} - export function rewriteErrorMessage(e: Error, newMessage: string): Error { if (e.stack) { const index = e.stack.indexOf(e.message); diff --git a/tests/playwright-test/config.spec.ts b/tests/playwright-test/config.spec.ts index f27c9f2da8..84acfd3a8c 100644 --- a/tests/playwright-test/config.spec.ts +++ b/tests/playwright-test/config.spec.ts @@ -175,23 +175,6 @@ test('should support different testDirs', async ({ runInlineTest }) => { expect(result.report.suites[1].specs[0].title).toBe('runs twice'); }); -test('should allow export default form the config file', async ({ runInlineTest }) => { - const result = await runInlineTest({ - 'playwright.config.ts': ` - export default { timeout: 1000 }; - `, - 'a.test.ts': ` - const { test } = pwt; - test('fails', async ({}, testInfo) => { - await new Promise(f => setTimeout(f, 2000)); - }); - ` - }); - - expect(result.exitCode).toBe(1); - expect(result.failed).toBe(1); - expect(result.output).toContain('Timeout of 1000ms exceeded.'); -}); test('should allow root testDir and use it for relative paths', async ({ runInlineTest }) => { const result = await runInlineTest({ diff --git a/tests/playwright-test/fixture-errors.spec.ts b/tests/playwright-test/fixture-errors.spec.ts index ccf29cbd33..ea39a33933 100644 --- a/tests/playwright-test/fixture-errors.spec.ts +++ b/tests/playwright-test/fixture-errors.spec.ts @@ -367,7 +367,7 @@ test('should print nice error message for problematic fixtures', async ({ runInl }); expect(result.exitCode).toBe(1); expect(result.output).toContain('oh my!'); - expect(result.output).toContain('x.spec.ts:5:29'); + expect(result.output).toContain('x.spec.ts:6:49'); }); test('should exit with timeout when fixture causes an exception in the test', async ({ runInlineTest }) => { diff --git a/tests/playwright-test/global-setup.spec.ts b/tests/playwright-test/global-setup.spec.ts index b8621e2a9a..ddbbad7ea4 100644 --- a/tests/playwright-test/global-setup.spec.ts +++ b/tests/playwright-test/global-setup.spec.ts @@ -179,7 +179,7 @@ test('globalSetup should throw when passed non-function', async ({ runInlineTest }); `, }); - expect(output).toContain(`globalSetup file must export a single function.`); + expect(output).toContain(`globalSetup.ts: globalSetup file must export a single function.`); }); test('globalSetup should work with default export and run the returned fn', async ({ runInlineTest }) => { diff --git a/tests/playwright-test/loader.spec.ts b/tests/playwright-test/loader.spec.ts new file mode 100644 index 0000000000..97c27f692e --- /dev/null +++ b/tests/playwright-test/loader.spec.ts @@ -0,0 +1,106 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * 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 { test, expect } from './playwright-test-fixtures'; + +test('should return the location of a syntax error', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'error.spec.js': ` + const x = { + foo: 'bar'; + }; + ` + }); + expect(result.exitCode).toBe(1); + expect(result.passed).toBe(0); + expect(result.failed).toBe(0); + expect(result.output).toContain('error.spec.js:6'); +}); + +test('should print an improper error', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'error.spec.js': ` + throw 123; + ` + }); + expect(result.exitCode).toBe(1); + expect(result.passed).toBe(0); + expect(result.failed).toBe(0); + expect(result.output).toContain('123'); +}); + +test('should print a null error', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'error.spec.js': ` + throw null; + ` + }); + expect(result.exitCode).toBe(1); + expect(result.passed).toBe(0); + expect(result.failed).toBe(0); + expect(result.output).toContain('null'); +}); + +test('should return the location of a syntax error in typescript', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'error.spec.ts': ` + const x = { + foo: 'bar'; + }; + ` + }, {}, { + FORCE_COLOR: '0' + }); + expect(result.exitCode).toBe(1); + expect(result.passed).toBe(0); + expect(result.failed).toBe(0); + expect(result.output).toContain('error.spec.ts'); + expect(result.output).toContain(`'bar';`); +}); + +test('should allow export default form the config file', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + export default { timeout: 1000 }; + `, + 'a.test.ts': ` + const { test } = pwt; + test('fails', async ({}, testInfo) => { + await new Promise(f => setTimeout(f, 2000)); + }); + ` + }); + + expect(result.exitCode).toBe(1); + expect(result.failed).toBe(1); + expect(result.output).toContain('Timeout of 1000ms exceeded.'); +}); + +test('should validate configuration object', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + export default { timeout: '1000' }; + `, + 'a.test.ts': ` + const { test } = pwt; + test('works', () => {}); + ` + }); + + expect(result.exitCode).toBe(1); + expect(result.failed).toBe(0); + expect(result.output).toContain('playwright.config.ts: config.timeout must be a non-negative number'); +}); diff --git a/tests/playwright-test/playwright-test-fixtures.ts b/tests/playwright-test/playwright-test-fixtures.ts index e04282bb71..ad9ea9d834 100644 --- a/tests/playwright-test/playwright-test-fixtures.ts +++ b/tests/playwright-test/playwright-test-fixtures.ts @@ -225,7 +225,7 @@ export const test = base.extend({ runResult = await runPlaywrightTest(baseDir, params, env); return runResult; }); - if (testInfo.status !== testInfo.expectedStatus && runResult) + if (testInfo.status !== testInfo.expectedStatus && runResult && !process.env.PW_RUNNER_DEBUG) console.log(runResult.output); }, @@ -236,7 +236,7 @@ export const test = base.extend({ tscResult = await runTSC(baseDir); return tscResult; }); - if (testInfo.status !== testInfo.expectedStatus && tscResult) + if (testInfo.status !== testInfo.expectedStatus && tscResult && !process.env.PW_RUNNER_DEBUG) console.log(tscResult.output); }, }); diff --git a/tests/stack-trace.spec.ts b/tests/stack-trace.spec.ts deleted file mode 100644 index 58ecc3ca35..0000000000 --- a/tests/stack-trace.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright 2019 Google Inc. All rights reserved. - * Modifications copyright (c) Microsoft Corporation. - * - * 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 { playwrightTest as test, expect } from './config/browserTest'; -import path from 'path'; -import * as stackTrace from '../src/utils/stackTrace'; -import { setUnderTest } from '../src/utils/utils'; - -test('caller file path', async ({}) => { - setUnderTest(); - const callme = require('./assets/callback'); - const filePath = callme(() => { - return stackTrace.getCallerFilePath(path.join(__dirname, 'assets') + path.sep); - }); - expect(filePath).toBe(__filename); -});