feat(test-runner): support expect.soft (#11800)

Soft expects will still fail the test, but will not abort it's execution. As a consequence of this:
-  `TestResult` now might have multiple errors, which is reflected with a new `testResult.erros: TestError[]` field.
- `TestInfo` now might have multiple errors as well, which is reflected with a new `testInfo.errors: TestError[]` field.

Fixes #7819
This commit is contained in:
Andrey Lushnikov 2022-02-02 19:33:51 -07:00 committed by GitHub
parent f587a43932
commit ba0c7e679b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 252 additions and 90 deletions

View file

@ -128,12 +128,15 @@ The number of milliseconds the test took to finish. Always zero before the test
## property: TestInfo.error
- type: <[Object]>
- `message` <[void]|[string]> Error message. Set when `Error` (or its subclass) has been thrown.
- `stack` <[void]|[string]> Error stack. Set when `Error` (or its subclass) has been thrown.
- `value` <[void]|[string]> The thrown value. Set when anything except the `Error` (or its subclass) has been thrown.
- type: <[void]|[TestError]>
An error thrown during test execution, if any.
First error thrown during test execution, if any. This is equal to the first
element in [`property: TestInfo.errors`].
## property: TestInfo.errors
- type: <[Array]<[TestError]>>
Errors thrown during test execution, if any.
## property: TestInfo.expectedStatus

View file

@ -19,10 +19,25 @@ expect(value).not.toEqual(0);
await expect(locator).not.toContainText("some text");
```
You can also specify a custom error message as a second argument to the `expect` function, for example:
By default, failed assertion will terminate test execution. Playwright also
supports *soft assertions*: failed soft assertions **do not** terminate test execution,
but mark the test as failed.
```js
// Make a few checks that will not stop the test when failed...
await expect.soft(page.locator('#status')).toHaveText('Success');
await expect.soft(page.locator('#eta')).toHaveText('1 day');
// ... and continue the test to check more things.
await page.locator('#next-page').click();
await expect.soft(page.locator('#title')).toHaveText('Make another order');
```
You can specify a custom error message as a second argument to the `expect` function, for example:
```js
expect(value, 'my custom error message').toBe(42);
expect.soft(value, 'my soft assertion').toBe(56);
```
<!-- TOC -->

View file

@ -20,7 +20,13 @@ Running time in milliseconds.
## property: TestResult.error
- type: <[void]|[TestError]>
An error thrown during the test execution, if any.
First error thrown during test execution, if any. This is equal to the first
element in [`property: TestResult.errors`].
## property: TestResult.errors
- type: <[Array]<[TestError]>>
Errors thrown during the test execution.
## property: TestResult.retry
- type: <[int]>

View file

@ -26,6 +26,7 @@ const result: TestResult = {
retry: 0,
startTime: new Date(0).toUTCString(),
duration: 100,
errors: [],
steps: [{
title: 'Outer step',
startTime: new Date(100).toUTCString(),

View file

@ -55,6 +55,7 @@
border-radius: 6px;
padding: 16px;
line-height: initial;
margin-bottom: 6px;
}
.test-result-counter {

View file

@ -51,8 +51,8 @@ export const TestResultView: React.FC<{
const diff = attachmentsMap.get('diff');
const hasImages = [actual?.contentType, expected?.contentType, diff?.contentType].some(v => v && /^image\//i.test(v));
return <div className='test-result'>
{result.error && <AutoChip header='Errors'>
<ErrorMessage key='test-result-error-message' error={result.error}></ErrorMessage>
{!!result.errors.length && <AutoChip header='Errors'>
{result.errors.map((error, index) => <ErrorMessage key={'test-result-error-message-' + index} error={error}></ErrorMessage>)}
</AutoChip>}
{!!result.steps.length && <AutoChip header='Test Steps'>
{result.steps.map((step, i) => <StepTreeItem key={`step-${i}`} step={step} depth={0}></StepTreeItem>)}

View file

@ -199,7 +199,8 @@ export class Dispatcher {
const { result } = data.resultByWorkerIndex.get(worker.workerIndex)!;
data.resultByWorkerIndex.delete(worker.workerIndex);
result.duration = params.duration;
result.error = params.error;
result.errors = params.errors;
result.error = result.errors[0];
result.attachments = params.attachments.map(a => ({
name: a.name,
path: a.path,
@ -292,7 +293,8 @@ export class Dispatcher {
if (runningHookId) {
const data = this._testById.get(runningHookId)!;
const { result } = data.resultByWorkerIndex.get(worker.workerIndex)!;
result.error = params.fatalError;
result.errors = [params.fatalError];
result.error = result.errors[0];
result.status = 'failed';
this._reporter.onTestEnd?.(data.test, result);
}
@ -312,7 +314,8 @@ export class Dispatcher {
if (test._type === 'test')
this._reporter.onTestBegin?.(test, result);
}
result.error = params.fatalError;
result.errors = [params.fatalError];
result.error = result.errors[0];
result.status = first ? 'failed' : 'skipped';
this._reportTestEnd(test, result);
failedTestIds.add(test._id);

View file

@ -92,15 +92,23 @@ export const printReceivedStringContainExpectedResult = (
// #endregion
function createExpect(actual: unknown, message: string|undefined, isSoft: boolean) {
if (message !== undefined && typeof message !== 'string')
throw new Error('expect(actual, optionalErrorMessage): optional error message must be a string.');
return new Proxy(expectLibrary(actual), new ExpectMetaInfoProxyHandler(message || '', isSoft));
}
export const expect: Expect = new Proxy(expectLibrary as any, {
apply: function(target: any, thisArg: any, argumentsList: [actual: unknown, message: string|undefined]) {
const message = argumentsList[1];
if (message !== undefined && typeof message !== 'string')
throw new Error('expect(actual, optionalErrorMessage): optional error message must be a string.');
return new Proxy(expectLibrary.call(thisArg, argumentsList[0]), new ExpectMetaInfoProxyHandler(message || ''));
const [actual, message] = argumentsList;
return createExpect(actual, message, false /* isSoft */);
}
});
expect.soft = (actual: unknown, message: string|undefined) => {
return createExpect(actual, message, true /* isSoft */);
};
expectLibrary.setState({ expand: false });
const customMatchers = {
toBeChecked,
@ -128,15 +136,18 @@ const customMatchers = {
type ExpectMetaInfo = {
message: string;
isSoft: boolean;
};
let expectCallMetaInfo: undefined|ExpectMetaInfo = undefined;
class ExpectMetaInfoProxyHandler {
private _message: string;
private _isSoft: boolean;
constructor(message: string) {
constructor(message: string, isSoft: boolean) {
this._message = message;
this._isSoft = isSoft;
}
get(target: any, prop: any, receiver: any): any {
@ -144,12 +155,26 @@ class ExpectMetaInfoProxyHandler {
if (typeof value !== 'function')
return new Proxy(value, this);
return (...args: any[]) => {
const testInfo = currentTestInfo();
if (!testInfo)
return value.call(target, ...args);
const handleError = (e: Error) => {
if (this._isSoft)
testInfo._failWithError(serializeError(e), false /* isHardError */);
else
throw e;
};
try {
expectCallMetaInfo = {
message: this._message,
isSoft: this._isSoft,
};
const result = value.call(target, ...args);
let result = value.call(target, ...args);
if ((result instanceof Promise))
result = result.catch(handleError);
return result;
} catch (e) {
handleError(e);
} finally {
expectCallMetaInfo = undefined;
}
@ -172,10 +197,11 @@ function wrap(matcherName: string, matcher: any) {
const stackLines = new Error().stack!.split('\n').slice(INTERNAL_STACK_LENGTH + 1);
const frame = stackLines[0] ? stackUtils.parseLine(stackLines[0]) : undefined;
const customMessage = expectCallMetaInfo?.message ?? '';
const isSoft = expectCallMetaInfo?.isSoft ?? false;
const step = testInfo._addStep({
location: frame && frame.file ? { file: path.resolve(process.cwd(), frame.file), line: frame.line || 0, column: frame.column || 0 } : undefined,
category: 'expect',
title: customMessage || `expect${this.isNot ? '.not' : ''}.${matcherName}`,
title: customMessage || `expect${isSoft ? '.soft' : ''}${this.isNot ? '.not' : ''}.${matcherName}`,
canHaveChildren: true,
forceNoParent: false
});

View file

@ -380,7 +380,8 @@ export const test = _baseTest.extend<TestFixtures, WorkerFixtures>({
const anyContext = leftoverContexts[0];
const pendingCalls = anyContext ? formatPendingCalls((anyContext as any)._connection.pendingProtocolCalls()) : '';
await Promise.all(leftoverContexts.filter(c => createdContexts.has(c)).map(c => c.close()));
testInfo.error = prependToTestError(testInfo.error, pendingCalls);
if (pendingCalls)
testInfo.error = prependToTestError(testInfo.error, pendingCalls);
}
}, { auto: true }],
@ -434,7 +435,8 @@ export const test = _baseTest.extend<TestFixtures, WorkerFixtures>({
}
}));
testInfo.error = prependToTestError(testInfo.error, prependToError);
if (prependToError)
testInfo.error = prependToTestError(testInfo.error, prependToError);
},
context: async ({ _contextFactory }, use) => {

View file

@ -39,7 +39,7 @@ export type TestEndPayload = {
testId: string;
duration: number;
status: TestStatus;
error?: TestError;
errors: TestError[];
expectedStatus: TestStatus;
annotations: { type: string, description?: string }[];
timeout: number;

View file

@ -23,7 +23,7 @@ import jpeg from 'jpeg-js';
import pixelmatch from 'pixelmatch';
import { diff_match_patch, DIFF_INSERT, DIFF_DELETE, DIFF_EQUAL } from '../third_party/diff_match_patch';
import { UpdateSnapshots } from '../types';
import { addSuffixToFilePath } from '../util';
import { addSuffixToFilePath, serializeError } from '../util';
import BlinkDiff from '../third_party/blink-diff';
import PNGImage from '../third_party/png-js';
import { TestInfoImpl } from '../testInfo';
@ -129,8 +129,8 @@ export function compare(
return { pass: true, message };
}
if (updateSnapshots === 'missing') {
testInfo._appendErrorMessage(message);
return { pass: true, message };
testInfo._failWithError(serializeError(new Error(message)), false /* isHardError */);
return { pass: true };
}
return { pass: false, message };
}

View file

@ -33,11 +33,6 @@ type Annotation = {
location?: Location;
};
type FailureDetails = {
tokens: string[];
location?: Location;
};
type ErrorDetails = {
message: string;
location?: Location;
@ -99,7 +94,7 @@ export class BaseReporter implements Reporter {
}
onError(error: TestError) {
console.log(formatError(this.config, error, colors.enabled).message);
console.log('\n' + formatError(this.config, error, colors.enabled).message);
}
async onEnd(result: FullResult) {
@ -232,14 +227,16 @@ export function formatFailure(config: FullConfig, test: TestCase, options: {inde
lines.push(colors.red(header));
for (const result of test.results) {
const resultLines: string[] = [];
const { tokens: resultTokens, location } = formatResultFailure(config, test, result, ' ', colors.enabled);
if (!resultTokens.length)
const errors = formatResultFailure(config, test, result, ' ', colors.enabled);
if (!errors.length)
continue;
const retryLines = [];
if (result.retry) {
resultLines.push('');
resultLines.push(colors.gray(pad(` Retry #${result.retry}`, '-')));
retryLines.push('');
retryLines.push(colors.gray(pad(` Retry #${result.retry}`, '-')));
}
resultLines.push(...resultTokens);
resultLines.push(...retryLines);
resultLines.push(...errors.map(error => '\n' + error.message));
if (includeAttachments) {
for (let i = 0; i < result.attachments.length; ++i) {
const attachment = result.attachments[i];
@ -277,11 +274,13 @@ export function formatFailure(config: FullConfig, test: TestCase, options: {inde
resultLines.push('');
resultLines.push(colors.gray(pad('--- Test output', '-')) + '\n\n' + outputText + '\n' + pad('', '-'));
}
annotations.push({
location,
title,
message: [header, ...resultLines].join('\n'),
});
for (const error of errors) {
annotations.push({
location: error.location,
title,
message: [header, ...retryLines, error.message].join('\n'),
});
}
lines.push(...resultLines);
}
lines.push('');
@ -291,25 +290,27 @@ export function formatFailure(config: FullConfig, test: TestCase, options: {inde
};
}
export function formatResultFailure(config: FullConfig, test: TestCase, result: TestResult, initialIndent: string, highlightCode: boolean): FailureDetails {
const resultTokens: string[] = [];
export function formatResultFailure(config: FullConfig, test: TestCase, result: TestResult, initialIndent: string, highlightCode: boolean): ErrorDetails[] {
const errorDetails: ErrorDetails[] = [];
if (result.status === 'timedOut') {
resultTokens.push('');
resultTokens.push(indent(colors.red(`Timeout of ${test.timeout}ms exceeded.`), initialIndent));
errorDetails.push({
message: indent(colors.red(`Timeout of ${test.timeout}ms exceeded.`), initialIndent),
});
} else if (result.status === 'passed' && test.expectedStatus === 'failed') {
errorDetails.push({
message: indent(colors.red(`Expected to fail, but passed.`), initialIndent),
});
}
if (result.status === 'passed' && test.expectedStatus === 'failed') {
resultTokens.push('');
resultTokens.push(indent(colors.red(`Expected to fail, but passed.`), initialIndent));
for (const error of result.errors) {
const formattedError = formatError(config, error, highlightCode, test.location.file);
errorDetails.push({
message: indent(formattedError.message, initialIndent),
location: formattedError.location,
});
}
let error: ErrorDetails | undefined = undefined;
if (result.error !== undefined) {
error = formatError(config, result.error, highlightCode, test.location.file);
resultTokens.push(indent(error.message, initialIndent));
}
return {
tokens: resultTokens,
location: error?.location,
};
return errorDetails;
}
function relativeFilePath(config: FullConfig, file: string): string {
@ -341,7 +342,7 @@ function formatTestHeader(config: FullConfig, test: TestCase, indent: string, in
export function formatError(config: FullConfig, error: TestError, highlightCode: boolean, file?: string): ErrorDetails {
const stack = error.stack;
const tokens = [''];
const tokens = [];
let location: Location | undefined;
if (stack) {
const parsed = prepareErrorStack(stack, file);

View file

@ -93,7 +93,7 @@ export type TestResult = {
startTime: string;
duration: number;
steps: TestStep[];
error?: string;
errors: string[];
attachments: TestAttachment[];
status: 'passed' | 'failed' | 'timedOut' | 'skipped';
};
@ -393,7 +393,7 @@ class HtmlBuilder {
startTime: result.startTime,
retry: result.retry,
steps: result.steps.map(s => this._createTestStep(s)),
error: result.error,
errors: result.errors,
status: result.status,
attachments: result.attachments.map(a => {
if (a.name === 'trace')

View file

@ -83,7 +83,7 @@ export type JsonTestResult = {
startTime: string;
duration: number;
status: TestStatus;
error?: JsonError;
errors: JsonError[];
attachments: JsonAttachment[];
steps: JsonTestStep[];
};
@ -224,7 +224,7 @@ class RawReporter {
startTime: result.startTime.toISOString(),
duration: result.duration,
status: result.status,
error: formatResultFailure(this.config, test, result, '', true).tokens.join('').trim(),
errors: formatResultFailure(this.config, test, result, '', true).map(error => error.message),
attachments: this._createAttachments(result),
steps: dedupeSteps(result.steps.map(step => this._serializeStep(test, step)))
};

View file

@ -191,7 +191,8 @@ export class TestCase extends Base implements reporterTypes.TestCase {
stderr: [],
attachments: [],
status: 'skipped',
steps: []
steps: [],
errors: [],
};
this.results.push(result);
return result;

View file

@ -34,6 +34,7 @@ export class TestInfoImpl implements TestInfo {
readonly _timeoutRunner: TimeoutRunner;
readonly _startTime: number;
readonly _startWallTime: number;
private _hasHardError: boolean = false;
// ------------ TestInfo fields ------------
readonly repeatEachIndex: number;
@ -59,7 +60,20 @@ export class TestInfoImpl implements TestInfo {
snapshotSuffix: string = '';
readonly outputDir: string;
readonly snapshotDir: string;
error: TestError | undefined = undefined;
errors: TestError[] = [];
get error(): TestError | undefined {
return this.errors.length > 0 ? this.errors[0] : undefined;
}
set error(e: TestError | undefined) {
if (e === undefined)
throw new Error('Cannot assign testInfo.error undefined value!');
if (!this.errors.length)
this.errors.push(e);
else
this.errors[0] = e;
}
constructor(
loader: Loader,
@ -168,7 +182,7 @@ export class TestInfoImpl implements TestInfo {
this.status = 'skipped';
} else {
const serialized = serializeError(error);
this._failWithError(serialized);
this._failWithError(serialized, true /* isHardError */);
return serialized;
}
}
@ -178,25 +192,18 @@ export class TestInfoImpl implements TestInfo {
return this._addStepImpl(data);
}
_failWithError(error: TestError) {
// Do not overwrite any previous error and error status.
_failWithError(error: TestError, isHardError: boolean) {
// Do not overwrite any previous hard errors.
// Some (but not all) scenarios include:
// - expect() that fails after uncaught exception.
// - fail after the timeout, e.g. due to fixture teardown.
if (isHardError && this._hasHardError)
return;
if (isHardError)
this._hasHardError = true;
if (this.status === 'passed')
this.status = 'failed';
if (this.error === undefined)
this.error = error;
}
_appendErrorMessage(message: string) {
// Do not overwrite any previous error status.
if (this.status === 'passed')
this.status = 'failed';
if (this.error === undefined)
this.error = { value: 'Error: ' + message };
else if (this.error.value)
this.error.value += '\nError: ' + message;
this.errors.push(error);
}
// ------------ TestInfo methods ------------

View file

@ -24,6 +24,7 @@ import { calculateSha1, isRegExp } from 'playwright-core/lib/utils/utils';
import { isInternalFileName } from 'playwright-core/lib/utils/stackTrace';
const PLAYWRIGHT_CORE_PATH = path.dirname(require.resolve('playwright-core'));
const EXPECT_PATH = path.dirname(require.resolve('expect'));
const PLAYWRIGHT_TEST_PATH = path.join(__dirname, '..');
function filterStackTrace(e: Error) {
@ -46,7 +47,7 @@ function filterStackTrace(e: Error) {
const functionName = callSite.getFunctionName() || undefined;
if (!fileName)
return true;
return !fileName.startsWith(PLAYWRIGHT_TEST_PATH) && !fileName.startsWith(PLAYWRIGHT_CORE_PATH) && !isInternalFileName(fileName, functionName);
return !fileName.startsWith(PLAYWRIGHT_TEST_PATH) && !fileName.startsWith(PLAYWRIGHT_CORE_PATH) && !fileName.startsWith(EXPECT_PATH) && !isInternalFileName(fileName, functionName);
}));
};
// eslint-disable-next-line
@ -202,9 +203,7 @@ export function getContainedPath(parentPath: string, subPath: string = ''): stri
export const debugTest = debug('pw:test');
export function prependToTestError(testError: TestError | undefined, message: string | undefined, location?: Location) {
if (!message)
return testError;
export function prependToTestError(testError: TestError | undefined, message: string, location?: Location): TestError {
if (!testError) {
if (!location)
return { value: message };

View file

@ -95,7 +95,7 @@ export class WorkerRunner extends EventEmitter {
// and continuing to run tests in the same worker is problematic. Therefore,
// we turn this into a fatal error and restart the worker anyway.
if (this._currentTest && this._currentTest._test._type === 'test' && this._currentTest.expectedStatus !== 'failed') {
this._currentTest._failWithError(serializeError(error));
this._currentTest._failWithError(serializeError(error), true /* isHardError */);
} else {
// No current test - fatal error.
if (!this._fatalError)
@ -395,7 +395,7 @@ function buildTestEndPayload(testInfo: TestInfoImpl): TestEndPayload {
testId: testInfo._test._id,
duration: testInfo.duration,
status: testInfo.status!,
error: testInfo.error,
errors: testInfo.errors,
expectedStatus: testInfo.expectedStatus,
annotations: testInfo.annotations,
timeout: testInfo.timeout,

View file

@ -1492,9 +1492,14 @@ export interface TestInfo {
*/
status?: TestStatus;
/**
* An error thrown during test execution, if any.
* First error thrown during test execution, if any. This is equal to the first element in
* [testInfo.errors](https://playwright.dev/docs/api/class-testinfo#test-info-errors).
*/
error?: TestError;
/**
* Errors thrown during test execution, if any.
*/
errors: TestError[];
/**
* Output written to `process.stdout` or `console.log` during the test execution.
*/

View file

@ -29,6 +29,7 @@ type MakeMatchers<T, ReturnValue = T> = PlaywrightTest.Matchers<ReturnValue> &
export declare type Expect = {
<T = unknown>(actual: T, message?: string): MakeMatchers<T>;
soft: <T = unknown>(actual: T, message?: string) => MakeMatchers<T>;
// Sourced from node_modules/expect/build/types.d.ts
assertions(arg0: number): void;

View file

@ -213,9 +213,14 @@ export interface TestResult {
*/
status: TestStatus;
/**
* An error thrown during the test execution, if any.
* First error thrown during test execution, if any. This is equal to the first element in
* [testResult.errors](https://playwright.dev/docs/api/class-testresult#test-result-errors).
*/
error?: TestError;
/**
* Errors thrown during the test execution.
*/
errors: TestError[];
/**
* The list of files or buffers attached during the test execution through
* [testInfo.attachments](https://playwright.dev/docs/api/class-testinfo#test-info-attachments).

View file

@ -0,0 +1,80 @@
/**
* Copyright Microsoft Corporation. All rights reserved.
*
* 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, stripAnsi } from './playwright-test-fixtures';
test('soft expects should compile', async ({ runTSC }) => {
const result = await runTSC({
'a.spec.ts': `
const { test } = pwt;
test('should work', () => {
test.expect.soft(1+1).toBe(3);
test.expect.soft(1+1, 'custom error message').toBe(3);
});
`
});
expect(result.exitCode).toBe(0);
});
test('soft expects should work', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.spec.ts': `
const { test } = pwt;
test('should work', () => {
test.expect.soft(1+1).toBe(3);
console.log('woof-woof');
});
`
});
expect(result.exitCode).toBe(1);
expect(stripAnsi(result.output)).toContain('woof-woof');
});
test('should report a mixture of soft and non-soft errors', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.spec.ts': `
const { test } = pwt;
test('should work', ({}) => {
test.expect.soft(1+1, 'one plus one').toBe(3);
test.expect.soft(2*2, 'two times two').toBe(5);
test.expect(3/3, 'three div three').toBe(7);
test.expect.soft(6-4, 'six minus four').toBe(3);
});
`
});
expect(result.exitCode).toBe(1);
expect(stripAnsi(result.output)).toContain('Error: one plus one');
expect(stripAnsi(result.output)).toContain('Error: two times two');
expect(stripAnsi(result.output)).toContain('Error: three div three');
expect(stripAnsi(result.output)).not.toContain('Error: six minus four');
});
test('testInfo should contain all soft expect errors', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.spec.ts': `
const { test } = pwt;
test('should work', ({}, testInfo) => {
test.expect.soft(1+1, 'one plus one').toBe(3);
test.expect.soft(2*2, 'two times two').toBe(5);
test.expect(testInfo.errors.length, 'must be exactly two errors').toBe(2);
});
`
});
expect(result.exitCode).toBe(1);
expect(stripAnsi(result.output)).toContain('Error: one plus one');
expect(stripAnsi(result.output)).toContain('Error: two times two');
expect(stripAnsi(result.output)).not.toContain('Error: must be exactly two errors');
});

View file

@ -172,14 +172,18 @@ test('should write missing expectations locally twice and continue', async ({ ru
expect(result.failed).toBe(1);
const snapshot1OutputPath = testInfo.outputPath('a.spec.js-snapshots/snapshot.txt');
expect(result.output).toContain(`${snapshot1OutputPath} is missing in snapshots, writing actual`);
expect(result.output).toContain(`Error: ${snapshot1OutputPath} is missing in snapshots, writing actual`);
expect(fs.readFileSync(snapshot1OutputPath, 'utf-8')).toBe('Hello world');
const snapshot2OutputPath = testInfo.outputPath('a.spec.js-snapshots/snapshot2.txt');
expect(result.output).toContain(`${snapshot2OutputPath} is missing in snapshots, writing actual`);
expect(result.output).toContain(`Error: ${snapshot2OutputPath} is missing in snapshots, writing actual`);
expect(fs.readFileSync(snapshot2OutputPath, 'utf-8')).toBe('Hello world2');
expect(result.output).toContain('Here we are!');
const stackLines = stripAnsi(result.output).split('\n').filter(line => line.includes(' at ')).filter(line => !line.includes(testInfo.outputPath()));
expect(result.output).toContain('a.spec.js:8');
expect(stackLines.length).toBe(0);
});
test('shouldn\'t write missing expectations locally for negated matcher', async ({ runInlineTest }, testInfo) => {

View file

@ -89,6 +89,6 @@ test('print GitHub annotations for global error', async ({ runInlineTest }) => {
`,
}, { reporter: 'github' });
const text = stripAnsi(result.output);
expect(text).toContain('::error ::%0AError: Oh my!%0A%0A');
expect(text).toContain('::error ::Error: Oh my!%0A%0A');
expect(result.exitCode).toBe(1);
});

View file

@ -216,6 +216,7 @@ export interface TestInfo {
duration: number;
status?: TestStatus;
error?: TestError;
errors: TestError[];
stdout: (string | Buffer)[];
stderr: (string | Buffer)[];
snapshotSuffix: string;

View file

@ -56,6 +56,7 @@ export interface TestResult {
duration: number;
status: TestStatus;
error?: TestError;
errors: TestError[];
attachments: { name: string, path?: string, body?: Buffer, contentType: string }[];
stdout: (string | Buffer)[];
stderr: (string | Buffer)[];