feat(soft expect): mark steps with failed soft expects as failed (#19973)
Fixes #19673.
This commit is contained in:
parent
7d2cc06355
commit
28577afde4
|
|
@ -18,7 +18,7 @@ import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { monotonicTime } from 'playwright-core/lib/utils';
|
import { monotonicTime } from 'playwright-core/lib/utils';
|
||||||
import type { TestInfoError, TestInfo, TestStatus } from '../types/test';
|
import type { TestInfoError, TestInfo, TestStatus } from '../types/test';
|
||||||
import type { WorkerInitParams } from './ipc';
|
import type { StepBeginPayload, StepEndPayload, WorkerInitParams } from './ipc';
|
||||||
import type { Loader } from './loader';
|
import type { Loader } from './loader';
|
||||||
import type { TestCase } from './test';
|
import type { TestCase } from './test';
|
||||||
import { TimeoutManager } from './timeoutManager';
|
import { TimeoutManager } from './timeoutManager';
|
||||||
|
|
@ -26,7 +26,8 @@ import type { Annotation, FullConfigInternal, FullProjectInternal, TestStepInter
|
||||||
import { getContainedPath, normalizeAndSaveAttachment, sanitizeForFilePath, serializeError, trimLongString } from './util';
|
import { getContainedPath, normalizeAndSaveAttachment, sanitizeForFilePath, serializeError, trimLongString } from './util';
|
||||||
|
|
||||||
export class TestInfoImpl implements TestInfo {
|
export class TestInfoImpl implements TestInfo {
|
||||||
private _addStepImpl: (data: Omit<TestStepInternal, 'complete'>) => TestStepInternal;
|
private _onStepBegin: (payload: StepBeginPayload) => void;
|
||||||
|
private _onStepEnd: (payload: StepEndPayload) => void;
|
||||||
readonly _test: TestCase;
|
readonly _test: TestCase;
|
||||||
readonly _timeoutManager: TimeoutManager;
|
readonly _timeoutManager: TimeoutManager;
|
||||||
readonly _startTime: number;
|
readonly _startTime: number;
|
||||||
|
|
@ -34,6 +35,7 @@ export class TestInfoImpl implements TestInfo {
|
||||||
private _hasHardError: boolean = false;
|
private _hasHardError: boolean = false;
|
||||||
readonly _onTestFailureImmediateCallbacks = new Map<() => Promise<void>, string>(); // fn -> title
|
readonly _onTestFailureImmediateCallbacks = new Map<() => Promise<void>, string>(); // fn -> title
|
||||||
_didTimeout = false;
|
_didTimeout = false;
|
||||||
|
_lastStepId = 0;
|
||||||
|
|
||||||
// ------------ TestInfo fields ------------
|
// ------------ TestInfo fields ------------
|
||||||
readonly repeatEachIndex: number;
|
readonly repeatEachIndex: number;
|
||||||
|
|
@ -85,10 +87,12 @@ export class TestInfoImpl implements TestInfo {
|
||||||
workerParams: WorkerInitParams,
|
workerParams: WorkerInitParams,
|
||||||
test: TestCase,
|
test: TestCase,
|
||||||
retry: number,
|
retry: number,
|
||||||
addStepImpl: (data: Omit<TestStepInternal, 'complete'>) => TestStepInternal,
|
onStepBegin: (payload: StepBeginPayload) => void,
|
||||||
|
onStepEnd: (payload: StepEndPayload) => void,
|
||||||
) {
|
) {
|
||||||
this._test = test;
|
this._test = test;
|
||||||
this._addStepImpl = addStepImpl;
|
this._onStepBegin = onStepBegin;
|
||||||
|
this._onStepEnd = onStepEnd;
|
||||||
this._startTime = monotonicTime();
|
this._startTime = monotonicTime();
|
||||||
this._startWallTime = Date.now();
|
this._startWallTime = Date.now();
|
||||||
|
|
||||||
|
|
@ -183,8 +187,50 @@ export class TestInfoImpl implements TestInfo {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_addStep(data: Omit<TestStepInternal, 'complete'>) {
|
_addStep(data: Omit<TestStepInternal, 'complete'>): TestStepInternal {
|
||||||
return this._addStepImpl(data);
|
const stepId = `${data.category}@${data.title}@${++this._lastStepId}`;
|
||||||
|
let callbackHandled = false;
|
||||||
|
const firstErrorIndex = this.errors.length;
|
||||||
|
const step: TestStepInternal = {
|
||||||
|
...data,
|
||||||
|
complete: result => {
|
||||||
|
if (callbackHandled)
|
||||||
|
return;
|
||||||
|
callbackHandled = true;
|
||||||
|
let error: TestInfoError | undefined;
|
||||||
|
if (result.error instanceof Error) {
|
||||||
|
// Step function threw an error.
|
||||||
|
error = serializeError(result.error);
|
||||||
|
} else if (result.error) {
|
||||||
|
// Internal API step reported an error.
|
||||||
|
error = result.error;
|
||||||
|
} else {
|
||||||
|
// There was some other error (porbably soft expect) during step execution.
|
||||||
|
// Report step as failed to make it easier to spot.
|
||||||
|
error = this.errors[firstErrorIndex];
|
||||||
|
}
|
||||||
|
const payload: StepEndPayload = {
|
||||||
|
testId: this._test.id,
|
||||||
|
refinedTitle: step.refinedTitle,
|
||||||
|
stepId,
|
||||||
|
wallTime: Date.now(),
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
this._onStepEnd(payload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const hasLocation = data.location && !data.location.file.includes('@playwright');
|
||||||
|
// Sanitize location that comes from user land, it might have extra properties.
|
||||||
|
const location = data.location && hasLocation ? { file: data.location.file, line: data.location.line, column: data.location.column } : undefined;
|
||||||
|
const payload: StepBeginPayload = {
|
||||||
|
testId: this._test.id,
|
||||||
|
stepId,
|
||||||
|
...data,
|
||||||
|
location,
|
||||||
|
wallTime: Date.now(),
|
||||||
|
};
|
||||||
|
this._onStepBegin(payload);
|
||||||
|
return step;
|
||||||
}
|
}
|
||||||
|
|
||||||
_failWithError(error: TestInfoError, isHardError: boolean) {
|
_failWithError(error: TestInfoError, isHardError: boolean) {
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,11 @@ import { colors, rimraf } from 'playwright-core/lib/utilsBundle';
|
||||||
import util from 'util';
|
import util from 'util';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { debugTest, formatLocation, relativeFilePath, serializeError } from './util';
|
import { debugTest, formatLocation, relativeFilePath, serializeError } from './util';
|
||||||
import type { TestBeginPayload, TestEndPayload, RunPayload, DonePayload, WorkerInitParams, StepBeginPayload, StepEndPayload, TeardownErrorsPayload, WatchTestResolvedPayload } from './ipc';
|
import type { TestBeginPayload, TestEndPayload, RunPayload, DonePayload, WorkerInitParams, TeardownErrorsPayload, WatchTestResolvedPayload } from './ipc';
|
||||||
import { setCurrentTestInfo } from './globals';
|
import { setCurrentTestInfo } from './globals';
|
||||||
import { Loader } from './loader';
|
import { Loader } from './loader';
|
||||||
import type { Suite, TestCase } from './test';
|
import type { Suite, TestCase } from './test';
|
||||||
import type { Annotation, FullProjectInternal, TestInfoError, TestStepInternal } from './types';
|
import type { Annotation, FullProjectInternal, TestInfoError } from './types';
|
||||||
import { FixtureRunner } from './fixtures';
|
import { FixtureRunner } from './fixtures';
|
||||||
import { ManualPromise } from 'playwright-core/lib/utils/manualPromise';
|
import { ManualPromise } from 'playwright-core/lib/utils/manualPromise';
|
||||||
import { TestInfoImpl } from './testInfo';
|
import { TestInfoImpl } from './testInfo';
|
||||||
|
|
@ -226,40 +226,9 @@ export class WorkerRunner extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _runTest(test: TestCase, retry: number, nextTest: TestCase | undefined) {
|
private async _runTest(test: TestCase, retry: number, nextTest: TestCase | undefined) {
|
||||||
let lastStepId = 0;
|
const testInfo = new TestInfoImpl(this._loader, this._project, this._params, test, retry,
|
||||||
const testInfo = new TestInfoImpl(this._loader, this._project, this._params, test, retry, data => {
|
stepBeginPayload => this.emit('stepBegin', stepBeginPayload),
|
||||||
const stepId = `${data.category}@${data.title}@${++lastStepId}`;
|
stepEndPayload => this.emit('stepEnd', stepEndPayload));
|
||||||
let callbackHandled = false;
|
|
||||||
const step: TestStepInternal = {
|
|
||||||
...data,
|
|
||||||
complete: result => {
|
|
||||||
if (callbackHandled)
|
|
||||||
return;
|
|
||||||
callbackHandled = true;
|
|
||||||
const error = result.error instanceof Error ? serializeError(result.error) : result.error;
|
|
||||||
const payload: StepEndPayload = {
|
|
||||||
testId: test.id,
|
|
||||||
refinedTitle: step.refinedTitle,
|
|
||||||
stepId,
|
|
||||||
wallTime: Date.now(),
|
|
||||||
error,
|
|
||||||
};
|
|
||||||
this.emit('stepEnd', payload);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const hasLocation = data.location && !data.location.file.includes('@playwright');
|
|
||||||
// Sanitize location that comes from user land, it might have extra properties.
|
|
||||||
const location = data.location && hasLocation ? { file: data.location.file, line: data.location.line, column: data.location.column } : undefined;
|
|
||||||
const payload: StepBeginPayload = {
|
|
||||||
testId: test.id,
|
|
||||||
stepId,
|
|
||||||
...data,
|
|
||||||
location,
|
|
||||||
wallTime: Date.now(),
|
|
||||||
};
|
|
||||||
this.emit('stepBegin', payload);
|
|
||||||
return step;
|
|
||||||
});
|
|
||||||
|
|
||||||
const processAnnotation = (annotation: Annotation) => {
|
const processAnnotation = (annotation: Annotation) => {
|
||||||
testInfo.annotations.push(annotation);
|
testInfo.annotations.push(annotation);
|
||||||
|
|
|
||||||
|
|
@ -482,7 +482,7 @@ test('should warn user when viewing via file:// protocol', async ({ runInlineTes
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should show timed out steps and hooks', async ({ runInlineTest, page, showReport }) => {
|
test('should show failed and timed out steps and hooks', async ({ runInlineTest, page, showReport }) => {
|
||||||
const result = await runInlineTest({
|
const result = await runInlineTest({
|
||||||
'playwright.config.js': `
|
'playwright.config.js': `
|
||||||
module.exports = { timeout: 3000 };
|
module.exports = { timeout: 3000 };
|
||||||
|
|
@ -508,6 +508,12 @@ test('should show timed out steps and hooks', async ({ runInlineTest, page, show
|
||||||
console.log('afterAll 1');
|
console.log('afterAll 1');
|
||||||
});
|
});
|
||||||
test('fails', async ({ page }) => {
|
test('fails', async ({ page }) => {
|
||||||
|
await test.step('outer error', async () => {
|
||||||
|
await test.step('inner error', async () => {
|
||||||
|
expect.soft(1).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await test.step('outer step', async () => {
|
await test.step('outer step', async () => {
|
||||||
await test.step('inner step', async () => {
|
await test.step('inner step', async () => {
|
||||||
await new Promise(() => {});
|
await new Promise(() => {});
|
||||||
|
|
@ -521,9 +527,16 @@ test('should show timed out steps and hooks', async ({ runInlineTest, page, show
|
||||||
|
|
||||||
await showReport();
|
await showReport();
|
||||||
await page.click('text=fails');
|
await page.click('text=fails');
|
||||||
|
|
||||||
|
await page.click('.tree-item:has-text("outer error") >> text=outer error');
|
||||||
|
await page.click('.tree-item:has-text("outer error") >> .tree-item >> text=inner error');
|
||||||
|
await expect(page.locator('.tree-item:has-text("outer error") svg.color-text-danger')).toHaveCount(3);
|
||||||
|
await expect(page.locator('.tree-item:has-text("expect.soft.toBe"):not(:has-text("inner"))')).toBeVisible();
|
||||||
|
|
||||||
await page.click('text=outer step');
|
await page.click('text=outer step');
|
||||||
await expect(page.locator('.tree-item:has-text("outer step") svg.color-text-danger')).toHaveCount(2);
|
await expect(page.locator('.tree-item:has-text("outer step") svg.color-text-danger')).toHaveCount(2);
|
||||||
await expect(page.locator('.tree-item:has-text("inner step") svg.color-text-danger')).toHaveCount(2);
|
await expect(page.locator('.tree-item:has-text("inner step") svg.color-text-danger')).toHaveCount(2);
|
||||||
|
|
||||||
await page.click('text=Before Hooks');
|
await page.click('text=Before Hooks');
|
||||||
await expect(page.locator('.tree-item:has-text("Before Hooks") .tree-item')).toContainText([
|
await expect(page.locator('.tree-item:has-text("Before Hooks") .tree-item')).toContainText([
|
||||||
/beforeAll hook/,
|
/beforeAll hook/,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ class Reporter {
|
||||||
column: step.location.column ? typeof step.location.column : 0
|
column: step.location.column ? typeof step.location.column : 0
|
||||||
} : undefined,
|
} : undefined,
|
||||||
steps: step.steps.length ? step.steps.map(s => this.distillStep(s)) : undefined,
|
steps: step.steps.length ? step.steps.map(s => this.distillStep(s)) : undefined,
|
||||||
|
error: step.error ? '<error>' : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -401,3 +402,57 @@ test('should return value from step', async ({ runInlineTest }) => {
|
||||||
expect(result.output).toContain('v1 = 10');
|
expect(result.output).toContain('v1 = 10');
|
||||||
expect(result.output).toContain('v2 = 20');
|
expect(result.output).toContain('v2 = 20');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should mark step as failed when soft expect fails', async ({ runInlineTest }) => {
|
||||||
|
const result = await runInlineTest({
|
||||||
|
'reporter.ts': stepHierarchyReporter,
|
||||||
|
'playwright.config.ts': `
|
||||||
|
module.exports = {
|
||||||
|
reporter: './reporter',
|
||||||
|
};
|
||||||
|
`,
|
||||||
|
'a.test.ts': `
|
||||||
|
const { test } = pwt;
|
||||||
|
test('pass', async ({}) => {
|
||||||
|
await test.step('outer', async () => {
|
||||||
|
await test.step('inner', async () => {
|
||||||
|
expect.soft(1).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await test.step('passing', () => {});
|
||||||
|
});
|
||||||
|
`
|
||||||
|
}, { reporter: '', workers: 1 });
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
const objects = result.output.split('\n').filter(line => line.startsWith('%% ')).map(line => line.substring(3).trim()).filter(Boolean).map(line => JSON.parse(line));
|
||||||
|
expect(objects).toEqual([
|
||||||
|
{ title: 'Before Hooks', category: 'hook' },
|
||||||
|
{
|
||||||
|
title: 'outer',
|
||||||
|
category: 'test.step',
|
||||||
|
error: '<error>',
|
||||||
|
steps: [{
|
||||||
|
title: 'inner',
|
||||||
|
category: 'test.step',
|
||||||
|
error: '<error>',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
title: 'expect.soft.toBe',
|
||||||
|
category: 'expect',
|
||||||
|
location: { file: 'a.test.ts', line: 'number', column: 'number' },
|
||||||
|
error: '<error>'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
location: { file: 'a.test.ts', line: 'number', column: 'number' }
|
||||||
|
}],
|
||||||
|
location: { file: 'a.test.ts', line: 'number', column: 'number' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'passing',
|
||||||
|
category: 'test.step',
|
||||||
|
location: { file: 'a.test.ts', line: 'number', column: 'number' }
|
||||||
|
},
|
||||||
|
{ title: 'After Hooks', category: 'hook' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue