diff --git a/docs/src/test-reporter-api/class-reporter.md b/docs/src/test-reporter-api/class-reporter.md index 8cc8e48676d85..b1796b719a958 100644 --- a/docs/src/test-reporter-api/class-reporter.md +++ b/docs/src/test-reporter-api/class-reporter.md @@ -297,3 +297,30 @@ 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 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.plan`] returns, unless [`method: Reporter.implementsSharding`] returns `true`. + +## 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). diff --git a/docs/src/test-reporter-api/class-suite.md b/docs/src/test-reporter-api/class-suite.md index 1d458c842b3f0..0bec189b9f1ca 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.plan`], 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..006d41f0bbb86 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.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. 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/suiteUtils.ts b/packages/playwright/src/common/suiteUtils.ts index 6c7128cca6cf9..f40be3f98fbf9 100644 --- a/packages/playwright/src/common/suiteUtils.ts +++ b/packages/playwright/src/common/suiteUtils.ts @@ -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); @@ -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 } { diff --git a/packages/playwright/src/common/test.ts b/packages/playwright/src/common/test.ts index fa8d8c20032d0..a5fc5e401d827 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'; @@ -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) => { @@ -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 { @@ -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); @@ -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', @@ -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 }; +} 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..deb40c71315dc 100644 --- a/packages/playwright/src/reporters/internalReporter.ts +++ b/packages/playwright/src/reporters/internalReporter.ts @@ -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); @@ -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) { diff --git a/packages/playwright/src/reporters/multiplexer.ts b/packages/playwright/src/reporters/multiplexer.ts index 6596a2bd66f72..efb7a802d10d5 100644 --- a/packages/playwright/src/reporters/multiplexer.ts +++ b/packages/playwright/src/reporters/multiplexer.ts @@ -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)); @@ -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(callback: () => T | Promise) { diff --git a/packages/playwright/src/reporters/reporterV2.ts b/packages/playwright/src/reporters/reporterV2.ts index 23cffcb4bb916..77e96d867165a 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; + plan?(config: FullConfig, suite: Suite): void | Promise; onBegin?(suite: Suite): void; onTestBegin?(test: TestCase, result: TestResult): void; onStdOut?(chunk: string | Buffer, test?: TestCase, result?: TestResult): void; @@ -42,6 +43,7 @@ export interface ReporterV2 { onStepBegin?(test: TestCase, result: TestResult, step: TestStep): void; onStepEnd?(test: TestCase, result: TestResult, step: TestStep): void; printsToStdio?(): boolean; + implementsSharding?(): boolean; version(): 'v2'; } @@ -79,6 +81,10 @@ class ReporterV2Wrapper implements ReporterV2 { this._config = config; } + async plan(config: FullConfig, suite: Suite) { + await this._reporter.plan?.(config, suite); + } + onBegin(suite: Suite) { this._reporter.onBegin?.(this._config, suite); @@ -145,4 +151,8 @@ class ReporterV2Wrapper implements ReporterV2 { printsToStdio() { return this._reporter.printsToStdio ? this._reporter.printsToStdio() : true; } + + implementsSharding() { + return this._reporter.implementsSharding ? this._reporter.implementsSharding() : false; + } } 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..3ae087f5dc751 100644 --- a/packages/playwright/src/runner/loadUtils.ts +++ b/packages/playwright/src/runner/loadUtils.ts @@ -23,7 +23,7 @@ import { toPosixPath } from '@utils/fileUtils'; import { InProcessLoaderHost, OutOfProcessLoaderHost } from './loaderHost'; import { createTitleMatcher, errorWithFile, parseLocationArg } from '../util'; import { buildProjectsClosure, collectFilesForProject } from './projectUtils'; -import { createTestGroups, filterForShard } from './testGroups'; +import { createTestGroups, filterForShard } from './testGroups'; import { cc, config as commonConfig, FullConfigInternal, suiteUtils, test as testNs, transform } from '../common'; import type { RawSourceMap } from 'source-map'; @@ -129,9 +129,15 @@ export async function createRootSuite(testRun: TestRun, errors: TestError[], sho for (const [project, fileSuites] of testRun.projectSuites) { const projectSuite = createProjectSuite(project, fileSuites); projectSuites.set(project, projectSuite); - - const filteredProjectSuite = filterProjectSuite(projectSuite, testRun.preOnlyTestFilters); - filteredProjectSuites.set(project, filteredProjectSuite); + filteredProjectSuites.set(project, projectSuite); + if (testRun.preOnlyTestFilters.length) { + const filteredProjectSuite = projectSuite._deepClone(); + for (const test of filteredProjectSuite.allTests()) { + if (!testRun.preOnlyTestFilters.every(f => f(test))) + test.exclude(); + } + filteredProjectSuites.set(project, filteredProjectSuite); + } } } @@ -165,8 +171,9 @@ export async function createRootSuite(testRun: TestRun, errors: TestError[], sho } } + await testRun.reporter.plan(config.config, rootSuite); // Shard only the top-level projects. - if (config.config.shard) { + if (config.config.shard && !testRun.reporter.implementsSharding()) { // Create test groups for top-level projects. const testGroups: TestGroup[] = []; for (const projectSuite of rootSuite.suites) { @@ -184,12 +191,19 @@ export async function createRootSuite(testRun: TestRun, errors: TestError[], sho testsInThisShard.add(test); } - // Update project suites, removing empty ones. - suiteUtils.filterTestsRemoveEmptySuites(rootSuite, test => testsInThisShard.has(test)); + // Drop all tests that are not in this shard. + for (const t of rootSuite.allTests()) { + if (!testsInThisShard.has(t)) + t.exclude(); + } } - if (testRun.postShardTestFilters.length) - suiteUtils.filterTestsRemoveEmptySuites(rootSuite, test => testRun.postShardTestFilters.every(filter => filter(test))); + if (testRun.postShardTestFilters.length){ + for (const test of rootSuite.allTests()) { + if (!testRun.postShardTestFilters.every(f => f(test))) + test.exclude(); + } + } const topLevelProjects = []; // Now prepend dependency projects without filtration. @@ -218,25 +232,14 @@ function createProjectSuite(project: commonConfig.FullProjectInternal, fileSuite const grepMatcher = createTitleMatcher(project.project.grep); const grepInvertMatcher = project.project.grepInvert ? createTitleMatcher(project.project.grepInvert) : null; - suiteUtils.filterTestsRemoveEmptySuites(projectSuite, (test: testNs.TestCase) => { + for (const test of projectSuite.allTests()) { const grepTitle = test._grepTitleWithTags(); - if (grepInvertMatcher?.(grepTitle)) - return false; - return grepMatcher(grepTitle); - }); + if (grepInvertMatcher?.(grepTitle) || !grepMatcher(grepTitle)) + test.exclude(); + } return projectSuite; } -function filterProjectSuite(projectSuite: testNs.Suite, testFilters: TestCaseFilter[]): testNs.Suite { - // Fast path. - if (!testFilters.length) - return projectSuite; - - const result = projectSuite._deepClone(); - suiteUtils.filterTestsRemoveEmptySuites(result, test => testFilters.every(filter => filter(test))); - return result; -} - function buildProjectSuite(project: commonConfig.FullProjectInternal, projectSuite: testNs.Suite): testNs.Suite { const result = new testNs.Suite(project.project.name, 'project'); result._fullProject = project; diff --git a/packages/playwright/src/runner/reporters.ts b/packages/playwright/src/runner/reporters.ts index 00dd732b63991..7bb85f87547b5 100644 --- a/packages/playwright/src/runner/reporters.ts +++ b/packages/playwright/src/runner/reporters.ts @@ -74,7 +74,7 @@ export async function createReporters(config: FullConfigInternal, mode: 'list' | } } - const someReporterPrintsToStdio = reporters.some(r => r.printsToStdio ? r.printsToStdio() : true); + const someReporterPrintsToStdio = reporters.some(r => r.printsToStdio?.() ?? true); if (reporters.length && !someReporterPrintsToStdio) { // Add a line/dot/list-mode reporter for convenience. // Important to put it first, just in case some other reporter stalls onEnd. @@ -83,6 +83,11 @@ export async function createReporters(config: FullConfigInternal, mode: 'list' | else if (mode !== 'merge') reporters.unshift(!process.env.CI ? new LineReporter() : new DotReporter()); } + + const shardingReporters = reporters.filter(r => r.implementsSharding?.() ?? false); + 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 reporters; } diff --git a/packages/playwright/src/worker/workerMain.ts b/packages/playwright/src/worker/workerMain.ts index 22e8f46451639..c1c9f69f9b6cf 100644 --- a/packages/playwright/src/worker/workerMain.ts +++ b/packages/playwright/src/worker/workerMain.ts @@ -220,7 +220,13 @@ export class WorkerMain extends ProcessRunner { const suite = suiteUtils.bindFileSuiteToProject(this._project, fileSuite); if (this._params.repeatEachIndex) suiteUtils.applyRepeatEachIndex(this._project, suite, this._params.repeatEachIndex); - suiteUtils.filterTestsRemoveEmptySuites(suite, test => entries.has(test.id)); + for (const test of suite.allTests()) { + const entry = entries.get(test.id); + if (!entry) + test.exclude(); + else + test.annotations.push(...entry.planAnnotations); + } const tests = suite.allTests(); // Collect test IDs that were not found in the worker diff --git a/packages/playwright/types/testReporter.d.ts b/packages/playwright/types/testReporter.d.ts index e5bd52c42995e..2c4a30d7c1573 100644 --- a/packages/playwright/types/testReporter.d.ts +++ b/packages/playwright/types/testReporter.d.ts @@ -158,6 +158,14 @@ export interface Reporter { * - `'interrupted'` - Interrupted by the user. */ onEnd?(result: FullResult): Promise<{ status?: FullResult['status'] } | undefined | void> | void; + /** + * When `true`, Playwright skips its built-in shard filter for this run, leaving sharding to the reporter (typically + * implemented inside [reporter.plan(config, suite)](https://playwright.dev/docs/api/class-reporter#reporter-plan) by + * calling [testCase.exclude()](https://playwright.dev/docs/api/class-testcase#test-case-exclude) on out-of-shard + * tests). + */ + implementsSharding?(): boolean; + /** * Called once before running tests. All tests have been already discovered and put into a hierarchy of * [Suite](https://playwright.dev/docs/api/class-suite)s. @@ -227,6 +235,25 @@ export interface Reporter { */ onTestEnd?(test: TestCase, result: TestResult): void; + /** + * 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.plan(config, suite)](https://playwright.dev/docs/api/class-reporter#reporter-plan) returns, unless + * [reporter.implementsSharding()](https://playwright.dev/docs/api/class-reporter#reporter-implements-sharding) + * returns `true`. + */ + plan?(config: FullConfig, suite: Suite): Promise; + /** * 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`. @@ -368,11 +395,39 @@ export interface Suite { */ entries(): Array; + /** + * Must be called from inside + * [reporter.plan(config, suite)](https://playwright.dev/docs/api/class-reporter#reporter-plan), 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 +482,30 @@ export interface Suite { * projects' suites. */ export interface TestCase { + /** + * Must be called from inside + * [reporter.plan(config, suite)](https://playwright.dev/docs/api/class-reporter#reporter-plan), 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.plan(config, suite)](https://playwright.dev/docs/api/class-reporter#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 reason Optional explanation surfaced as the annotation description. + */ + fail(reason?: string): void; + + /** + * Must be called from inside + * [reporter.plan(config, suite)](https://playwright.dev/docs/api/class-reporter#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 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 +519,14 @@ export interface TestCase { */ outcome(): "skipped"|"expected"|"unexpected"|"flaky"; + /** + * Must be called from inside + * [reporter.plan(config, suite)](https://playwright.dev/docs/api/class-reporter#reporter-plan), 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..4dc5a8ef6b16d --- /dev/null +++ b/tests/playwright-test/reporter-plan.spec.ts @@ -0,0 +1,397 @@ +/** + * 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('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 plan(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 plan(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 plan(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('plan throwing aborts the run before onBegin', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'reporter.ts': ` + class Reporter { + async plan(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 plan(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 plan(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 { + implementsSharding() { return true; } + async plan(config, suite) { + let i = 0; + for (const t of suite.allTests()) { + if (i++ % 2 === 1) t.exclude(); + } + } + 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 { implementsSharding() { return true; } } + module.exports = A; + `, + 'reporter-b.ts': ` + class B { implementsSharding() { return 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.rawOutput).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 plan(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 plan(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 plan(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 plan(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 plan(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']); +});