feat: conditional step.skip() (#34578)

This commit is contained in:
Yury Semikhatsky 2025-01-31 15:45:57 -08:00 committed by GitHub
parent da12af24c2
commit a1451c75f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 244 additions and 32 deletions

View File

@ -1751,7 +1751,7 @@ Step name.
### param: Test.step.body
* since: v1.10
- `body` <[function]\(\):[Promise]<[any]>>
- `body` <[function]\([TestStepInfo]\):[Promise]<[any]>>
Step body.

View File

@ -0,0 +1,39 @@
# class: TestStepInfo
* since: v1.51
* langs: js
`TestStepInfo` contains information about currently running test step. It is passed as an argument to the step function. `TestStepInfo` provides utilities to control test step execution.
```js
import { test, expect } from '@playwright/test';
test('basic test', async ({ page, browserName }, TestStepInfo) => {
await test.step('check some behavior', async step => {
await step.skip(browserName === 'webkit', 'The feature is not available in WebKit');
// ... rest of the step code
await page.check('input');
});
});
```
## method: TestStepInfo.skip#1
* since: v1.51
Unconditionally skip the currently running step. Test step is immediately aborted. This is similar to [`method: Test.step.skip`].
## method: TestStepInfo.skip#2
* since: v1.51
Conditionally skips the currently running step with an optional description. This is similar to [`method: Test.step.skip`].
### param: TestStepInfo.skip#2.condition
* since: v1.51
- `condition` <[boolean]>
A skip condition. Test step is skipped when the condition is `true`.
### param: TestStepInfo.skip#2.description
* since: v1.51
- `description` ?<[string]>
Optional description that will be reflected in a test report.

View File

@ -50,6 +50,14 @@ Start time of this particular test step.
List of steps inside this step.
## property: TestStep.annotations
* since: v1.51
- type: <[Array]<[Object]>>
- `type` <[string]> Annotation type, for example `'skip'`.
- `description` ?<[string]> Optional description.
The list of annotations applicable to the current test step.
## property: TestStep.attachments
* since: v1.50
- type: <[Array]<[Object]>>

View File

@ -109,6 +109,7 @@ export type StepEndPayload = {
wallTime: number; // milliseconds since unix epoch
error?: TestInfoErrorImpl;
suggestedRebaseline?: string;
annotations: { type: string, description?: string }[];
};
export type TestEntry = {

View File

@ -19,7 +19,7 @@ import { currentlyLoadingFileSuite, currentTestInfo, setCurrentlyLoadingFileSuit
import { TestCase, Suite } from './test';
import { wrapFunctionWithLocation } from '../transform/transform';
import type { FixturesWithLocation } from './config';
import type { Fixtures, TestType, TestDetails } from '../../types/test';
import type { Fixtures, TestType, TestDetails, TestStepInfo } from '../../types/test';
import type { Location } from '../../types/testReporter';
import { getPackageManagerExecCommand, monotonicTime, raceAgainstDeadline, zones } from 'playwright-core/lib/utils';
import { errors } from 'playwright-core';
@ -258,22 +258,17 @@ export class TestTypeImpl {
suite._use.push({ fixtures, location });
}
async _step<T>(expectation: 'pass'|'skip', title: string, body: () => T | Promise<T>, options: {box?: boolean, location?: Location, timeout?: number } = {}): Promise<T> {
async _step<T>(expectation: 'pass'|'skip', title: string, body: (step: TestStepInfo) => T | Promise<T>, options: {box?: boolean, location?: Location, timeout?: number } = {}): Promise<T> {
const testInfo = currentTestInfo();
if (!testInfo)
throw new Error(`test.step() can only be called from a test`);
if (expectation === 'skip') {
const step = testInfo._addStep({ category: 'test.step.skip', title, location: options.location, box: options.box });
step.complete({});
return undefined as T;
}
const step = testInfo._addStep({ category: 'test.step', title, location: options.location, box: options.box });
return await zones.run('stepZone', step, async () => {
try {
let result: Awaited<ReturnType<typeof raceAgainstDeadline<T>>> | undefined = undefined;
result = await raceAgainstDeadline(async () => {
try {
return await body();
return await step.info._runStepBody(expectation === 'skip', body);
} catch (e) {
// If the step timed out, the test fixtures will tear down, which in turn
// will abort unfinished actions in the step body. Record such errors here.

View File

@ -109,6 +109,7 @@ export type JsonTestStepEnd = {
duration: number;
error?: reporterTypes.TestError;
attachments?: number[]; // index of JsonTestResultEnd.attachments
annotations?: Annotation[];
};
export type JsonFullResult = {
@ -546,6 +547,10 @@ class TeleTestStep implements reporterTypes.TestStep {
get attachments() {
return this._endPayload?.attachments?.map(index => this._result.attachments[index]) ?? [];
}
get annotations() {
return this._endPayload?.annotations ?? [];
}
}
export class TeleTestResult implements reporterTypes.TestResult {

View File

@ -517,9 +517,12 @@ class HtmlBuilder {
private _createTestStep(dedupedStep: DedupedStep, result: api.TestResult): TestStep {
const { step, duration, count } = dedupedStep;
const skipped = dedupedStep.step.category === 'test.step.skip';
const skipped = dedupedStep.step.annotations?.find(a => a.type === 'skip');
let title = step.title;
if (skipped)
title = `${title} (skipped${skipped.description ? ': ' + skipped.description : ''})`;
const testStep: TestStep = {
title: step.title,
title,
startTime: step.startTime.toISOString(),
duration,
steps: dedupeSteps(step.steps).map(s => this._createTestStep(s, result)),
@ -532,7 +535,7 @@ class HtmlBuilder {
location: this._relativeLocation(step.location),
error: step.error?.message,
count,
skipped
skipped: !!skipped,
};
if (step.location)
this._stepsInFile.set(step.location.file, testStep);

View File

@ -257,6 +257,7 @@ export class TeleReporterEmitter implements ReporterV2 {
duration: step.duration,
error: step.error,
attachments: step.attachments.map(a => result.attachments.indexOf(a)),
annotations: step.annotations.length ? step.annotations : undefined,
};
}

View File

@ -321,6 +321,7 @@ class JobDispatcher {
duration: -1,
steps: [],
attachments: [],
annotations: [],
location: params.location,
};
steps.set(params.stepId, step);
@ -345,6 +346,7 @@ class JobDispatcher {
step.error = params.error;
if (params.suggestedRebaseline)
addSuggestedRebaseline(step.location!, params.suggestedRebaseline);
step.annotations = params.annotations;
steps.delete(params.stepId);
this._reporter.onStepEnd?.(test, result, step);
}

View File

@ -17,7 +17,7 @@
import fs from 'fs';
import path from 'path';
import { captureRawStack, monotonicTime, zones, sanitizeForFilePath, stringifyStackFrames } from 'playwright-core/lib/utils';
import type { TestInfo, TestStatus, FullProject } from '../../types/test';
import type { TestInfo, TestStatus, FullProject, TestStepInfo } from '../../types/test';
import type { AttachmentPayload, StepBeginPayload, StepEndPayload, TestInfoErrorImpl, WorkerInitParams } from '../common/ipc';
import type { TestCase } from '../common/test';
import { TimeoutManager, TimeoutManagerError, kMaxDeadline } from './timeoutManager';
@ -31,6 +31,7 @@ import { testInfoError } from './util';
export interface TestStepInternal {
complete(result: { error?: Error | unknown, suggestedRebaseline?: string }): void;
info: TestStepInfoImpl
attachmentIndices: number[];
stepId: string;
title: string;
@ -244,7 +245,7 @@ export class TestInfoImpl implements TestInfo {
?? this._findLastStageStep(this._steps); // If no parent step on stack, assume the current stage as parent.
}
_addStep(data: Omit<TestStepInternal, 'complete' | 'stepId' | 'steps' | 'attachmentIndices'>, parentStep?: TestStepInternal): TestStepInternal {
_addStep(data: Omit<TestStepInternal, 'complete' | 'stepId' | 'steps' | 'attachmentIndices' | 'info'>, parentStep?: TestStepInternal): TestStepInternal {
const stepId = `${data.category}@${++this._lastStepId}`;
if (data.isStage) {
@ -269,6 +270,7 @@ export class TestInfoImpl implements TestInfo {
...data,
steps: [],
attachmentIndices,
info: new TestStepInfoImpl(),
complete: result => {
if (step.endWallTime)
return;
@ -302,11 +304,12 @@ export class TestInfoImpl implements TestInfo {
wallTime: step.endWallTime,
error: step.error,
suggestedRebaseline: result.suggestedRebaseline,
annotations: step.info.annotations,
};
this._onStepEnd(payload);
const errorForTrace = step.error ? { name: '', message: step.error.message || '', stack: step.error.stack } : undefined;
const attachments = attachmentIndices.map(i => this.attachments[i]);
this._tracing.appendAfterActionForStep(stepId, errorForTrace, attachments);
this._tracing.appendAfterActionForStep(stepId, errorForTrace, attachments, step.info.annotations);
}
};
const parentStepList = parentStep ? parentStep.steps : this._steps;
@ -504,6 +507,34 @@ export class TestInfoImpl implements TestInfo {
}
}
export class TestStepInfoImpl implements TestStepInfo {
annotations: Annotation[] = [];
async _runStepBody<T>(skip: boolean, body: (step: TestStepInfo) => T | Promise<T>) {
if (skip) {
this.annotations.push({ type: 'skip' });
return undefined as T;
}
try {
return await body(this);
} catch (e) {
if (e instanceof SkipError)
return undefined as T;
throw e;
}
}
skip(...args: unknown[]) {
// skip();
// skip(condition: boolean, description: string);
if (args.length > 0 && !args[0])
return;
const description = args[1] as (string|undefined);
this.annotations.push({ type: 'skip', description });
throw new SkipError(description);
}
}
export class SkipError extends Error {
}

View File

@ -252,19 +252,20 @@ export class TestTracing {
parentId,
startTime: monotonicTime(),
class: 'Test',
method: category,
method: 'step',
apiName,
params: Object.fromEntries(Object.entries(params || {}).map(([name, value]) => [name, generatePreview(value)])),
stack,
});
}
appendAfterActionForStep(callId: string, error?: SerializedError['error'], attachments: Attachment[] = []) {
appendAfterActionForStep(callId: string, error?: SerializedError['error'], attachments: Attachment[] = [], annotations?: trace.AfterActionTraceEventAnnotation[]) {
this._appendTraceEvent({
type: 'after',
callId,
endTime: monotonicTime(),
attachments: serializeAttachments(attachments),
annotations,
error,
});
}

View File

@ -5811,7 +5811,7 @@ export interface TestType<TestArgs extends {}, WorkerArgs extends {}> {
* @param body Step body.
* @param options
*/
<T>(title: string, body: () => T | Promise<T>, options?: { box?: boolean, location?: Location, timeout?: number }): Promise<T>;
<T>(title: string, body: (step: TestStepInfo) => T | Promise<T>, options?: { box?: boolean, location?: Location, timeout?: number }): Promise<T>;
/**
* Mark a test step as "skip" to temporarily disable its execution, useful for steps that are currently failing and
* planned for a near-term fix. Playwright will not run the step.
@ -5835,7 +5835,7 @@ export interface TestType<TestArgs extends {}, WorkerArgs extends {}> {
* @param body Step body.
* @param options
*/
skip(title: string, body: () => any | Promise<any>, options?: { box?: boolean, location?: Location, timeout?: number }): Promise<void>;
skip(title: string, body: (step: TestStepInfo) => any | Promise<any>, options?: { box?: boolean, location?: Location, timeout?: number }): Promise<void>;
}
/**
* `expect` function can be used to create test assertions. Read more about [test assertions](https://playwright.dev/docs/test-assertions).
@ -9553,6 +9553,39 @@ export interface TestInfoError {
value?: string;
}
/**
* `TestStepInfo` contains information about currently running test step. It is passed as an argument to the step
* function. `TestStepInfo` provides utilities to control test step execution.
*
* ```js
* import { test, expect } from '@playwright/test';
*
* test('basic test', async ({ page, browserName }, TestStepInfo) => {
* await test.step('check some behavior', async step => {
* await step.skip(browserName === 'webkit', 'The feature is not available in WebKit');
* // ... rest of the step code
* await page.check('input');
* });
* });
* ```
*
*/
export interface TestStepInfo {
/**
* Unconditionally skip the currently running step. Test step is immediately aborted. This is similar to
* [test.step.skip(title, body[, options])](https://playwright.dev/docs/api/class-test#test-step-skip).
*/
skip(): void;
/**
* Conditionally skips the currently running step with an optional description. This is similar to
* [test.step.skip(title, body[, options])](https://playwright.dev/docs/api/class-test#test-step-skip).
* @param condition A skip condition. Test step is skipped when the condition is `true`.
* @param description Optional description that will be reflected in a test report.
*/
skip(condition: boolean, description?: string): void;
}
/**
* `WorkerInfo` contains information about the worker that is running tests and is available to worker-scoped
* fixtures. `WorkerInfo` is a subset of [TestInfo](https://playwright.dev/docs/api/class-testinfo) that is available

View File

@ -691,6 +691,21 @@ export interface TestStep {
*/
titlePath(): Array<string>;
/**
* The list of annotations applicable to the current test step.
*/
annotations: Array<{
/**
* Annotation type, for example `'skip'`.
*/
type: string;
/**
* Optional description.
*/
description?: string;
}>;
/**
* The list of files or buffers attached in the step execution through
* [testInfo.attach(name[, options])](https://playwright.dev/docs/api/class-testinfo#test-info-attach).

View File

@ -126,6 +126,7 @@ export class TraceModernizer {
existing!.result = event.result;
existing!.error = event.error;
existing!.attachments = event.attachments;
existing!.annotations = event.annotations;
if (event.point)
existing!.point = event.point;
break;

View File

@ -120,7 +120,7 @@ export const renderAction = (
const parameterString = actionParameterDisplayString(action, sdkLanguage || 'javascript');
const isSkipped = action.class === 'Test' && action.method === 'test.step.skip';
const isSkipped = action.class === 'Test' && action.method === 'step' && action.annotations?.some(a => a.type === 'skip');
let time: string = '';
if (action.endTime)
time = msToString(action.endTime - action.startTime);

View File

@ -258,6 +258,8 @@ function mergeActionsAndUpdateTimingSameTrace(contexts: ContextEntry[]): ActionT
existing.error = action.error;
if (action.attachments)
existing.attachments = action.attachments;
if (action.annotations)
existing.annotations = action.annotations;
if (action.parentId)
existing.parentId = nonPrimaryIdToPrimaryId.get(action.parentId) ?? action.parentId;
// For the events that are present in the test runner context, always take

View File

@ -86,6 +86,11 @@ export type AfterActionTraceEventAttachment = {
base64?: string;
};
export type AfterActionTraceEventAnnotation = {
type: string,
description?: string
};
export type AfterActionTraceEvent = {
type: 'after',
callId: string;
@ -93,6 +98,7 @@ export type AfterActionTraceEvent = {
afterSnapshot?: string;
error?: SerializedError['error'];
attachments?: AfterActionTraceEventAttachment[];
annotations?: AfterActionTraceEventAnnotation[];
result?: any;
point?: Point;
};

View File

@ -780,10 +780,35 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await showReport();
await page.click('text=example');
await page.click('text=skipped step title');
await page.click('text=skipped step title (skipped)');
await expect(page.getByTestId('test-snippet')).toContainText(`await test.step.skip('skipped step title', async () => {`);
});
test('step title should inlclude skipped step description', async ({ runInlineTest, page, showReport }) => {
const result = await runInlineTest({
'playwright.config.js': `
export default { testDir: './tests' };
`,
'tests/a.test.ts': `
import { test, expect } from '@playwright/test';
test('example', async ({}) => {
await test.step('step title', async (step) => {
expect(1).toBe(1);
step.skip(true, 'conditional step.skip');
});
});
`,
}, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
await showReport();
await page.click('text=example');
await page.click('text=step title (skipped: conditional step.skip)');
await expect(page.getByTestId('test-snippet')).toContainText(`await test.step('step title', async (step) => {`);
});
test('should render annotations', async ({ runInlineTest, page, showReport }) => {
const result = await runInlineTest({
'playwright.config.js': `

View File

@ -75,7 +75,9 @@ export default class MyReporter implements Reporter {
let location = '';
if (step.location)
location = formatLocation(step.location);
console.log(formatPrefix(step.category) + indent + step.title + location);
const skip = step.annotations?.find(a => a.type === 'skip');
const skipped = skip?.description ? ' (skipped: ' + skip.description + ')' : skip ? ' (skipped)' : '';
console.log(formatPrefix(step.category) + indent + step.title + location + skipped);
if (step.error) {
const errorLocation = this.printErrorLocation ? formatLocation(step.error.location) : '';
console.log(formatPrefix(step.category) + indent + '↪ error: ' + this.trimError(step.error.message!) + errorLocation);
@ -362,18 +364,16 @@ hook |Worker Cleanup
`);
});
test('should not pass arguments and return value from step', async ({ runInlineTest }) => {
test('should not pass return value from step', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('steps with return values', async ({ page }) => {
const v1 = await test.step('my step', (...args) => {
expect(args.length).toBe(0);
const v1 = await test.step('my step', () => {
return 10;
});
console.log('v1 = ' + v1);
const v2 = await test.step('my step', async (...args) => {
expect(args.length).toBe(0);
const v2 = await test.step('my step', async () => {
return new Promise(f => setTimeout(() => f(v1 + 10), 100));
});
console.log('v2 = ' + v2);
@ -1549,9 +1549,9 @@ test('test.step.skip should work', async ({ runInlineTest }) => {
expect(result.report.stats.unexpected).toBe(0);
expect(stripAnsi(result.output)).toBe(`
hook |Before Hooks
test.step.skip|outer step 1 @ a.test.ts:4
test.step |outer step 1 @ a.test.ts:4 (skipped)
test.step |outer step 2 @ a.test.ts:11
test.step.skip| inner step 2.1 @ a.test.ts:12
test.step | inner step 2.1 @ a.test.ts:12 (skipped)
test.step | inner step 2.2 @ a.test.ts:13
expect | expect.toBe @ a.test.ts:14
hook |After Hooks
@ -1581,12 +1581,56 @@ test('skip test.step.skip body', async ({ runInlineTest }) => {
expect(stripAnsi(result.output)).toBe(`
hook |Before Hooks
test.step |outer step 2 @ a.test.ts:5
test.step.skip| inner step 2 @ a.test.ts:6
test.step | inner step 2 @ a.test.ts:6 (skipped)
expect |expect.toBe @ a.test.ts:10
hook |After Hooks
`);
});
test('step.skip should work at runtime', async ({ runInlineTest }) => {
const result = await runInlineTest({
'reporter.ts': stepIndentReporter,
'playwright.config.ts': `module.exports = { reporter: './reporter' };`,
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({ }) => {
await test.step('outer step 1', async () => {
await test.step('inner step 1.1', async (step) => {
step.skip();
});
await test.step('inner step 1.2', async (step) => {
step.skip(true, 'condition is true');
});
await test.step('inner step 1.3', async () => {});
});
await test.step('outer step 2', async () => {
await test.step.skip('inner step 2.1', async () => {});
await test.step('inner step 2.2', async () => {
expect(1).toBe(1);
});
});
});
`
}, { reporter: '' });
expect(result.exitCode).toBe(0);
expect(result.report.stats.expected).toBe(1);
expect(result.report.stats.unexpected).toBe(0);
expect(stripAnsi(result.output)).toBe(`
hook |Before Hooks
test.step |outer step 1 @ a.test.ts:4
test.step | inner step 1.1 @ a.test.ts:5 (skipped)
test.step | inner step 1.2 @ a.test.ts:8 (skipped: condition is true)
test.step | inner step 1.3 @ a.test.ts:11
test.step |outer step 2 @ a.test.ts:13
test.step | inner step 2.1 @ a.test.ts:14 (skipped)
test.step | inner step 2.2 @ a.test.ts:15
expect | expect.toBe @ a.test.ts:16
hook |After Hooks
`);
});
test('show api calls inside expects', async ({ runInlineTest }) => {
const result = await runInlineTest({
'reporter.ts': stepIndentReporter,

View File

@ -163,8 +163,8 @@ export interface TestType<TestArgs extends {}, WorkerArgs extends {}> {
afterAll(title: string, inner: (args: TestArgs & WorkerArgs, testInfo: TestInfo) => Promise<any> | any): void;
use(fixtures: Fixtures<{}, {}, TestArgs, WorkerArgs>): void;
step: {
<T>(title: string, body: () => T | Promise<T>, options?: { box?: boolean, location?: Location, timeout?: number }): Promise<T>;
skip(title: string, body: () => any | Promise<any>, options?: { box?: boolean, location?: Location, timeout?: number }): Promise<void>;
<T>(title: string, body: (step: TestStepInfo) => T | Promise<T>, options?: { box?: boolean, location?: Location, timeout?: number }): Promise<T>;
skip(title: string, body: (step: TestStepInfo) => any | Promise<any>, options?: { box?: boolean, location?: Location, timeout?: number }): Promise<void>;
}
expect: Expect<{}>;
extend<T extends {}, W extends {} = {}>(fixtures: Fixtures<T, W, TestArgs, WorkerArgs>): TestType<TestArgs & T, WorkerArgs & W>;