feat(test runner): expect(locator) matchers to show a nice error on timeout (#7935)
This commit is contained in:
parent
5cf1a3e4ef
commit
081b8683a3
|
|
@ -14,14 +14,14 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TestInfo } from './types';
|
import type { TestInfoImpl } from './types';
|
||||||
import { Suite } from './test';
|
import { Suite } from './test';
|
||||||
|
|
||||||
let currentTestInfoValue: TestInfo | null = null;
|
let currentTestInfoValue: TestInfoImpl | null = null;
|
||||||
export function setCurrentTestInfo(testInfo: TestInfo | null) {
|
export function setCurrentTestInfo(testInfo: TestInfoImpl | null) {
|
||||||
currentTestInfoValue = testInfo;
|
currentTestInfoValue = testInfo;
|
||||||
}
|
}
|
||||||
export function currentTestInfo(): TestInfo | null {
|
export function currentTestInfo(): TestInfoImpl | null {
|
||||||
return currentTestInfoValue;
|
return currentTestInfoValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export async function toBeTruthy<T>(
|
||||||
received = await query(remainingTime);
|
received = await query(remainingTime);
|
||||||
pass = !!received;
|
pass = !!received;
|
||||||
return pass === !matcherOptions.isNot;
|
return pass === !matcherOptions.isNot;
|
||||||
}, options.timeout, 100);
|
}, options.timeout, 100, testInfo._testFinished);
|
||||||
|
|
||||||
const message = () => {
|
const message = () => {
|
||||||
return matcherHint(matcherName, undefined, '', matcherOptions);
|
return matcherHint(matcherName, undefined, '', matcherOptions);
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export async function toEqual<T>(
|
||||||
received = await query(remainingTime);
|
received = await query(remainingTime);
|
||||||
pass = equals(received, expected, [iterableEquality]);
|
pass = equals(received, expected, [iterableEquality]);
|
||||||
return pass === !matcherOptions.isNot;
|
return pass === !matcherOptions.isNot;
|
||||||
}, options.timeout, 100);
|
}, options.timeout, 100, testInfo._testFinished);
|
||||||
|
|
||||||
const message = pass
|
const message = pass
|
||||||
? () =>
|
? () =>
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ export async function toMatchText(
|
||||||
pass = expected.test(received);
|
pass = expected.test(received);
|
||||||
|
|
||||||
return pass === !matcherOptions.isNot;
|
return pass === !matcherOptions.isNot;
|
||||||
}, options.timeout, 100);
|
}, options.timeout, 100, testInfo._testFinished);
|
||||||
|
|
||||||
const stringSubstring = options.matchSubstring ? 'substring' : 'string';
|
const stringSubstring = options.matchSubstring ? 'substring' : 'string';
|
||||||
const message = pass
|
const message = pass
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Fixtures } from '../../types/test';
|
import type { Fixtures, TestInfo } from '../../types/test';
|
||||||
import type { Location } from '../../types/testReporter';
|
import type { Location } from '../../types/testReporter';
|
||||||
export * from '../../types/test';
|
export * from '../../types/test';
|
||||||
export { Location } from '../../types/testReporter';
|
export { Location } from '../../types/testReporter';
|
||||||
|
|
@ -24,3 +24,7 @@ export type FixturesWithLocation = {
|
||||||
location: Location;
|
location: Location;
|
||||||
};
|
};
|
||||||
export type Annotations = { type: string, description?: string }[];
|
export type Annotations = { type: string, description?: string }[];
|
||||||
|
|
||||||
|
export interface TestInfoImpl extends TestInfo {
|
||||||
|
_testFinished: Promise<void>;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,25 +71,40 @@ export async function raceAgainstDeadline<T>(promise: Promise<T>, deadline: numb
|
||||||
return (new DeadlineRunner(promise, deadline)).result;
|
return (new DeadlineRunner(promise, deadline)).result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pollUntilDeadline(state: ReturnType<Expect['getState']>, func: (remainingTime: number) => Promise<boolean>, pollTime: number | undefined, pollInterval: number): Promise<void> {
|
export async function pollUntilDeadline(state: ReturnType<Expect['getState']>, func: (remainingTime: number) => Promise<boolean>, pollTime: number | undefined, pollInterval: number, deadlinePromise: Promise<void>): Promise<void> {
|
||||||
const playwrightActionTimeout = (state as any).playwrightActionTimeout;
|
const playwrightActionTimeout = (state as any).playwrightActionTimeout;
|
||||||
pollTime = pollTime === 0 ? 0 : pollTime || playwrightActionTimeout;
|
pollTime = pollTime === 0 ? 0 : pollTime || playwrightActionTimeout;
|
||||||
const deadline = pollTime ? monotonicTime() + pollTime : 0;
|
const deadline = pollTime ? monotonicTime() + pollTime : 0;
|
||||||
|
|
||||||
while (true) {
|
let aborted = false;
|
||||||
|
const abortedPromise = deadlinePromise.then(() => {
|
||||||
|
aborted = true;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
while (!aborted) {
|
||||||
const remainingTime = deadline ? deadline - monotonicTime() : 1000 * 3600 * 24;
|
const remainingTime = deadline ? deadline - monotonicTime() : 1000 * 3600 * 24;
|
||||||
if (remainingTime <= 0)
|
if (remainingTime <= 0)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (await func(remainingTime))
|
// Either aborted, or func() returned truthy.
|
||||||
|
const result = await Promise.race([
|
||||||
|
func(remainingTime),
|
||||||
|
abortedPromise,
|
||||||
|
]);
|
||||||
|
if (result)
|
||||||
return;
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof errors.TimeoutError)
|
if (e instanceof errors.TimeoutError)
|
||||||
return;
|
return;
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
await new Promise(f => setTimeout(f, pollInterval));
|
|
||||||
|
let timer: NodeJS.Timer;
|
||||||
|
const timeoutPromise = new Promise(f => timer = setTimeout(f, pollInterval));
|
||||||
|
await Promise.race([abortedPromise, timeoutPromise]);
|
||||||
|
clearTimeout(timer!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import { TestBeginPayload, TestEndPayload, RunPayload, TestEntry, DonePayload, W
|
||||||
import { setCurrentTestInfo } from './globals';
|
import { setCurrentTestInfo } from './globals';
|
||||||
import { Loader } from './loader';
|
import { Loader } from './loader';
|
||||||
import { Modifier, Suite, TestCase } from './test';
|
import { Modifier, Suite, TestCase } from './test';
|
||||||
import { Annotations, TestError, TestInfo, WorkerInfo } from './types';
|
import { Annotations, TestError, TestInfo, TestInfoImpl, WorkerInfo } from './types';
|
||||||
import { ProjectImpl } from './project';
|
import { ProjectImpl } from './project';
|
||||||
import { FixturePool, FixtureRunner } from './fixtures';
|
import { FixturePool, FixtureRunner } from './fixtures';
|
||||||
|
|
||||||
|
|
@ -43,7 +43,7 @@ export class WorkerRunner extends EventEmitter {
|
||||||
private _fatalError: TestError | undefined;
|
private _fatalError: TestError | undefined;
|
||||||
private _entries = new Map<string, TestEntry>();
|
private _entries = new Map<string, TestEntry>();
|
||||||
private _isStopped = false;
|
private _isStopped = false;
|
||||||
_currentTest: { testId: string, testInfo: TestInfo } | null = null;
|
_currentTest: { testId: string, testInfo: TestInfoImpl, testFinishedCallback: () => void } | null = null;
|
||||||
|
|
||||||
constructor(params: WorkerInitParams) {
|
constructor(params: WorkerInitParams) {
|
||||||
super();
|
super();
|
||||||
|
|
@ -53,8 +53,10 @@ export class WorkerRunner extends EventEmitter {
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
// TODO: mark test as 'interrupted' instead.
|
// TODO: mark test as 'interrupted' instead.
|
||||||
if (this._currentTest && this._currentTest.testInfo.status === 'passed')
|
if (this._currentTest && this._currentTest.testInfo.status === 'passed') {
|
||||||
this._currentTest.testInfo.status = 'skipped';
|
this._currentTest.testInfo.status = 'skipped';
|
||||||
|
this._currentTest.testFinishedCallback();
|
||||||
|
}
|
||||||
this._isStopped = true;
|
this._isStopped = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,7 +220,8 @@ export class WorkerRunner extends EventEmitter {
|
||||||
return path.join(this._project.config.outputDir, testOutputDir);
|
return path.join(this._project.config.outputDir, testOutputDir);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const testInfo: TestInfo = {
|
let testFinishedCallback = () => {};
|
||||||
|
const testInfo: TestInfoImpl = {
|
||||||
...this._workerInfo,
|
...this._workerInfo,
|
||||||
title: test.title,
|
title: test.title,
|
||||||
file: test.location.file,
|
file: test.location.file,
|
||||||
|
|
@ -263,6 +266,7 @@ export class WorkerRunner extends EventEmitter {
|
||||||
if (deadlineRunner)
|
if (deadlineRunner)
|
||||||
deadlineRunner.setDeadline(deadline());
|
deadlineRunner.setDeadline(deadline());
|
||||||
},
|
},
|
||||||
|
_testFinished: new Promise(f => testFinishedCallback = f),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Inherit test.setTimeout() from parent suites.
|
// Inherit test.setTimeout() from parent suites.
|
||||||
|
|
@ -291,7 +295,7 @@ export class WorkerRunner extends EventEmitter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this._setCurrentTest({ testInfo, testId });
|
this._setCurrentTest({ testInfo, testId, testFinishedCallback });
|
||||||
const deadline = () => {
|
const deadline = () => {
|
||||||
return testInfo.timeout ? startTime + testInfo.timeout : undefined;
|
return testInfo.timeout ? startTime + testInfo.timeout : undefined;
|
||||||
};
|
};
|
||||||
|
|
@ -316,6 +320,7 @@ export class WorkerRunner extends EventEmitter {
|
||||||
if (!this._isStopped) {
|
if (!this._isStopped) {
|
||||||
// When stopped during the test run (usually either a user interruption or an unhandled error),
|
// When stopped during the test run (usually either a user interruption or an unhandled error),
|
||||||
// we do not run cleanup because the worker will cleanup() anyway.
|
// we do not run cleanup because the worker will cleanup() anyway.
|
||||||
|
testFinishedCallback();
|
||||||
if (!result.timedOut) {
|
if (!result.timedOut) {
|
||||||
deadlineRunner = new DeadlineRunner(this._runAfterHooks(test, testInfo), deadline());
|
deadlineRunner = new DeadlineRunner(this._runAfterHooks(test, testInfo), deadline());
|
||||||
deadlineRunner.setDeadline(deadline());
|
deadlineRunner.setDeadline(deadline());
|
||||||
|
|
@ -350,7 +355,7 @@ export class WorkerRunner extends EventEmitter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _setCurrentTest(currentTest: { testId: string, testInfo: TestInfo} | null) {
|
private _setCurrentTest(currentTest: { testId: string, testInfo: TestInfoImpl, testFinishedCallback: () => void } | null) {
|
||||||
this._currentTest = currentTest;
|
this._currentTest = currentTest;
|
||||||
setCurrentTestInfo(currentTest ? currentTest.testInfo : null);
|
setCurrentTestInfo(currentTest ? currentTest.testInfo : null);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -221,3 +221,22 @@ test('should support toHaveValue', async ({ runInlineTest }) => {
|
||||||
expect(result.passed).toBe(1);
|
expect(result.passed).toBe(1);
|
||||||
expect(result.exitCode).toBe(0);
|
expect(result.exitCode).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should print expected/received before timeout', async ({ runInlineTest }) => {
|
||||||
|
const result = await runInlineTest({
|
||||||
|
'a.test.ts': `
|
||||||
|
const { test } = pwt;
|
||||||
|
|
||||||
|
test('times out waiting for text', async ({ page }) => {
|
||||||
|
await page.setContent('<div id=node>Text content</div>');
|
||||||
|
await expect(page.locator('#node')).toHaveText('Text 2');
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
}, { workers: 1, timeout: 2000 });
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.passed).toBe(0);
|
||||||
|
expect(result.failed).toBe(1);
|
||||||
|
expect(result.output).toContain('Timeout of 2000ms exceeded.');
|
||||||
|
expect(stripAscii(result.output)).toContain('Expected string: "Text 2"');
|
||||||
|
expect(stripAscii(result.output)).toContain('Received string: "Text content"');
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue