chore: do not rely on zones for predefined steps (#29485)

This fixes some edge cases where fixtures and steps inside them were
attached to the wrong parent (see the new test).
This will also allow to replace some `runAsStep` calls with a flat list
of tasks to do that do not rely on lexical scope.
This commit is contained in:
Dmitry Gozman 2024-02-15 12:49:08 -08:00 committed by GitHub
parent 08afb34c14
commit dc9cddde95
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 189 additions and 30 deletions

View file

@ -256,7 +256,6 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
apiName, apiName,
params, params,
wallTime, wallTime,
laxParent: true,
}); });
userData.userObject = step; userData.userObject = step;
}, },

View file

@ -267,7 +267,6 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
params: args[0] ? { expected: args[0] } : undefined, params: args[0] ? { expected: args[0] } : undefined,
wallTime, wallTime,
infectParentStepsWithError: this._info.isSoft, infectParentStepsWithError: this._info.isSoft,
laxParent: true,
isSoft: this._info.isSoft, isSoft: this._info.isSoft,
}; };

View file

@ -99,7 +99,7 @@ class Fixture {
title: `fixture: ${this.registration.name}`, title: `fixture: ${this.registration.name}`,
category: 'fixture', category: 'fixture',
location: isInternalFixture ? this.registration.location : undefined, location: isInternalFixture ? this.registration.location : undefined,
}, testInfo._afterHooksStep); });
mutableStepOnStack!.stepId = afterStep.stepId; mutableStepOnStack!.stepId = afterStep.stepId;
} }
}; };

View file

