fix(test runner): expose real stack traces and speed up locations (#7265)

Stop wrapping/prepending error messages so that we do not loose the stack trace. For this, update a few manually thrown errors with better messages (usually including a file path).

Speed up locations by doing manual `sourceMapSupport.wrapCallSite()` for a single call site. Performance gain in the runner process with 100 files x 100 tests each:
- 25% on the fresh run without babel cache;
- 80% on the cached run where babel is almost instant.

Also some obvious cleanups around stack traces (removing unused code).
This commit is contained in:
Dmitry Gozman 2021-06-23 10:30:54 -07:00 committed by GitHub
parent f342d8b0b1
commit 4c6fa42810
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 254 additions and 250 deletions

View file

@ -64,7 +64,7 @@ export function addTestCommand(program: commander.CommanderStatic) {
try { try {
await runTests(args, opts); await runTests(args, opts);
} catch (e) { } catch (e) {
console.error(e.toString()); console.error(e);
process.exit(1); process.exit(1);
} }
}); });

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { errorWithCallLocation, formatLocation, prependErrorMessage, wrapInPromise } from './util'; import { wrapInPromise } from './util';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { FixturesWithLocation, Location } from './types'; import { FixturesWithLocation, Location } from './types';
@ -56,9 +56,7 @@ class Fixture {
const params: { [key: string]: any } = {}; const params: { [key: string]: any } = {};
for (const name of this.registration.deps) { for (const name of this.registration.deps) {
const registration = this.runner.pool!.resolveDependency(this.registration, name); const registration = this.runner.pool!.resolveDependency(this.registration, name)!;
if (!registration)
throw errorWithCallLocation(`Unknown fixture "${name}"`);
const dep = await this.runner.setupFixtureForRegistration(registration, info); const dep = await this.runner.setupFixtureForRegistration(registration, info);
dep.usages.add(this); dep.usages.add(this);
params[name] = dep.value; params[name] = dep.value;
@ -71,7 +69,7 @@ class Fixture {
const teardownFence = new Promise(f => this._teardownFenceCallback = f); const teardownFence = new Promise(f => this._teardownFenceCallback = f);
this._tearDownComplete = wrapInPromise(this.registration.fn(params, async (value: any) => { this._tearDownComplete = wrapInPromise(this.registration.fn(params, async (value: any) => {
if (called) 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; called = true;
this.value = value; this.value = value;
setupFenceFulfill(); setupFenceFulfill();
@ -111,40 +109,35 @@ export class FixturePool {
this.registrations = new Map(parentPool ? parentPool.registrations : []); this.registrations = new Map(parentPool ? parentPool.registrations : []);
for (const { fixtures, location } of fixturesList) { for (const { fixtures, location } of fixturesList) {
try { for (const entry of Object.entries(fixtures)) {
for (const entry of Object.entries(fixtures)) { const name = entry[0];
const name = entry[0]; let value = entry[1];
let value = entry[1]; let options: { auto: boolean, scope: FixtureScope } | undefined;
let options: { auto: boolean, scope: FixtureScope } | undefined; if (Array.isArray(value) && typeof value[1] === 'object' && ('scope' in value[1] || 'auto' in value[1])) {
if (Array.isArray(value) && typeof value[1] === 'object' && ('scope' in value[1] || 'auto' in value[1])) { options = {
options = { auto: !!value[1].auto,
auto: !!value[1].auto, scope: value[1].scope || 'test'
scope: value[1].scope || 'test' };
}; value = value[0];
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);
} }
} catch (e) { const fn = value as (Function | any);
prependErrorMessage(e, `Error processing fixtures at ${formatLocation(location)}:\n`);
throw e; 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: '<unused>', line: 1, column: 1 }); const names = fixtureParameterNames(fn, { file: '<unused>', line: 1, column: 1 });
const params: { [key: string]: any } = {}; const params: { [key: string]: any } = {};
for (const name of names) { for (const name of names) {
const registration = this.pool!.registrations.get(name); const registration = this.pool!.registrations.get(name)!;
if (!registration)
throw errorWithCallLocation('Unknown fixture: ' + name);
const fixture = await this.setupFixtureForRegistration(registration, info); const fixture = await this.setupFixtureForRegistration(registration, info);
params[name] = fixture.value; params[name] = fixture.value;
} }
@ -355,7 +346,9 @@ function errorWithLocations(message: string, ...defined: { location: Location, n
prefix = `"${name}" `; prefix = `"${name}" `;
message += `\n ${prefix}defined at ${formatLocation(location)}`; message += `\n ${prefix}defined at ${formatLocation(location)}`;
} }
const error = new Error(message); return new Error(message);
error.stack = 'Error: ' + message + '\n'; }
return error;
function formatLocation(location: Location) {
return location.file + ':' + location.line + ':' + location.column;
} }

View file

@ -16,7 +16,7 @@
import { installTransform } from './transform'; import { installTransform } from './transform';
import type { FullConfig, Config, FullProject, Project, ReporterDescription, PreserveOutput } from './types'; 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 { setCurrentlyLoadingFileSuite } from './globals';
import { Suite } from './test'; import { Suite } from './test';
import { SerializedLoaderData } from './ipc'; import { SerializedLoaderData } from './ipc';
@ -61,9 +61,6 @@ export class Loader {
const rawConfig = { ...config }; const rawConfig = { ...config };
this._processConfigObject(path.dirname(file)); this._processConfigObject(path.dirname(file));
return rawConfig; return rawConfig;
} catch (e) {
prependErrorMessage(e, `Error while reading ${file}:\n`);
throw e;
} finally { } finally {
revertBabelRequire(); revertBabelRequire();
} }
@ -75,7 +72,7 @@ export class Loader {
} }
private _processConfigObject(rootDir: string) { private _processConfigObject(rootDir: string) {
validateConfig(this._config); validateConfig(this._configFile || '<default config>', this._config);
// Resolve script hooks relative to the root dir. // Resolve script hooks relative to the root dir.
if (this._config.globalSetup) if (this._config.globalSetup)
@ -123,9 +120,6 @@ export class Loader {
require(file); require(file);
this._fileSuites.set(file, suite); this._fileSuites.set(file, suite);
return suite; return suite;
} catch (e) {
prependErrorMessage(e, `Error while reading ${file}:\n`);
throw e;
} finally { } finally {
revertBabelRequire(); revertBabelRequire();
setCurrentlyLoadingFileSuite(undefined); setCurrentlyLoadingFileSuite(undefined);
@ -139,11 +133,8 @@ export class Loader {
if (hook && typeof hook === 'object' && ('default' in hook)) if (hook && typeof hook === 'object' && ('default' in hook))
hook = hook['default']; hook = hook['default'];
if (typeof hook !== 'function') 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; return hook;
} catch (e) {
prependErrorMessage(e, `Error while reading ${file}:\n`);
throw e;
} finally { } finally {
revertBabelRequire(); revertBabelRequire();
} }
@ -156,11 +147,8 @@ export class Loader {
if (func && typeof func === 'object' && ('default' in func)) if (func && typeof func === 'object' && ('default' in func))
func = func['default']; func = func['default'];
if (typeof func !== 'function') 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; return func;
} catch (e) {
prependErrorMessage(e, `Error while reading ${file}:\n`);
throw e;
} finally { } finally {
revertBabelRequire(); revertBabelRequire();
} }
@ -227,40 +215,44 @@ function toReporters(reporters: 'dot' | 'line' | 'list' | 'junit' | 'json' | 'nu
return reporters; return reporters;
} }
function validateConfig(config: Config) { function errorWithFile(file: string, message: string) {
if (typeof config !== 'object' || !config) return new Error(`${file}: ${message}`);
throw new Error(`Configuration file must export a single object`); }
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 ('forbidOnly' in config && config.forbidOnly !== undefined) {
if (typeof config.forbidOnly !== 'boolean') 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 ('globalSetup' in config && config.globalSetup !== undefined) {
if (typeof config.globalSetup !== 'string') 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 ('globalTeardown' in config && config.globalTeardown !== undefined) {
if (typeof config.globalTeardown !== 'string') 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 ('globalTimeout' in config && config.globalTimeout !== undefined) {
if (typeof config.globalTimeout !== 'number' || config.globalTimeout < 0) 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 ('grep' in config && config.grep !== undefined) {
if (Array.isArray(config.grep)) { if (Array.isArray(config.grep)) {
config.grep.forEach((item, index) => { config.grep.forEach((item, index) => {
if (!isRegExp(item)) 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)) { } 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)) { if (Array.isArray(config.grepInvert)) {
config.grepInvert.forEach((item, index) => { config.grepInvert.forEach((item, index) => {
if (!isRegExp(item)) 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)) { } 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 ('maxFailures' in config && config.maxFailures !== undefined) {
if (typeof config.maxFailures !== 'number' || config.maxFailures < 0) 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 ('preserveOutput' in config && config.preserveOutput !== undefined) {
if (typeof config.preserveOutput !== 'string' || !['always', 'never', 'failures-only'].includes(config.preserveOutput)) 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 ('projects' in config && config.projects !== undefined) {
if (!Array.isArray(config.projects)) 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) => { config.projects.forEach((project, index) => {
validateProject(project, `config.projects[${index}]`); validateProject(file, project, `config.projects[${index}]`);
}); });
} }
if ('quiet' in config && config.quiet !== undefined) { if ('quiet' in config && config.quiet !== undefined) {
if (typeof config.quiet !== 'boolean') 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 ('reporter' in config && config.reporter !== undefined) {
if (Array.isArray(config.reporter)) { if (Array.isArray(config.reporter)) {
config.reporter.forEach((item, index) => { config.reporter.forEach((item, index) => {
if (!Array.isArray(item) || item.length <= 0 || item.length > 2 || typeof item[0] !== 'string') 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 { } else {
const builtinReporters = ['dot', 'line', 'list', 'junit', 'json', 'null']; const builtinReporters = ['dot', 'line', 'list', 'junit', 'json', 'null'];
if (typeof config.reporter !== 'string' || !builtinReporters.includes(config.reporter)) 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 ('reportSlowTests' in config && config.reportSlowTests !== undefined && config.reportSlowTests !== null) {
if (!config.reportSlowTests || typeof config.reportSlowTests !== 'object') 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) 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) 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 ('shard' in config && config.shard !== undefined && config.shard !== null) {
if (!config.shard || typeof config.shard !== 'object') 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) 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) 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 ('updateSnapshots' in config && config.updateSnapshots !== undefined) {
if (typeof config.updateSnapshots !== 'string' || !['all', 'none', 'missing'].includes(config.updateSnapshots)) 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 ('workers' in config && config.workers !== undefined) {
if (typeof config.workers !== 'number' || config.workers <= 0) 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) 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 ('define' in project && project.define !== undefined) {
if (Array.isArray(project.define)) { if (Array.isArray(project.define)) {
project.define.forEach((item, index) => { project.define.forEach((item, index) => {
validateDefine(item, `${title}.define[${index}]`); validateDefine(file, item, `${title}.define[${index}]`);
}); });
} else { } else {
validateDefine(project.define, `${title}.define`); validateDefine(file, project.define, `${title}.define`);
} }
} }
if ('name' in project && project.name !== undefined) { if ('name' in project && project.name !== undefined) {
if (typeof project.name !== 'string') 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 ('outputDir' in project && project.outputDir !== undefined) {
if (typeof project.outputDir !== 'string') 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 ('repeatEach' in project && project.repeatEach !== undefined) {
if (typeof project.repeatEach !== 'number' || project.repeatEach < 0) 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 ('retries' in project && project.retries !== undefined) {
if (typeof project.retries !== 'number' || project.retries < 0) 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 ('testDir' in project && project.testDir !== undefined) {
if (typeof project.testDir !== 'string') 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) { for (const prop of ['testIgnore', 'testMatch'] as const) {
@ -385,28 +377,28 @@ function validateProject(project: Project, title: string) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
value.forEach((item, index) => { value.forEach((item, index) => {
if (typeof item !== 'string' && !isRegExp(item)) 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)) { } 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 ('timeout' in project && project.timeout !== undefined) {
if (typeof project.timeout !== 'number' || project.timeout < 0) 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 ('use' in project && project.use !== undefined) {
if (!project.use || typeof project.use !== 'object') 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) 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 = { const baseFullConfig: FullConfig = {

View file

@ -17,12 +17,10 @@
import { expect } from './expect'; import { expect } from './expect';
import { currentlyLoadingFileSuite, currentTestInfo, setCurrentlyLoadingFileSuite } from './globals'; import { currentlyLoadingFileSuite, currentTestInfo, setCurrentlyLoadingFileSuite } from './globals';
import { Spec, Suite } from './test'; import { Spec, Suite } from './test';
import { callLocation, errorWithCallLocation } from './util'; import { wrapFunctionWithLocation } from './transform';
import { Fixtures, FixturesWithLocation, Location, TestInfo, TestType } from './types'; import { Fixtures, FixturesWithLocation, Location, TestInfo, TestType } from './types';
import { inheritFixtureParameterNames } from './fixtures'; import { inheritFixtureParameterNames } from './fixtures';
Error.stackTraceLimit = 15;
const countByFile = new Map<string, number>(); const countByFile = new Map<string, number>();
export class DeclaredFixtures { export class DeclaredFixtures {
@ -37,31 +35,30 @@ export class TestTypeImpl {
constructor(fixtures: (FixturesWithLocation | DeclaredFixtures)[]) { constructor(fixtures: (FixturesWithLocation | DeclaredFixtures)[]) {
this.fixtures = fixtures; this.fixtures = fixtures;
const test: any = this._spec.bind(this, 'default'); const test: any = wrapFunctionWithLocation(this._spec.bind(this, 'default'));
test.expect = expect; test.expect = expect;
test.only = this._spec.bind(this, 'only'); test.only = wrapFunctionWithLocation(this._spec.bind(this, 'only'));
test.describe = this._describe.bind(this, 'default'); test.describe = wrapFunctionWithLocation(this._describe.bind(this, 'default'));
test.describe.only = this._describe.bind(this, 'only'); test.describe.only = wrapFunctionWithLocation(this._describe.bind(this, 'only'));
test.beforeEach = this._hook.bind(this, 'beforeEach'); test.beforeEach = wrapFunctionWithLocation(this._hook.bind(this, 'beforeEach'));
test.afterEach = this._hook.bind(this, 'afterEach'); test.afterEach = wrapFunctionWithLocation(this._hook.bind(this, 'afterEach'));
test.beforeAll = this._hook.bind(this, 'beforeAll'); test.beforeAll = wrapFunctionWithLocation(this._hook.bind(this, 'beforeAll'));
test.afterAll = this._hook.bind(this, 'afterAll'); test.afterAll = wrapFunctionWithLocation(this._hook.bind(this, 'afterAll'));
test.skip = this._modifier.bind(this, 'skip'); test.skip = wrapFunctionWithLocation(this._modifier.bind(this, 'skip'));
test.fixme = this._modifier.bind(this, 'fixme'); test.fixme = wrapFunctionWithLocation(this._modifier.bind(this, 'fixme'));
test.fail = this._modifier.bind(this, 'fail'); test.fail = wrapFunctionWithLocation(this._modifier.bind(this, 'fail'));
test.slow = this._modifier.bind(this, 'slow'); test.slow = wrapFunctionWithLocation(this._modifier.bind(this, 'slow'));
test.setTimeout = this._setTimeout.bind(this); test.setTimeout = this._setTimeout.bind(this);
test.use = this._use.bind(this); test.use = wrapFunctionWithLocation(this._use.bind(this));
test.extend = this._extend.bind(this); test.extend = wrapFunctionWithLocation(this._extend.bind(this));
test.declare = this._declare.bind(this); test.declare = wrapFunctionWithLocation(this._declare.bind(this));
this.test = test; 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(); const suite = currentlyLoadingFileSuite();
if (!suite) if (!suite)
throw errorWithCallLocation(`test() can only be called in a test file`); throw new Error(`test() can only be called in a test file`);
const location = callLocation(suite.file);
const ordinalInFile = countByFile.get(suite._requireFile) || 0; const ordinalInFile = countByFile.get(suite._requireFile) || 0;
countByFile.set(suite._requireFile, ordinalInFile + 1); countByFile.set(suite._requireFile, ordinalInFile + 1);
@ -77,11 +74,10 @@ export class TestTypeImpl {
spec._only = true; 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(); const suite = currentlyLoadingFileSuite();
if (!suite) if (!suite)
throw errorWithCallLocation(`describe() can only be called in a test file`); throw new Error(`describe() can only be called in a test file`);
const location = callLocation(suite.file);
const child = new Suite(title); const child = new Suite(title);
child._requireFile = suite._requireFile; child._requireFile = suite._requireFile;
@ -98,17 +94,16 @@ export class TestTypeImpl {
setCurrentlyLoadingFileSuite(suite); 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(); const suite = currentlyLoadingFileSuite();
if (!suite) if (!suite)
throw errorWithCallLocation(`${name} hook can only be called in a test file`); throw new Error(`${name} hook can only be called in a test file`);
suite._hooks.push({ type: name, fn, location: callLocation() }); 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(); const suite = currentlyLoadingFileSuite();
if (suite) { if (suite) {
const location = callLocation();
if (typeof modiferAgs[0] === 'function') { if (typeof modiferAgs[0] === 'function') {
const [conditionFn, description] = modiferAgs; const [conditionFn, description] = modiferAgs;
const fn = (args: any, testInfo: TestInfo) => testInfo[type](conditionFn(args), description!); const fn = (args: any, testInfo: TestInfo) => testInfo[type](conditionFn(args), description!);
@ -136,24 +131,21 @@ export class TestTypeImpl {
testInfo.setTimeout(timeout); testInfo.setTimeout(timeout);
} }
private _use(fixtures: Fixtures) { private _use(location: Location, fixtures: Fixtures) {
const suite = currentlyLoadingFileSuite(); const suite = currentlyLoadingFileSuite();
if (!suite) 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 }; suite._fixtureOverrides = { ...suite._fixtureOverrides, ...fixtures };
} }
private _extend(fixtures: Fixtures) { private _extend(location: Location, fixtures: Fixtures) {
const fixturesWithLocation = { const fixturesWithLocation = { fixtures, location };
fixtures,
location: callLocation(),
};
return new TestTypeImpl([...this.fixtures, fixturesWithLocation]).test; return new TestTypeImpl([...this.fixtures, fixturesWithLocation]).test;
} }
private _declare() { private _declare(location: Location) {
const declared = new DeclaredFixtures(); const declared = new DeclaredFixtures();
declared.location = callLocation(); declared.location = location;
const child = new TestTypeImpl([...this.fixtures, declared]); const child = new TestTypeImpl([...this.fixtures, declared]);
declared.testType = child; declared.testType = child;
return child.test; return child.test;

View file

@ -21,11 +21,15 @@ import * as fs from 'fs';
import * as pirates from 'pirates'; import * as pirates from 'pirates';
import * as babel from '@babel/core'; import * as babel from '@babel/core';
import * as sourceMapSupport from 'source-map-support'; import * as sourceMapSupport from 'source-map-support';
import type { Location } from './types';
const version = 4; const version = 4;
const cacheDir = process.env.PWTEST_CACHE_DIR || path.join(os.tmpdir(), 'playwright-transform-cache'); const cacheDir = process.env.PWTEST_CACHE_DIR || path.join(os.tmpdir(), 'playwright-transform-cache');
const sourceMaps: Map<string, string> = new Map(); const sourceMaps: Map<string, string> = new Map();
const kStackTraceLimit = 15;
Error.stackTraceLimit = kStackTraceLimit;
sourceMapSupport.install({ sourceMapSupport.install({
environment: 'node', environment: 'node',
handleUncaughtExceptions: false, handleUncaughtExceptions: false,
@ -97,3 +101,24 @@ export function installTransform(): () => void {
exts: ['.ts'] exts: ['.ts']
}); });
} }
export function wrapFunctionWithLocation<A extends any[], R>(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);
};
}

View file

@ -14,19 +14,10 @@
* limitations under the License. * limitations under the License.
*/ */
import path from 'path';
import util from 'util'; import util from 'util';
import StackUtils from 'stack-utils'; import type { TestError } from './types';
import type { Location, TestError } from './types';
import { default as minimatch } from 'minimatch'; 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<T> { export class DeadlineRunner<T> {
private _timer: NodeJS.Timer | undefined; private _timer: NodeJS.Timer | undefined;
private _done = false; 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 || '<unknown>', 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 { export function monotonicTime(): number {
const [seconds, nanoseconds] = process.hrtime(); const [seconds, nanoseconds] = process.hrtime();
return seconds * 1000 + (nanoseconds / 1000000 | 0); 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 { export function isRegExp(e: any): e is RegExp {
return e && typeof e === 'object' && (e instanceof RegExp || Object.prototype.toString.call(e) === '[object 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; return value;
} }
export function formatLocation(location: Location) {
return location.file + ':' + location.line + ':' + location.column;
}
export function forceRegExp(pattern: string): RegExp { export function forceRegExp(pattern: string): RegExp {
const match = pattern.match(/^\/(.*)\/([gi]*)$/); const match = pattern.match(/^\/(.*)\/([gi]*)$/);
if (match) if (match)

View file

@ -21,11 +21,6 @@ import { isUnderTest } from './utils';
const stackUtils = new StackUtils(); 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 { export function rewriteErrorMessage(e: Error, newMessage: string): Error {
if (e.stack) { if (e.stack) {
const index = e.stack.indexOf(e.message); const index = e.stack.indexOf(e.message);

View file

@ -175,23 +175,6 @@ test('should support different testDirs', async ({ runInlineTest }) => {
expect(result.report.suites[1].specs[0].title).toBe('runs twice'); 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 }) => { test('should allow root testDir and use it for relative paths', async ({ runInlineTest }) => {
const result = await runInlineTest({ const result = await runInlineTest({

View file

@ -367,7 +367,7 @@ test('should print nice error message for problematic fixtures', async ({ runInl
}); });
expect(result.exitCode).toBe(1); expect(result.exitCode).toBe(1);
expect(result.output).toContain('oh my!'); 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 }) => { test('should exit with timeout when fixture causes an exception in the test', async ({ runInlineTest }) => {

View file

@ -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 }) => { test('globalSetup should work with default export and run the returned fn', async ({ runInlineTest }) => {

View file

@ -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');
});

View file

@ -225,7 +225,7 @@ export const test = base.extend<Fixtures>({
runResult = await runPlaywrightTest(baseDir, params, env); runResult = await runPlaywrightTest(baseDir, params, env);
return runResult; return runResult;
}); });
if (testInfo.status !== testInfo.expectedStatus && runResult) if (testInfo.status !== testInfo.expectedStatus && runResult && !process.env.PW_RUNNER_DEBUG)
console.log(runResult.output); console.log(runResult.output);
}, },
@ -236,7 +236,7 @@ export const test = base.extend<Fixtures>({
tscResult = await runTSC(baseDir); tscResult = await runTSC(baseDir);
return tscResult; return tscResult;
}); });
if (testInfo.status !== testInfo.expectedStatus && tscResult) if (testInfo.status !== testInfo.expectedStatus && tscResult && !process.env.PW_RUNNER_DEBUG)
console.log(tscResult.output); console.log(tscResult.output);
}, },
}); });

View file

@ -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);
});