Skip to content

Commit

Permalink
fix: do not retry missing snapshot errors (#29272)
Browse files Browse the repository at this point in the history
When `updateSnapshots === 'missing'` we generate new expectations on the
first attempt and don't retry the test afterwards instead of trying it
retries-1 times and only writing new expectation on the last attempt.
This logic infects all serial mode suites that contain the test with
missing expectations, so they also will not be retried.

Reference #29073
  • Loading branch information
yury-s authored Jan 31, 2024
1 parent ad0be80 commit b5082e1
Show file tree
Hide file tree
Showing 6 changed files with 75 additions and 8 deletions.
1 change: 1 addition & 0 deletions packages/playwright/src/common/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export type TestEndPayload = {
duration: number;
status: TestStatus;
errors: TestInfoError[];
hasNonRetriableError: boolean;
expectedStatus: TestStatus;
annotations: { type: string, description?: string }[];
timeout: number;
Expand Down
4 changes: 1 addition & 3 deletions packages/playwright/src/matchers/toMatchSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,6 @@ class SnapshotHelper<T extends ImageComparatorOptions> {
this.locator = locator;

this.updateSnapshots = testInfo.config.updateSnapshots;
if (this.updateSnapshots === 'missing' && testInfo.retry < testInfo.project.retries)
this.updateSnapshots = 'none';
this.mimeType = mime.getType(path.basename(this.snapshotPath)) ?? 'application/octet-string';
this.comparator = getComparator(this.mimeType);

Expand Down Expand Up @@ -206,7 +204,7 @@ class SnapshotHelper<T extends ImageComparatorOptions> {
return this.createMatcherResult(message, true);
}
if (this.updateSnapshots === 'missing') {
this.testInfo._failWithError(new Error(message), false /* isHardError */);
this.testInfo._failWithError(new Error(message), false /* isHardError */, false /* retriable */);
return this.createMatcherResult('', true);
}
return this.createMatcherResult(message, false);
Expand Down
15 changes: 14 additions & 1 deletion packages/playwright/src/runner/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ class JobDispatcher {

private _listeners: RegisteredListener[] = [];
private _failedTests = new Set<TestCase>();
private _failedWithNonRetriableError = new Set<TestCase|Suite>();
private _remainingByTestId = new Map<string, TestCase>();
private _dataByTestId = new Map<string, { test: TestCase, result: TestResult, steps: Map<string, TestStep> }>();
private _parallelIndex = 0;
Expand Down Expand Up @@ -293,10 +294,20 @@ class JobDispatcher {
const isFailure = result.status !== 'skipped' && result.status !== test.expectedStatus;
if (isFailure)
this._failedTests.add(test);
if (params.hasNonRetriableError)
this._addNonretriableTestAndSerialModeParents(test);
this._reportTestEnd(test, result);
this._currentlyRunning = undefined;
}

private _addNonretriableTestAndSerialModeParents(test: TestCase) {
this._failedWithNonRetriableError.add(test);
for (let parent: Suite | undefined = test.parent; parent; parent = parent.parent) {
if (parent._parallelMode === 'serial')
this._failedWithNonRetriableError.add(parent);
}
}

private _onStepBegin(params: StepBeginPayload) {
const data = this._dataByTestId.get(params.testId);
if (!data) {
Expand Down Expand Up @@ -435,14 +446,16 @@ class JobDispatcher {
const serialSuitesWithFailures = new Set<Suite>();

for (const failedTest of this._failedTests) {
if (this._failedWithNonRetriableError.has(failedTest))
continue;
retryCandidates.add(failedTest);

let outermostSerialSuite: Suite | undefined;
for (let parent: Suite | undefined = failedTest.parent; parent; parent = parent.parent) {
if (parent._parallelMode === 'serial')
outermostSerialSuite = parent;
}
if (outermostSerialSuite)
if (outermostSerialSuite && !this._failedWithNonRetriableError.has(outermostSerialSuite))
serialSuitesWithFailures.add(outermostSerialSuite);
}

Expand Down
10 changes: 7 additions & 3 deletions packages/playwright/src/worker/testInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ export class TestInfoImpl implements TestInfo {
_afterHooksStep: TestStepInternal | undefined;
_onDidFinishTestFunction: (() => Promise<void>) | undefined;

_hasNonRetriableError = false;

// ------------ TestInfo fields ------------
readonly testId: string;
readonly repeatEachIndex: number;
Expand Down Expand Up @@ -241,7 +243,7 @@ export class TestInfoImpl implements TestInfo {
if (this.status === 'passed')
this.status = 'skipped';
} else {
this._failWithError(error, true /* isHardError */);
this._failWithError(error, true /* isHardError */, true /* retriable */);
return error;
}
}
Expand Down Expand Up @@ -318,7 +320,7 @@ export class TestInfoImpl implements TestInfo {
this._tracing.appendAfterActionForStep(stepId, errorForTrace, result.attachments);

if (step.isSoft && result.error)
this._failWithError(result.error, false /* isHardError */);
this._failWithError(result.error, false /* isHardError */, true /* retriable */);
}
};
const parentStepList = parentStep ? parentStep.steps : this._steps;
Expand Down Expand Up @@ -346,7 +348,9 @@ export class TestInfoImpl implements TestInfo {
this.status = 'interrupted';
}

_failWithError(error: Error, isHardError: boolean) {
_failWithError(error: Error, isHardError: boolean, retriable: boolean) {
if (!retriable)
this._hasNonRetriableError = true;
// Do not overwrite any previous hard errors.
// Some (but not all) scenarios include:
// - expect() that fails after uncaught exception.
Expand Down
3 changes: 2 additions & 1 deletion packages/playwright/src/worker/workerMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export class WorkerMain extends ProcessRunner {
// and unhandled errors - both lead to the test failing. This is good for regular tests,
// so that you can, e.g. expect() from inside an event handler. The test fails,
// and we restart the worker.
this._currentTest._failWithError(error, true /* isHardError */);
this._currentTest._failWithError(error, true /* isHardError */, true /* retriable */);

// For tests marked with test.fail(), this might be a problem when unhandled error
// is not coming from the user test code (legit failure), but from fixtures or test runner.
Expand Down Expand Up @@ -624,6 +624,7 @@ function buildTestEndPayload(testInfo: TestInfoImpl): TestEndPayload {
duration: testInfo.duration,
status: testInfo.status!,
errors: testInfo.errors,
hasNonRetriableError: testInfo._hasNonRetriableError,
expectedStatus: testInfo.expectedStatus,
annotations: testInfo.annotations,
timeout: testInfo.timeout,
Expand Down
50 changes: 50 additions & 0 deletions tests/playwright-test/to-have-screenshot.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,56 @@ test('should disable animations by default', async ({ runInlineTest }, testInfo)
expect(result.exitCode).toBe(0);
});

test('should not retry missing expectation errors', async ({ runInlineTest }, testInfo) => {
const cssTransitionURL = pathToFileURL(path.join(__dirname, '../assets/css-transition.html'));
const result = await runInlineTest({
...playwrightConfig({
retries: 2,
}),
'a.spec.js': `
const { test, expect } = require('@playwright/test');
test('is a test', async ({ page }) => {
await page.goto('${cssTransitionURL}');
await expect(page).toHaveScreenshot('foo.png', { timeout: 1000 });
await expect(page).toHaveScreenshot('bar.png', { timeout: 1000 });
});
`
});
expect(result.output).not.toContain(`retry #`);
expect(result.output).toMatch(/A snapshot doesn't exist.*foo.*, writing actual./);
expect(result.output).toMatch(/A snapshot doesn't exist.*bar.*, writing actual./);
expect(result.exitCode).toBe(1);
});

test('should not retry serial mode suites with missing expectation errors', async ({ runInlineTest }, testInfo) => {
const cssTransitionURL = pathToFileURL(path.join(__dirname, '../assets/css-transition.html'));
const result = await runInlineTest({
...playwrightConfig({
retries: 2,
}),
'a.spec.js': `
const { test, expect } = require('@playwright/test');
test.describe.serial('outer', () => {
test('last', async ({ page }) => {
});
test.describe('nested', () => {
test('is a test', async ({ page }) => {
await page.goto('${cssTransitionURL}');
await expect(page).toHaveScreenshot({ timeout: 1000 });
await expect(page).toHaveScreenshot({ timeout: 1000 });
});
test('last', async ({ page }) => {
});
});
});
`
});
expect(result.output).not.toContain(`retry #`);
expect(result.output).toMatch(/A snapshot doesn't exist.*1.*, writing actual./);
expect(result.output).toMatch(/A snapshot doesn't exist.*2.*, writing actual./);
expect(result.exitCode).toBe(1);
});

test.describe('expect config animations option', () => {
test('disabled', async ({ runInlineTest }, testInfo) => {
const cssTransitionURL = pathToFileURL(path.join(__dirname, '../assets/css-transition.html'));
Expand Down

0 comments on commit b5082e1

Please sign in to comment.