@ -33,12 +33,11 @@ export interface TestStepInternal {
complete(result: { error?: Error, attachments?: Attachment[] }): void; complete(result: { error?: Error, attachments?: Attachment[] }): void;
stepId: string; stepId: string;
title: string; title: string;
category: string; category: 'hook' | 'fixture' | 'test.step' | string;
wallTime: number; wallTime: number;
location?: Location; location?: Location;
boxedStack?: StackFrame[]; boxedStack?: StackFrame[];
steps: TestStepInternal[]; steps: TestStepInternal[];
laxParent?: boolean;
endWallTime?: number; endWallTime?: number;
apiName?: string; apiName?: string;
params?: Record<string, any>; params?: Record<string, any>;
@ -46,6 +45,7 @@ export interface TestStepInternal {
infectParentStepsWithError?: boolean; infectParentStepsWithError?: boolean;
box?: boolean; box?: boolean;
isSoft?: boolean; isSoft?: boolean;
forceNoParent?: boolean;
} }
export class TestInfoImpl implements TestInfo { export class TestInfoImpl implements TestInfo {
@ -65,8 +65,6 @@ export class TestInfoImpl implements TestInfo {
readonly _projectInternal: FullProjectInternal; readonly _projectInternal: FullProjectInternal;
readonly _configInternal: FullConfigInternal; readonly _configInternal: FullConfigInternal;
readonly _steps: TestStepInternal[] = []; readonly _steps: TestStepInternal[] = [];
_beforeHooksStep: TestStepInternal | undefined;
_afterHooksStep: TestStepInternal | undefined;
_onDidFinishTestFunction: (() => Promise<void>) | undefined; _onDidFinishTestFunction: (() => Promise<void>) | undefined;
_hasNonRetriableError = false; _hasNonRetriableError = false;
@ -250,24 +248,40 @@ export class TestInfoImpl implements TestInfo {
} }
} }
_addStep(data: Omit<TestStepInternal, 'complete' | 'stepId' | 'steps'>, parentStep?: TestStepInternal): TestStepInternal { private _findLastNonFinishedStep(filter: (step: TestStepInternal) => boolean) {
let result: TestStepInternal | undefined;
const visit = (step: TestStepInternal) => {
if (!step.endWallTime && filter(step))
result = step;
step.steps.forEach(visit);
};
this._steps.forEach(visit);
return result;
}
_addStep(data: Omit<TestStepInternal, 'complete' | 'stepId' | 'steps'>): TestStepInternal {
const stepId = `${data.category}@${++this._lastStepId}`; const stepId = `${data.category}@${++this._lastStepId}`;
const rawStack = captureRawStack(); const rawStack = captureRawStack();
if (!parentStep)
parentStep = zones.zoneData<TestStepInternal>('stepZone', rawStack!) || undefined;
// For out-of-stack calls, locate the enclosing step. let parentStep: TestStepInternal | undefined;
let isLaxParent = false; if (data.category === 'hook' || data.category === 'fixture') {
if (!parentStep && data.laxParent) { // Predefined steps form a fixed hierarchy - find the last non-finished one.
const visit = (step: TestStepInternal) => { parentStep = this._findLastNonFinishedStep(step => step.category === 'fixture' || step.category === 'hook');
// Do not nest chains of route.continue. } else {
const shouldNest = step.title !== data.title; parentStep = zones.zoneData<TestStepInternal>('stepZone', rawStack!) || undefined;
if (!step.endWallTime && shouldNest) if (!parentStep) {
parentStep = step; if (data.category === 'test.step') {
step.steps.forEach(visit); // Nest test.step without a good stack in the last non-finished predefined step like a hook.
}; parentStep = this._findLastNonFinishedStep(step => step.category === 'fixture' || step.category === 'hook');
this._steps.forEach(visit); } else {
isLaxParent = !!parentStep; // Do not nest chains of route.continue.
parentStep = this._findLastNonFinishedStep(step => step.title !== data.title);
}
}
}
if (data.forceNoParent) {
// This is used to reset step hierarchy after test timeout.
parentStep = undefined;
} }
const filteredStack = filteredStackTrace(rawStack); const filteredStack = filteredStackTrace(rawStack);
@ -281,7 +295,6 @@ export class TestInfoImpl implements TestInfo {
const step: TestStepInternal = { const step: TestStepInternal = {
stepId, stepId,
...data, ...data,
laxParent: isLaxParent,
steps: [], steps: [],
complete: result => { complete: result => {
if (step.endWallTime) if (step.endWallTime)
@ -414,7 +427,6 @@ export class TestInfoImpl implements TestInfo {
title: `attach "${name}"`, title: `attach "${name}"`,
category: 'attach', category: 'attach',
wallTime: Date.now(), wallTime: Date.now(),
laxParent: true,
}); });
this._attachmentsPush(attachment); this._attachmentsPush(attachment);
this._onAttach({ this._onAttach({

View file

@ -358,8 +358,6 @@ export class WorkerMain extends ProcessRunner {
let testFunctionParams: object | null = null; let testFunctionParams: object | null = null;
await testInfo._runAsStep({ category: 'hook', title: 'Before Hooks' }, async step => { await testInfo._runAsStep({ category: 'hook', title: 'Before Hooks' }, async step => {
testInfo._beforeHooksStep = step;
// Run "beforeAll" hooks, unless already run during previous tests. // Run "beforeAll" hooks, unless already run during previous tests.
for (const suite of suites) { for (const suite of suites) {
didFailBeforeAllForSuite = suite; // Assume failure, unless reset below. didFailBeforeAllForSuite = suite; // Assume failure, unless reset below.
@ -416,8 +414,7 @@ export class WorkerMain extends ProcessRunner {
// A timed-out test gets a full additional timeout to run after hooks. // A timed-out test gets a full additional timeout to run after hooks.
const afterHooksSlot = testInfo._didTimeout ? { timeout: this._project.project.timeout, elapsed: 0 } : undefined; const afterHooksSlot = testInfo._didTimeout ? { timeout: this._project.project.timeout, elapsed: 0 } : undefined;
await testInfo._runAsStepWithRunnable({ category: 'hook', title: 'After Hooks', runnableType: 'afterHooks', runnableSlot: afterHooksSlot }, async step => { await testInfo._runAsStepWithRunnable({ category: 'hook', title: 'After Hooks', runnableType: 'afterHooks', runnableSlot: afterHooksSlot, forceNoParent: true }, async step => {
testInfo._afterHooksStep = step;
let firstAfterHooksError: Error | undefined; let firstAfterHooksError: Error | undefined;
await testInfo._runWithTimeout(async () => { await testInfo._runWithTimeout(async () => {
// Note: do not wrap all teardown steps together, because failure in any of them // Note: do not wrap all teardown steps together, because failure in any of them

View file

@ -330,8 +330,8 @@ test('should not override trace file in afterAll', async ({ runInlineTest, serve
' fixture: request', ' fixture: request',
' apiRequest.newContext', ' apiRequest.newContext',
' apiRequestContext.get', ' apiRequestContext.get',
' fixture: request', ' fixture: request',
' apiRequestContext.dispose', ' apiRequestContext.dispose',
' fixture: browser', ' fixture: browser',
]); ]);
expect(trace1.errors).toEqual([`'oh no!'`]); expect(trace1.errors).toEqual([`'oh no!'`]);
@ -836,3 +836,155 @@ test('should not throw when merging traces multiple times', async ({ runInlineTe
expect(result.passed).toBe(1); expect(result.passed).toBe(1);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-foo', 'trace.zip'))).toBe(true); expect(fs.existsSync(testInfo.outputPath('test-results', 'a-foo', 'trace.zip'))).toBe(true);
}); });
test('should record nested steps, even after timeout', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'playwright.config.ts': `
module.exports = {
use: { trace: { mode: 'on' } },
timeout: 5000,
};
`,
'a.spec.ts': `
import { test as base, expect } from '@playwright/test';
const test = base.extend({
fooPage: async ({ page }, use) => {
expect(1, 'fooPage setup').toBe(1);
await new Promise(f => setTimeout(f, 1)); // To avoid same-wall-time sorting issues.
await page.setContent('hello');
await test.step('step in fooPage setup', async () => {
await page.setContent('bar');
});
await use(page);
expect(1, 'fooPage teardown').toBe(1);
await new Promise(f => setTimeout(f, 1)); // To avoid same-wall-time sorting issues.
await page.setContent('hi');
await test.step('step in fooPage teardown', async () => {
await page.setContent('bar');
});
},
barPage: async ({ browser }, use) => {
expect(1, 'barPage setup').toBe(1);
await new Promise(f => setTimeout(f, 1)); // To avoid same-wall-time sorting issues.
const page = await browser.newPage();
await test.step('step in barPage setup', async () => {
await page.setContent('bar');
});
await use(page);
expect(1, 'barPage teardown').toBe(1);
await new Promise(f => setTimeout(f, 1)); // To avoid same-wall-time sorting issues.
await test.step('step in barPage teardown', async () => {
await page.close();
});
},
});
test.beforeAll(async ({ barPage }) => {
expect(1, 'beforeAll start').toBe(1);
await new Promise(f => setTimeout(f, 1)); // To avoid same-wall-time sorting issues.
await barPage.setContent('hello');
await test.step('step in beforeAll', async () => {
await barPage.setContent('bar');
});
});
test.beforeEach(async ({ fooPage }) => {
expect(1, 'beforeEach start').toBe(1);
await new Promise(f => setTimeout(f, 1)); // To avoid same-wall-time sorting issues.
await fooPage.setContent('hello');
await test.step('step in beforeEach', async () => {
await fooPage.setContent('hi');
// Next line times out. We make sure that after hooks steps
// form the expected step tree even when some previous steps have not finished.
await new Promise(() => {});
});
});
test('example', async ({ fooPage }) => {
});
test.afterEach(async ({ fooPage }) => {
expect(1, 'afterEach start').toBe(1);
await new Promise(f => setTimeout(f, 1)); // To avoid same-wall-time sorting issues.
await fooPage.setContent('hello');
await test.step('step in afterEach', async () => {
await fooPage.setContent('bar');
});
});
test.afterAll(async ({ barPage }) => {
expect(1, 'afterAll start').toBe(1);
await new Promise(f => setTimeout(f, 1)); // To avoid same-wall-time sorting issues.
await barPage.setContent('hello');
await test.step('step in afterAll', async () => {
await barPage.setContent('bar');
});
});
`,
}, { workers: 1 });
expect(result.exitCode).toBe(1);
expect(result.failed).toBe(1);
const trace = await parseTrace(testInfo.outputPath('test-results', 'a-example', 'trace.zip'));
expect(trace.actionTree).toEqual([
'Before Hooks',
' beforeAll hook',
' fixture: browser',
' browserType.launch',
' fixture: barPage',
' barPage setup',
' browser.newPage',
' step in barPage setup',
' page.setContent',
' beforeAll start',
' page.setContent',
' step in beforeAll',
' page.setContent',
' fixture: barPage',
' barPage teardown',
' step in barPage teardown',
' page.close',
' beforeEach hook',
' fixture: context',
' browser.newContext',
' fixture: page',
' browserContext.newPage',
' fixture: fooPage',
' fooPage setup',
' page.setContent',
' step in fooPage setup',
' page.setContent',
' beforeEach start',
' page.setContent',
' step in beforeEach',
' page.setContent',
'After Hooks',
' afterEach hook',
' afterEach start',
' page.setContent',
' step in afterEach',
' page.setContent',
' fixture: fooPage',
' fooPage teardown',
' page.setContent',
' step in fooPage teardown',
' page.setContent',
' fixture: page',
' fixture: context',
' afterAll hook',
' fixture: barPage',
' barPage setup',
' browser.newPage',
' step in barPage setup',
' page.setContent',
' afterAll start',
' page.setContent',
' step in afterAll',
' page.setContent',
' fixture: barPage',
' barPage teardown',
' step in barPage teardown',
' page.close',
' fixture: browser',
]);
});