diff --git a/docs/src/test-reporter-api/class-reporter.md b/docs/src/test-reporter-api/class-reporter.md index 8cc8e48676d85..26f8d619e22ca 100644 --- a/docs/src/test-reporter-api/class-reporter.md +++ b/docs/src/test-reporter-api/class-reporter.md @@ -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.preprocessSuite +* since: v1.61 +- `result` ?<[Object]> + - `implementsSharding` ?<[boolean]> When `true`, Playwright skips its built-in shard filter for this run, leaving sharding to the reporter (typically implemented by calling [`method: TestCase.exclude`] on out-of-shard tests). + +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.preprocessSuite.config +* since: v1.61 +- `config` <[FullConfig]> + +Resolved configuration. + +### param: Reporter.preprocessSuite.suite +* since: v1.61 +- `suite` <[Suite]> + +The root suite that contains the projects, files and test cases that will run. + +The suite reflects `--project`, `--grep`/`--grep-invert` and `.only` filtering, so it only contains tests that match the current invocation. It contains only the top-level projects being run — setup and dependency projects are not included and cannot be excluded from here. + +The suite ignores the `--shard` argument: it always contains the full, un-sharded corpus. Playwright applies its built-in sharding after [`method: Reporter.preprocessSuite`] returns, unless the returned `implementsSharding` is `true`. diff --git a/docs/src/test-reporter-api/class-suite.md b/docs/src/test-reporter-api/class-suite.md index 1d458c842b3f0..f9b06e36a5025 100644 --- a/docs/src/test-reporter-api/class-suite.md +++ b/docs/src/test-reporter-api/class-suite.md @@ -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.preprocessSuite`], exclude this suite from the run. Excluded tests do not appear in the report and their body is not executed. diff --git a/docs/src/test-reporter-api/class-testcase.md b/docs/src/test-reporter-api/class-testcase.md index 22a8588fb0934..95e6052e32ae7 100644 --- a/docs/src/test-reporter-api/class-testcase.md +++ b/docs/src/test-reporter-api/class-testcase.md @@ -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.preprocessSuite`], 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.preprocessSuite`], 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.preprocessSuite`], 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.preprocessSuite`], exclude this test from the run. Excluded tests do not appear in the report and their body is not executed. diff --git a/packages/playwright/src/common/ipc.ts b/packages/playwright/src/common/ipc.ts index 3fdaeccfcb6d5..27fa4287fe552 100644 --- a/packages/playwright/src/common/ipc.ts +++ b/packages/playwright/src/common/ipc.ts @@ -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 = { diff --git a/packages/playwright/src/common/test.ts b/packages/playwright/src/common/test.ts index fa8d8c20032d0..a3be0940cff9b 100644 --- a/packages/playwright/src/common/test.ts +++ b/packages/playwright/src/common/test.ts @@ -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'; @@ -58,6 +60,9 @@ export class Suite extends Base { _parallelMode: 'none' | 'default' | 'serial' | 'parallel' = 'none'; _fullProject: FullProjectInternal | undefined; _fileId: string | undefined; + // Set on the root suite for the duration of Reporter.preprocessSuite(), gating + // the disposition methods (skip/fixme/fail/exclude) to that phase only. + _preprocessing = false; readonly _type: 'root' | 'project' | 'file' | 'describe'; constructor(title: string, type: 'root' | 'project' | 'file' | 'describe') { @@ -96,6 +101,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) => { @@ -252,6 +265,34 @@ 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._rootSuite()._preprocessing) + throw new Error(`Suite.exclude() can only be called from Reporter.preprocessSuite().`); + if (this.parent) + this.parent._detach(this); + else + this._entries = []; + } + + _rootSuite(): Suite { + return this.parent?._rootSuite() ?? this; + } } export class TestCase extends Base implements reporterTypes.TestCase { @@ -275,6 +316,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); @@ -309,6 +351,44 @@ export class TestCase extends Base implements reporterTypes.TestCase { ]; } + skip(reason?: string): void { + if (!this._rootSuite()._preprocessing) + throw new Error(`TestCase.skip() can only be called from Reporter.preprocessSuite().`); + const annotation: TestAnnotation = { type: 'skip', description: reason, location: captureCallerLocation() }; + this.annotations.push(annotation); + this._planAnnotations.push(annotation); + this.expectedStatus = 'skipped'; + } + + fixme(reason?: string): void { + if (!this._rootSuite()._preprocessing) + throw new Error(`TestCase.fixme() can only be called from Reporter.preprocessSuite().`); + const annotation: TestAnnotation = { type: 'fixme', description: reason, location: captureCallerLocation() }; + this.annotations.push(annotation); + this._planAnnotations.push(annotation); + this.expectedStatus = 'skipped'; + } + + fail(reason?: string): void { + if (!this._rootSuite()._preprocessing) + throw new Error(`TestCase.fail() can only be called from Reporter.preprocessSuite().`); + 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 { + if (!this._rootSuite()._preprocessing) + throw new Error(`TestCase.exclude() can only be called from Reporter.preprocessSuite().`); + this.parent._detach(this); + } + + _rootSuite(): Suite { + return this.parent._rootSuite(); + } + _serialize(): any { return { kind: 'test', @@ -384,3 +464,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 }; +} diff --git a/packages/playwright/src/isomorphic/teleReceiver.ts b/packages/playwright/src/isomorphic/teleReceiver.ts index bfde074d8d1c1..b8a15723b8408 100644 --- a/packages/playwright/src/isomorphic/teleReceiver.ts +++ b/packages/playwright/src/isomorphic/teleReceiver.ts @@ -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 { @@ -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 { diff --git a/packages/playwright/src/reporters/internalReporter.ts b/packages/playwright/src/reporters/internalReporter.ts index 23429ffbee1a4..3afea92b23ec0 100644 --- a/packages/playwright/src/reporters/internalReporter.ts +++ b/packages/playwright/src/reporters/internalReporter.ts @@ -50,6 +50,15 @@ export class InternalReporter implements ReporterV2 { this._reporter.onConfigure?.(config); } + async preprocessSuite(config: FullConfig, suite: testNs.Suite) { + suite._preprocessing = true; + try { + return await this._reporter.preprocessSuite?.(config, suite); + } finally { + suite._preprocessing = false; + } + } + onBegin(suite: testNs.Suite) { this._didBegin = true; this._reporter.onBegin?.(suite); @@ -108,7 +117,7 @@ export class InternalReporter implements ReporterV2 { } printsToStdio() { - return this._reporter.printsToStdio ? this._reporter.printsToStdio() : true; + return this._reporter.printsToStdio?.() ?? true; } private _addSnippetToTestErrors(test: TestCase, result: TestResult) { diff --git a/packages/playwright/src/reporters/multiplexer.ts b/packages/playwright/src/reporters/multiplexer.ts index 6596a2bd66f72..bf271824a034a 100644 --- a/packages/playwright/src/reporters/multiplexer.ts +++ b/packages/playwright/src/reporters/multiplexer.ts @@ -34,6 +34,22 @@ export class Multiplexer implements ReporterV2 { wrap(() => reporter.onConfigure?.(config)); } + async preprocessSuite(config: FullConfig, suite: test.Suite) { + // Unlike other reporter callbacks, `preprocessSuite` errors are NOT swallowed — + // they propagate so the run aborts before onBegin. Reporters use preprocessSuite + // to mutate the corpus; silently dropping a planning error would let + // an inconsistent (partial-mutation) state reach the workers. + const shardingReporters: ReporterV2[] = []; + for (const reporter of this._reporters) { + const result = await reporter.preprocessSuite?.(config, suite); + if (result?.implementsSharding) + shardingReporters.push(reporter); + } + if (shardingReporters.length > 1) + throw new Error(`Multiple reporters declare 'implementsSharding': ${shardingReporters.map(r => r.constructor?.name ?? 'reporter').join(', ')}. Only one reporter may handle sharding.`); + return { implementsSharding: shardingReporters.length > 0 }; + } + onBegin(suite: test.Suite) { for (const reporter of this._reporters) wrap(() => reporter.onBegin?.(suite)); diff --git a/packages/playwright/src/reporters/reporterV2.ts b/packages/playwright/src/reporters/reporterV2.ts index 23cffcb4bb916..3d5a65a53d46a 100644 --- a/packages/playwright/src/reporters/reporterV2.ts +++ b/packages/playwright/src/reporters/reporterV2.ts @@ -28,6 +28,7 @@ export interface ReportEndParams { export interface ReporterV2 { onConfigure?(config: FullConfig): void; + preprocessSuite?(config: FullConfig, suite: Suite): { implementsSharding?: boolean } | Promise<{ implementsSharding?: boolean } | undefined | void> | void; onBegin?(suite: Suite): void; onTestBegin?(test: TestCase, result: TestResult): void; onStdOut?(chunk: string | Buffer, test?: TestCase, result?: TestResult): void; @@ -79,6 +80,10 @@ class ReporterV2Wrapper implements ReporterV2 { this._config = config; } + async preprocessSuite(config: FullConfig, suite: Suite) { + return await this._reporter.preprocessSuite?.(config, suite); + } + onBegin(suite: Suite) { this._reporter.onBegin?.(this._config, suite); diff --git a/packages/playwright/src/runner/dispatcher.ts b/packages/playwright/src/runner/dispatcher.ts index 29f4f4e84eb1a..a0fbcccf04083 100644 --- a/packages/playwright/src/runner/dispatcher.ts +++ b/packages/playwright/src/runner/dispatcher.ts @@ -563,7 +563,7 @@ class JobDispatcher { const runPayload: ipc.RunPayload = { file: this.job.requireFile, entries: this.job.tests.map(test => { - return { testId: test.id, retry: test.results.length }; + return { testId: test.id, retry: test.results.length, planAnnotations: test._planAnnotations }; }), }; worker.runTestGroup(runPayload); diff --git a/packages/playwright/src/runner/loadUtils.ts b/packages/playwright/src/runner/loadUtils.ts index a2cce3b386459..9485a923ba5a1 100644 --- a/packages/playwright/src/runner/loadUtils.ts +++ b/packages/playwright/src/runner/loadUtils.ts @@ -165,8 +165,9 @@ export async function createRootSuite(testRun: TestRun, errors: TestError[], sho } } + const preprocessResult = await testRun.reporter.preprocessSuite(config.config, rootSuite); // Shard only the top-level projects. - if (config.config.shard) { + if (config.config.shard && !preprocessResult?.implementsSharding) { // Create test groups for top-level projects. const testGroups: TestGroup[] = []; for (const projectSuite of rootSuite.suites) { diff --git a/packages/playwright/src/worker/workerMain.ts b/packages/playwright/src/worker/workerMain.ts index 22e8f46451639..163b2e455e4e7 100644 --- a/packages/playwright/src/worker/workerMain.ts +++ b/packages/playwright/src/worker/workerMain.ts @@ -222,6 +222,8 @@ export class WorkerMain extends ProcessRunner { suiteUtils.applyRepeatEachIndex(this._project, suite, this._params.repeatEachIndex); suiteUtils.filterTestsRemoveEmptySuites(suite, test => entries.has(test.id)); const tests = suite.allTests(); + for (const test of tests) + test.annotations.push(...entries.get(test.id)!.planAnnotations); // Collect test IDs that were not found in the worker // (e.g. test titles changed between runner and worker). diff --git a/packages/playwright/types/testReporter.d.ts b/packages/playwright/types/testReporter.d.ts index e5bd52c42995e..3ae8fedda5f5c 100644 --- a/packages/playwright/types/testReporter.d.ts +++ b/packages/playwright/types/testReporter.d.ts @@ -145,6 +145,23 @@ export interface FullResult { * [reporter.onBegin(config, suite)](https://playwright.dev/docs/api/class-reporter#reporter-on-begin). */ export interface Reporter { + /** + * Called after the configuration has been resolved and before + * [reporter.onBegin(config, suite)](https://playwright.dev/docs/api/class-reporter#reporter-on-begin). Allows a + * reporter to mark individual tests as skipped, excluded, fixed or failing. + * @param config Resolved configuration. + * @param suite The root suite that contains the projects, files and test cases that will run. + * + * The suite reflects `--project`, `--grep`/`--grep-invert` and `.only` filtering, so it only contains tests that + * match the current invocation. It contains only the top-level projects being run — setup and dependency projects are + * not included and cannot be excluded from here. + * + * The suite ignores the `--shard` argument: it always contains the full, un-sharded corpus. Playwright applies its + * built-in sharding after + * [reporter.preprocessSuite(config, suite)](https://playwright.dev/docs/api/class-reporter#reporter-preprocess-suite) + * returns, unless the returned `implementsSharding` is `true`. + */ + preprocessSuite?(config: FullConfig, suite: Suite): Promise<{ implementsSharding?: boolean } | undefined | void> | { implementsSharding?: boolean } | void; /** * Called after all tests have been run, or testing has been interrupted. Note that this method may return a [Promise] * and Playwright Test will await it. Reporter is allowed to override the status and hence affect the exit code of the @@ -368,11 +385,39 @@ export interface Suite { */ entries(): Array; + /** + * Must be called from inside + * [reporter.preprocessSuite(config, suite)](https://playwright.dev/docs/api/class-reporter#reporter-preprocess-suite), + * exclude this suite from the run. Excluded tests do not appear in the report and their body is not executed. + */ + exclude(): void; + + /** + * Mark every [TestCase](https://playwright.dev/docs/api/class-testcase) of this suite as expected-to-fail, see + * [testCase.fail([reason])](https://playwright.dev/docs/api/class-testcase#test-case-fail). + * @param reason Optional explanation surfaced as the annotation description. + */ + fail(reason?: string): void; + + /** + * Mark every [TestCase](https://playwright.dev/docs/api/class-testcase) of this suite as fixme, see + * [testCase.fixme([reason])](https://playwright.dev/docs/api/class-testcase#test-case-fixme). + * @param reason Optional explanation surfaced as the annotation description. + */ + fixme(reason?: string): void; + /** * Configuration of the project this suite belongs to, or [void] for the root suite. */ project(): FullProject|undefined; + /** + * Mark every [TestCase](https://playwright.dev/docs/api/class-testcase) of this suite as skipped, see + * [testCase.skip([reason])](https://playwright.dev/docs/api/class-testcase#test-case-skip). + * @param reason Optional explanation surfaced as the annotation description. + */ + skip(reason?: string): void; + /** * Returns a list of titles from the root down to this suite. */ @@ -427,6 +472,31 @@ export interface Suite { * projects' suites. */ export interface TestCase { + /** + * Must be called from inside + * [reporter.preprocessSuite(config, suite)](https://playwright.dev/docs/api/class-reporter#reporter-preprocess-suite), + * exclude this test from the run. Excluded tests do not appear in the report and their body is not executed. + */ + exclude(): void; + + /** + * Must be called from inside + * [reporter.preprocessSuite(config, suite)](https://playwright.dev/docs/api/class-reporter#reporter-preprocess-suite), + * 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 reason Optional explanation surfaced as the annotation description. + */ + fail(reason?: string): void; + + /** + * Must be called from inside + * [reporter.preprocessSuite(config, suite)](https://playwright.dev/docs/api/class-reporter#reporter-preprocess-suite), + * 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 reason Optional explanation surfaced as the annotation description. + */ + fixme(reason?: string): void; + /** * Whether the test is considered running fine. Non-ok tests fail the test run with non-zero exit code. */ @@ -440,6 +510,14 @@ export interface TestCase { */ outcome(): "skipped"|"expected"|"unexpected"|"flaky"; + /** + * Must be called from inside + * [reporter.preprocessSuite(config, suite)](https://playwright.dev/docs/api/class-reporter#reporter-preprocess-suite), + * skip this test. The test body is not executed and the test is reported as skipped. + * @param reason Optional explanation surfaced as the annotation description. + */ + skip(reason?: string): void; + /** * Returns a list of titles from the root down to this test. */ diff --git a/tests/playwright-test/reporter-plan.spec.ts b/tests/playwright-test/reporter-plan.spec.ts new file mode 100644 index 0000000000000..2a9e0d4bedcb7 --- /dev/null +++ b/tests/playwright-test/reporter-plan.spec.ts @@ -0,0 +1,520 @@ +/** + * 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 * as fs from 'fs'; +import { test, expect } from './playwright-test-fixtures'; +import { Reporter, FullConfig, Suite, TestCase, TestResult } from 'packages/playwright-test/reporter'; + +test('plan runs between project setup and onBegin, sees the .only-narrowed corpus, and can skip tests', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'reporter.ts': ` + class Reporter { + async preprocessSuite(config, suite) { + console.log('%% plan: ' + suite.allTests().map(t => t.title).join(',')); + for (const t of suite.allTests()) + if (t.title.includes('skip-me')) t.skip('planned skip'); + } + onBegin(config, suite) { + console.log('%% onBegin: ' + suite.allTests().map(t => t.title).join(',')); + } + onTestEnd(test, result) { + console.log('%% end ' + test.title + ' status=' + result.status + ' expected=' + test.expectedStatus + ' ann=' + test.annotations.map(a => a.type + ':' + (a.description || '')).join(',')); + } + } + module.exports = Reporter; + `, + 'playwright.config.ts': `module.exports = { reporter: './reporter.ts' };`, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('ignored-by-only', async () => {}); + test.only('run-me', async () => {}); + test.only('skip-me', async () => { throw new Error('should not run'); }); + `, + }, { reporter: '', workers: 1 }); + + expect(result.exitCode).toBe(0); + expect(result.outputLines).toEqual([ + 'plan: run-me,skip-me', + 'onBegin: run-me,skip-me', + 'end run-me status=passed expected=passed ann=', + 'end skip-me status=skipped expected=skipped ann=skip:planned skip', + ]); +}); + +test('TestCase.exclude removes test from run and report', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'reporter.ts': ` + class Reporter { + async preprocessSuite(config, suite) { + for (const t of suite.allTests()) + if (t.title === 'excluded') t.exclude(); + } + onBegin(config, suite) { + console.log('%% begin: ' + suite.allTests().map(t => t.title).join(',')); + } + onTestEnd(test, result) { + console.log('%% ran ' + test.title); + } + } + module.exports = Reporter; + `, + 'playwright.config.ts': `module.exports = { reporter: './reporter.ts' };`, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('kept', async () => {}); + test('excluded', async () => { throw new Error('should not run'); }); + `, + }, { reporter: '', workers: 1 }); + + expect(result.exitCode).toBe(0); + expect(result.outputLines).toEqual([ + 'begin: kept', + 'ran kept', + ]); +}); + +test('Suite.skip cascades to all descendants', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'reporter.ts': ` + class Reporter { + async preprocessSuite(config, suite) { + const visit = (s) => { + if (s.title === 'doomed') s.skip('whole group'); + for (const child of s.suites || []) visit(child); + }; + visit(suite); + } + onTestEnd(test, result) { + console.log('%% ' + test.title + ':' + result.status + ':' + test.expectedStatus); + } + } + module.exports = Reporter; + `, + 'playwright.config.ts': `module.exports = { reporter: './reporter.ts' };`, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test.describe('doomed', () => { + test('one', async () => { throw new Error('nope'); }); + test('two', async () => { throw new Error('nope'); }); + }); + test('keep', async () => {}); + `, + }, { reporter: '', workers: 1 }); + + expect(result.exitCode).toBe(0); + expect(result.outputLines.sort()).toEqual([ + 'keep:passed:passed', + 'one:skipped:skipped', + 'two:skipped:skipped', + ]); +}); + +test('disposition methods throw when called outside preprocessSuite', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'reporter.ts': ` + class Reporter { + onBegin(config, suite) { + try { + suite.allTests()[0].exclude(); + console.log('%% no-throw'); + } catch (e) { + console.log('%% threw: ' + e.message); + } + } + } + module.exports = Reporter; + `, + 'playwright.config.ts': `module.exports = { reporter: './reporter.ts' };`, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('t', async () => {}); + `, + }, { reporter: '', workers: 1 }); + + expect(result.exitCode).toBe(0); + expect(result.outputLines).toContain('threw: TestCase.exclude() can only be called from Reporter.preprocessSuite().'); +}); + +test('plan throwing aborts the run before onBegin', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'reporter.ts': ` + class Reporter { + async preprocessSuite(config, suite) { + throw new Error('plan-aborted'); + } + onBegin(config, suite) { + console.log('%% onBegin: ' + suite.allTests().length); + } + onError(err) { + console.log('%% error: ' + err.message); + } + } + module.exports = Reporter; + `, + 'playwright.config.ts': `module.exports = { reporter: './reporter.ts' };`, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('one', async () => {}); + `, + }, { reporter: '', workers: 1 }); + + expect(result.exitCode).not.toBe(0); + expect(result.outputLines).toContain('error: Error: plan-aborted'); + // Synthetic empty-suite onBegin is OK; the real onBegin (size 1) must NOT happen. + expect(result.outputLines).not.toContain('onBegin: 1'); +}); + +test('multiple reporters: plan called in order, annotations accumulate, exclude prunes for next reporter', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'first.ts': ` + class R { + async preprocessSuite(config, suite) { + console.log('%% first plan sees: ' + suite.allTests().map(t => t.title).join(',')); + for (const t of suite.allTests()) { + if (t.title === 'gone') t.exclude(); + else t.fail('first reason'); + } + } + onTestEnd(test, result) { + console.log('%% first onTestEnd: ' + test.expectedStatus + ' ann=' + test.annotations.map(a => a.type).join(',')); + } + } + module.exports = R; + `, + 'second.ts': ` + class R { + async preprocessSuite(config, suite) { + console.log('%% second plan sees: ' + suite.allTests().map(t => t.title).join(',')); + suite.allTests()[0].skip('second reason'); + } + } + module.exports = R; + `, + 'playwright.config.ts': `module.exports = { reporter: [['./first.ts'], ['./second.ts']] };`, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('kept', async () => {}); + test('gone', async () => { throw new Error('should not run'); }); + `, + }, { reporter: '', workers: 1 }); + + expect(result.exitCode).toBe(0); + // skip beats fail in expectedStatus, both annotations accumulate. + expect(result.outputLines).toEqual([ + 'first plan sees: kept,gone', + 'second plan sees: kept', + 'first onTestEnd: skipped ann=fail,skip', + ]); +}); + +test('implementsSharding disables built-in shard filter', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'reporter.ts': ` + class R { + async preprocessSuite(config, suite) { + let i = 0; + for (const t of suite.allTests()) { + if (i++ % 2 === 1) t.exclude(); + } + return { implementsSharding: true }; + } + onBegin(config, suite) { + console.log('%% begin: ' + suite.allTests().map(t => t.title).join(',')); + } + } + module.exports = R; + `, + 'playwright.config.ts': `module.exports = { reporter: './reporter.ts', shard: { current: 1, total: 2 } };`, + 'a.test.ts': ` + import { test } from '@playwright/test'; + for (let i = 0; i < 4; i++) + test('t' + i, async () => {}); + `, + }, { reporter: '', workers: 1 }); + + expect(result.exitCode).toBe(0); + // Reporter sees all 4 tests and excludes every other → t0, t2 kept. + // Built-in shard would have produced a different split (e.g. t0, t1) and + // would further reduce the corpus; the assertion proves it did not run. + expect(result.outputLines).toEqual(['begin: t0,t2']); +}); + +test('multiple reporters declaring implementsSharding throws', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'reporter-a.ts': ` + class A { + preprocessSuite() { return { implementsSharding: true }; } + onError(err) { console.log('%% error: ' + err.message); } + } + module.exports = A; + `, + 'reporter-b.ts': ` + class B { preprocessSuite() { return { implementsSharding: true }; } } + module.exports = B; + `, + 'playwright.config.ts': `module.exports = { reporter: [['./reporter-a.ts'], ['./reporter-b.ts']] };`, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('t', async () => {}); + `, + }, { reporter: '', workers: 1 }); + + expect(result.exitCode).not.toBe(0); + expect(result.outputLines.join('\n')).toContain(`Multiple reporters declare 'implementsSharding'`); +}); + +test('plan.suite contains only top-level projects, not dependency/setup projects', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'reporter.ts': ` + class Reporter { + async preprocessSuite(config, suite) { + // The suite only exposes top-level projects, so a reporter has no handle on + // setup/dependency project tests and therefore cannot exclude them. + console.log('%% plan projects: ' + suite.suites.map(s => s.title).join(',')); + console.log('%% plan tests: ' + suite.allTests().map(t => t.title).join(',')); + } + onTestEnd(test, result) { + console.log('%% ran ' + test.parent.project().name + '/' + test.title); + } + } + module.exports = Reporter; + `, + 'playwright.config.ts': ` + module.exports = { + reporter: './reporter.ts', + projects: [ + { name: 'setup', testMatch: /a\\.setup\\.ts/ }, + { name: 'main', testMatch: /a\\.test\\.ts/, dependencies: ['setup'] }, + ], + }; + `, + 'a.setup.ts': ` + import { test } from '@playwright/test'; + test('setup-test', async () => {}); + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('main-test', async () => {}); + `, + }, { reporter: '', workers: 1 }, undefined, { additionalArgs: ['--project=main'] }); + + expect(result.exitCode).toBe(0); + // plan only sees the top-level 'main' project; the 'setup' dependency is prepended afterwards. + expect(result.outputLines).toContain('plan projects: main'); + // 'setup-test' is absent from the plan suite, proving setup/dependency tests are not exposed. + expect(result.outputLines).toContain('plan tests: main-test'); + // Both the dependency and the main project still run. + expect(result.outputLines).toContain('ran setup/setup-test'); + expect(result.outputLines).toContain('ran main/main-test'); +}); + +test('plan.suite respects --grep filtering', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'reporter.ts': ` + class Reporter { + async preprocessSuite(config, suite) { + console.log('%% plan: ' + suite.allTests().map(t => t.title).join(',')); + } + } + module.exports = Reporter; + `, + 'playwright.config.ts': `module.exports = { reporter: './reporter.ts' };`, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('foo-one', async () => {}); + test('bar-two', async () => {}); + `, + }, { reporter: '', workers: 1, grep: 'foo' }); + + expect(result.exitCode).toBe(0); + expect(result.outputLines).toEqual(['plan: foo-one']); +}); + +test('plan.suite respects --project filtering', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'reporter.ts': ` + class Reporter { + async preprocessSuite(config, suite) { + console.log('%% plan projects: ' + suite.suites.map(s => s.title).join(',')); + } + } + module.exports = Reporter; + `, + 'playwright.config.ts': ` + module.exports = { + reporter: './reporter.ts', + projects: [{ name: 'one' }, { name: 'two' }], + }; + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('t', async () => {}); + `, + }, { reporter: '', workers: 1, project: 'one' }); + + expect(result.exitCode).toBe(0); + expect(result.outputLines).toEqual(['plan projects: one']); +}); + +test('plan.suite ignores --shard; built-in sharding applies after plan', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'reporter.ts': ` + class Reporter { + async preprocessSuite(config, suite) { + // plan sees the full, un-sharded corpus. + console.log('%% plan: ' + suite.allTests().map(t => t.title).join(',')); + } + onBegin(config, suite) { + // built-in sharding has narrowed the run after plan. + console.log('%% begin: ' + suite.allTests().map(t => t.title).join(',')); + } + } + module.exports = Reporter; + `, + 'playwright.config.ts': `module.exports = { reporter: './reporter.ts', fullyParallel: true };`, + 'a.test.ts': ` + import { test } from '@playwright/test'; + for (let i = 0; i < 4; i++) + test('t' + i, async () => {}); + `, + }, { reporter: '', workers: 1, shard: '1/2' }); + + expect(result.exitCode).toBe(0); + // plan observes all four tests regardless of --shard. + expect(result.outputLines).toContain('plan: t0,t1,t2,t3'); + // The built-in shard filter runs after plan and reduces the corpus. + const beginLine = result.outputLines.find(l => l.startsWith('begin: ')); + expect(beginLine).toBeTruthy(); + expect(beginLine!.slice('begin: '.length).split(',').length).toBe(2); +}); + +test('plan annotations capture caller location pointing at reporter', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'reporter.ts': ` + class Reporter { + async preprocessSuite(config, suite) { + for (const t of suite.allTests()) + t.skip('planned'); + } + onTestEnd(test, result) { + const a = test.annotations.find(a => a.type === 'skip'); + console.log('%% loc=' + (a?.location ? require('path').basename(a.location.file) + ':' + a.location.line : 'NONE')); + } + } + module.exports = Reporter; + `, + 'playwright.config.ts': `module.exports = { reporter: './reporter.ts' };`, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('t', async () => {}); + `, + }, { reporter: '', workers: 1 }); + + expect(result.exitCode).toBe(0); + expect(result.outputLines).toEqual(['loc=reporter.ts:5']); +}); + +test('greedy time-based scheduling can be built on preprocessSuite', async ({ runInlineTest, mergeReports }) => { + test.slow(); + + const timingsFile = test.info().outputPath('timings.json'); + + const files = { + 'scheduler.ts': ` + const fs = require('fs'); + ${class Scheduler implements Reporter { + _durations = new Map(); + _config!: FullConfig; + preprocessSuite(config: FullConfig, suite: Suite) { + if (!config.shard) + throw new Error('Should not be called during merge step.'); + const file = process.env.TIMINGS_FILE; + const timings = file && fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : null; + if (!timings) + return { implementsSharding: false }; + const total = config.shard.total; + const tests = suite.allTests(); + const known = Object.values(timings) as number[]; + const avg = known.length ? known.reduce((a, b) => a + b, 0) / known.length : 0; + const sorted = [...tests].sort((a, b) => (timings[b.id] || avg) - (timings[a.id] || avg)); + const loads = new Array(total).fill(0); + const assignment = new Map(); + for (const t of sorted) { + let min = 0; + for (let i = 1; i < total; i++) { + if (loads[i] < loads[min]) + min = i; + } + loads[min] += (timings[t.id] || avg); + assignment.set(t, min); + } + for (const t of tests) { + if (assignment.get(t) !== config.shard.current - 1) + t.exclude(); + } + return { implementsSharding: true }; + } + onBegin(config: FullConfig, suite: Suite) { + this._config = config; + const shard = config.shard ? config.shard.current + '/' + config.shard.total : 'none'; + console.log('%% shard ' + shard + ': ' + suite.allTests().map(t => t.title).join(',')); + } + onTestEnd(test: TestCase, result: TestResult) { + this._durations.set(test.id, result.duration); + } + onEnd() { + if (this._config.shard) + return; + fs.writeFileSync(process.env.TIMINGS_FILE, JSON.stringify(Object.fromEntries(this._durations))); + } + }.toString()} + module.exports = Scheduler; + `, + 'playwright.config.ts': ` + module.exports = { + fullyParallel: true, + reporter: [['blob'], ['./scheduler.ts']], + }; + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('a', async () => { await new Promise(r => setTimeout(r, 3000)); }); + test('b', async () => { await new Promise(r => setTimeout(r, 1500)); }); + test('c', async () => { await new Promise(r => setTimeout(r, 100)); }); + `, + }; + + // Round 1: no timing file, falls back to built-in contiguous sharding. + const r1s1 = await runInlineTest(files, { shard: '1/2' }, { TIMINGS_FILE: timingsFile }); + const r1s2 = await runInlineTest(files, { shard: '2/2' }, { TIMINGS_FILE: timingsFile, PWTEST_BLOB_DO_NOT_REMOVE: '1' }); + expect(r1s1.exitCode).toBe(0); + expect(r1s2.exitCode).toBe(0); + expect(r1s1.outputLines).toEqual(['shard 1/2: a,b']); + expect(r1s2.outputLines).toEqual(['shard 2/2: c']); + + // Merge: the reporter sees every test's duration and writes the timing file. + const merge = await mergeReports('blob-report', { TIMINGS_FILE: timingsFile }, { additionalArgs: ['--reporter', 'scheduler.ts'] }); + expect(merge.exitCode).toBe(0); + expect(Object.keys(JSON.parse(fs.readFileSync(timingsFile, 'utf8')))).toHaveLength(3); + + // Round 2: with the timing file, LPT scheduling balances the shards. + const r2s1 = await runInlineTest(files, { shard: '1/2' }, { TIMINGS_FILE: timingsFile }); + const r2s2 = await runInlineTest(files, { shard: '2/2' }, { TIMINGS_FILE: timingsFile }); + expect(r2s1.exitCode).toBe(0); + expect(r2s2.exitCode).toBe(0); + expect(r2s1.outputLines).toEqual(['shard 1/2: a']); + expect(r2s2.outputLines).toEqual(['shard 2/2: b,c']); +}); diff --git a/utils/generate_types/overrides-testReporter.d.ts b/utils/generate_types/overrides-testReporter.d.ts index b67b850603af1..a4376aabca5b0 100644 --- a/utils/generate_types/overrides-testReporter.d.ts +++ b/utils/generate_types/overrides-testReporter.d.ts @@ -42,6 +42,7 @@ export interface FullResult { } export interface Reporter { + preprocessSuite?(config: FullConfig, suite: Suite): Promise<{ implementsSharding?: boolean } | undefined | void> | { implementsSharding?: boolean } | void; onEnd?(result: FullResult): Promise<{ status?: FullResult['status'] } | undefined | void> | void; }