Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/src/test-reporter-api/class-reporter.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,3 +297,26 @@ Result of the test run.
- returns: <[boolean]>

Whether this reporter uses stdio for reporting. When it does not, Playwright Test could add some output to enhance user experience. If your reporter does not print to the terminal, it is strongly recommended to return `false`.

## optional async method: Reporter.plan
* since: v1.61

Called after the configuration has been resolved and before [`method: Reporter.onBegin`]. Allows a reporter to mark individual tests as skipped, excluded, fixed or failing.

### param: Reporter.plan.config
* since: v1.61
- `config` <[FullConfig]>

Resolved configuration.

### param: Reporter.plan.suite
* since: v1.61
- `suite` <[Suite]>

The root suite that contains all projects, files and test cases.

## optional method: Reporter.implementsSharding
* since: v1.61
- returns: <[boolean]>

When `true`, Playwright skips its built-in shard filter for this run, leaving sharding to the reporter (typically implemented inside [`method: Reporter.plan`] by calling [`method: TestCase.exclude`] on out-of-shard tests).
38 changes: 38 additions & 0 deletions docs/src/test-reporter-api/class-suite.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,41 @@ Returns a list of titles from the root down to this suite.

Returns the type of the suite. The Suites form the following hierarchy:
`root` -> `project` -> `file` -> `describe` -> ...`describe` -> `test`.

## method: Suite.skip
* since: v1.61

Mark every [TestCase] of this suite as skipped, see [`method: TestCase.skip`].

### param: Suite.skip.reason
* since: v1.61
- `reason` ?<[string]>

Optional explanation surfaced as the annotation description.

## method: Suite.fixme
* since: v1.61

Mark every [TestCase] of this suite as fixme, see [`method: TestCase.fixme`].

### param: Suite.fixme.reason
* since: v1.61
- `reason` ?<[string]>

Optional explanation surfaced as the annotation description.

## method: Suite.fail
* since: v1.61

Mark every [TestCase] of this suite as expected-to-fail, see [`method: TestCase.fail`].

### param: Suite.fail.reason
* since: v1.61
- `reason` ?<[string]>

Optional explanation surfaced as the annotation description.

## method: Suite.exclude
* since: v1.61

Must be called from inside [`method: Reporter.plan`], exclude this suite from the run. Excluded tests do not appear in the report and their body is not executed.
38 changes: 38 additions & 0 deletions docs/src/test-reporter-api/class-testcase.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,41 @@ Returns a list of titles from the root down to this test.
- returns: <[TestCaseType]<"test">>

Returns "test". Useful for detecting test cases in [`method: Suite.entries`].

## method: TestCase.skip
* since: v1.61

Must be called from inside [`method: Reporter.plan`], skip this test. The test body is not executed and the test is reported as skipped.

### param: TestCase.skip.reason
* since: v1.61
- `reason` ?<[string]>

Optional explanation surfaced as the annotation description.

## method: TestCase.fixme
* since: v1.61

Must be called from inside [`method: Reporter.plan`], mark this test as fixme. The test body is not executed and the test is reported as skipped, with the intention to fix it.

### param: TestCase.fixme.reason
* since: v1.61
- `reason` ?<[string]>

Optional explanation surfaced as the annotation description.

## method: TestCase.fail
* since: v1.61

Must be called from inside [`method: Reporter.plan`], mark this test as "should fail". Playwright runs the test and ensures it is actually failing, useful for documenting broken functionality until it is fixed.

### param: TestCase.fail.reason
* since: v1.61
- `reason` ?<[string]>

Optional explanation surfaced as the annotation description.

## method: TestCase.exclude
* since: v1.61

Must be called from inside [`method: Reporter.plan`], exclude this test from the run. Excluded tests do not appear in the report and their body is not executed.
1 change: 1 addition & 0 deletions packages/playwright/src/common/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export type StepEndPayload = {
export type TestEntry = {
testId: string;
retry: number;
planAnnotations: { type: string, description?: string, location?: { file: string, line: number, column: number } }[];
};

export type RunPayload = {
Expand Down
42 changes: 19 additions & 23 deletions packages/playwright/src/common/suiteUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,6 @@ import type { FullProjectInternal } from './config';
import type { Suite, TestCase } from './test';
import type { Matcher, TestCaseFilter } from '../util';

export function filterTestsRemoveEmptySuites(suite: Suite, filter: TestCaseFilter): boolean {
const filteredSuites = suite.suites.filter(child => filterTestsRemoveEmptySuites(child, filter));
const filteredTests = suite.tests.filter(filter);
const entries = new Set([...filteredSuites, ...filteredTests]);
suite._entries = suite._entries.filter(e => entries.has(e)); // Preserve the order.
return !!suite._entries.length;
}

export function bindFileSuiteToProject(project: FullProjectInternal, suite: Suite): Suite {
const relativeFile = path.relative(project.project.testDir, suite.location!.file);
const fileId = calculateSha1(toPosixPath(relativeFile)).slice(0, 20);
Expand Down Expand Up @@ -93,23 +85,27 @@ export function applyRepeatEachIndex(project: FullProjectInternal, fileSuite: Su
});
}

export function filterOnly(suite: Suite) {
if (!suite._getOnlyItems().length)
return;
const suiteFilter = (suite: Suite) => suite._only;
const testFilter = (test: TestCase) => test._only;
return filterSuiteWithOnlySemantics(suite, suiteFilter, testFilter);
}

function filterSuiteWithOnlySemantics(suite: Suite, suiteFilter: (suite: Suite) => boolean, testFilter: TestCaseFilter) {
const onlySuites = suite.suites.filter(child => filterSuiteWithOnlySemantics(child, suiteFilter, testFilter) || suiteFilter(child));
const onlyTests = suite.tests.filter(testFilter);
const onlyEntries = new Set([...onlySuites, ...onlyTests]);
if (onlyEntries.size) {
suite._entries = suite._entries.filter(e => onlyEntries.has(e)); // Preserve the order.
export function filterOnly(suite: Suite): boolean {
const toExclude: (Suite | TestCase)[] = [];
let hasOnlyInside = false;
for (const child of suite.suites) {
if (filterOnly(child))
hasOnlyInside = true;
else
toExclude.push(child);
}
for (const test of suite.tests) {
if (test._only)
hasOnlyInside = true;
else
toExclude.push(test);
}
if (hasOnlyInside) {
for (const e of toExclude)
e.exclude();
return true;
}
return false;
return !!suite._only;
}

export function createFiltersFromArguments(args: string[]): { fileFilter: Matcher, testFilter: TestCaseFilter } {
Expand Down
66 changes: 66 additions & 0 deletions packages/playwright/src/common/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
* limitations under the License.
*/

import { captureRawStack } from '@isomorphic/stackTrace';
import { rootTestType } from './testType';
import { computeTestCaseOutcome } from '../isomorphic/teleReceiver';
import { filteredStackTrace } from '../util';

import type { FixturesWithLocation, FullProjectInternal } from './config';
import type { FixturePool } from './fixtures';
Expand Down Expand Up @@ -96,6 +98,14 @@ export class Suite extends Base {
this._entries.unshift(suite);
}

_detach(child: Suite | TestCase) {
const idx = this._entries.indexOf(child);
if (idx !== -1)
this._entries.splice(idx, 1);
if (this._entries.length === 0)
this.parent?._detach(this);
}

allTests(): TestCase[] {
const result: TestCase[] = [];
const visit = (suite: Suite) => {
Expand Down Expand Up @@ -252,6 +262,28 @@ export class Suite extends Base {
project(): FullProject | undefined {
return this._fullProject?.project || this.parent?.project();
}

skip(reason?: string): void {
for (const entry of this.entries())
entry.skip(reason);
}

fixme(reason?: string): void {
for (const entry of this.entries())
entry.fixme(reason);
}

fail(reason?: string): void {
for (const entry of this.entries())
entry.fail(reason);
}

exclude(): void {
if (this.parent)
this.parent._detach(this);
else
this._entries = [];
}
}

export class TestCase extends Base implements reporterTypes.TestCase {
Expand All @@ -275,6 +307,7 @@ export class TestCase extends Base implements reporterTypes.TestCase {
_projectId = '';
// Explicitly declared tags that are not a part of the title.
_tags: string[] = [];
_planAnnotations: TestAnnotation[] = [];

constructor(title: string, fn: Function, testType: TestTypeImpl, location: Location) {
super(title);
Expand Down Expand Up @@ -309,6 +342,32 @@ export class TestCase extends Base implements reporterTypes.TestCase {
];
}

skip(reason?: string): void {
const annotation: TestAnnotation = { type: 'skip', description: reason, location: captureCallerLocation() };
this.annotations.push(annotation);
this._planAnnotations.push(annotation);
this.expectedStatus = 'skipped';
}

fixme(reason?: string): void {
const annotation: TestAnnotation = { type: 'fixme', description: reason, location: captureCallerLocation() };
this.annotations.push(annotation);
this._planAnnotations.push(annotation);
this.expectedStatus = 'skipped';
}

fail(reason?: string): void {
const annotation: TestAnnotation = { type: 'fail', description: reason, location: captureCallerLocation() };
this.annotations.push(annotation);
this._planAnnotations.push(annotation);
if (this.expectedStatus !== 'skipped')
this.expectedStatus = 'failed';
}

exclude(): void {
this.parent._detach(this);
}

_serialize(): any {
return {
kind: 'test',
Expand Down Expand Up @@ -384,3 +443,10 @@ export class TestCase extends Base implements reporterTypes.TestCase {
return path.join(' ');
}
}

function captureCallerLocation(): Location | undefined {
const frame = filteredStackTrace(captureRawStack())[0];
if (!frame?.file)
return undefined;
return { file: frame.file, line: frame.line ?? 0, column: frame.column ?? 0 };
}
26 changes: 26 additions & 0 deletions packages/playwright/src/isomorphic/teleReceiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,19 @@ export class TeleSuite implements reporterTypes.Suite {
suite.parent = this;
this._entries.push(suite);
}

skip(_reason?: string): void {
throw new Error('Disposition methods are not supported on a TeleSuite (read-only).');
}
fixme(_reason?: string): void {
throw new Error('Disposition methods are not supported on a TeleSuite (read-only).');
}
fail(_reason?: string): void {
throw new Error('Disposition methods are not supported on a TeleSuite (read-only).');
}
exclude(): void {
throw new Error('Disposition methods are not supported on a TeleSuite (read-only).');
}
}

export class TeleTestCase implements reporterTypes.TestCase {
Expand Down Expand Up @@ -693,6 +706,19 @@ export class TeleTestCase implements reporterTypes.TestCase {
this.results.push(result);
return result;
}

skip(_reason?: string): void {
throw new Error('Disposition methods are not supported on a TeleTestCase (read-only).');
}
fixme(_reason?: string): void {
throw new Error('Disposition methods are not supported on a TeleTestCase (read-only).');
}
fail(_reason?: string): void {
throw new Error('Disposition methods are not supported on a TeleTestCase (read-only).');
}
exclude(): void {
throw new Error('Disposition methods are not supported on a TeleTestCase (read-only).');
}
}

class TeleTestStep implements reporterTypes.TestStep {
Expand Down
10 changes: 9 additions & 1 deletion packages/playwright/src/reporters/internalReporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ export class InternalReporter implements ReporterV2 {
this._reporter.onConfigure?.(config);
}

async plan(config: FullConfig, suite: testNs.Suite) {
await this._reporter.plan?.(config, suite);
}

onBegin(suite: testNs.Suite) {
this._didBegin = true;
this._reporter.onBegin?.(suite);
Expand Down Expand Up @@ -108,7 +112,11 @@ export class InternalReporter implements ReporterV2 {
}

printsToStdio() {
return this._reporter.printsToStdio ? this._reporter.printsToStdio() : true;
return this._reporter.printsToStdio?.() ?? true;
}

implementsSharding() {
return this._reporter.implementsSharding?.() ?? false;
}

private _addSnippetToTestErrors(test: TestCase, result: TestResult) {
Expand Down
17 changes: 17 additions & 0 deletions packages/playwright/src/reporters/multiplexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ export class Multiplexer implements ReporterV2 {
wrap(() => reporter.onConfigure?.(config));
}

async plan(config: FullConfig, suite: test.Suite) {
// Unlike other reporter callbacks, `plan` errors are NOT swallowed —
// they propagate so the run aborts before onBegin. Reporters use plan
// to mutate the corpus; silently dropping a planning error would let
// an inconsistent (partial-mutation) state reach the workers.
for (const reporter of this._reporters)
await reporter.plan?.(config, suite);
}

onBegin(suite: test.Suite) {
for (const reporter of this._reporters)
wrap(() => reporter.onBegin?.(suite));
Expand Down Expand Up @@ -110,6 +119,14 @@ export class Multiplexer implements ReporterV2 {
return prints;
});
}

implementsSharding(): boolean {
return this._reporters.some(r => {
let shards = false;
wrap(() => shards = r.implementsSharding ? r.implementsSharding() : false);
return shards;
});
}
}

async function wrapAsync<T>(callback: () => T | Promise<T>) {
Expand Down
Loading
Loading