This commit is contained in:
Yury Semikhatsky 2024-10-17 18:42:04 -07:00
parent 78c5ff0817
commit 0669a71309
14 changed files with 151 additions and 82 deletions

View file

@ -6,16 +6,22 @@ Information about an error thrown during test execution.
## property: TestInfoError.actual
* since: v1.49
- type: ?<[any]>
- type: ?<[string]>
Actual value.
## property: TestInfoError.expected
* since: v1.49
- type: ?<[any]>
- type: ?<[string]>
Expected value.
## property: TestInfoError.locator
* since: v1.49
- type: ?<[string]>
Receiver's locator.
## property: TestInfoError.log
* since: v1.49
- type: ?<[Array]<[string]>>

View file

@ -6,16 +6,22 @@ Information about an error thrown during test execution.
## property: TestError.actual
* since: v1.49
- type: ?<[any]>
- type: ?<[string]>
Actual value.
## property: TestError.expected
* since: v1.49
- type: ?<[any]>
- type: ?<[string]>
Expected value.
## property: TestError.locator
* since: v1.49
- type: ?<[string]>
Receiver's locator.
## property: TestError.log
* since: v1.49
- type: ?<[Array]<[string]>>

View file

@ -19,22 +19,50 @@ import * as React from 'react';
import './testErrorView.css';
import type { ImageDiff } from '@web/shared/imageDiffView';
import { ImageDiffView } from '@web/shared/imageDiffView';
import { ErrorDetails } from './types';
export const TestErrorView: React.FC<{
error: string;
error: ErrorDetails;
testId?: string;
}> = ({ error, testId }) => {
const html = React.useMemo(() => ansiErrorToHtml(error), [error]);
const html = React.useMemo(() => {
const formattedError = [];
if (error.shortMessage)
formattedError.push('Error: ' + error.shortMessage);
if (error.locator)
formattedError.push(`Locator: ${error.locator}`);
if (error.expected)
formattedError.push(`Expected: ${error.expected}`);
if (error.actual)
formattedError.push(`Received: ${error.actual}`);
// if (error.diff)
if (error.log) {
formattedError.push('Call log:');
formattedError.push(...(error.log?.map(line => ' - ' + line) || []));
}
if (error.snippet)
formattedError.push('', error.snippet);
return ansiErrorToHtml(formattedError.join('\n'));
}, [error]);
return <div className='test-error-view test-error-text' data-testId={testId} dangerouslySetInnerHTML={{ __html: html || '' }}></div>;
};
export const TestScreenshotErrorView: React.FC<{
errorPrefix?: string,
error: ErrorDetails,
diff: ImageDiff,
errorSuffix?: string,
}> = ({ errorPrefix, diff, errorSuffix }) => {
const prefixHtml = React.useMemo(() => ansiErrorToHtml(errorPrefix), [errorPrefix]);
const suffixHtml = React.useMemo(() => ansiErrorToHtml(errorSuffix), [errorSuffix]);
}> = ({ error, diff }) => {
const prefixHtml = React.useMemo(() => ansiErrorToHtml(error.shortMessage), [error]);
const suffixHtml = React.useMemo(() => {
const errorSuffix = ['Call log:',
...(error.log?.map(line => ' - ' + line) || []),
'',
error.snippet,
'',
error.callStack,
].join('\n');
return ansiErrorToHtml(errorSuffix)
}, [error]);
return <div data-testid='test-screenshot-error-view' className='test-error-view'>
<div dangerouslySetInnerHTML={{ __html: prefixHtml || '' }} className='test-error-text' style={{ marginBottom: 20 }}></div>
<ImageDiffView key='image-diff' diff={diff} hideDetails={true}></ImageDiffView>
@ -73,3 +101,13 @@ const ansiColors = {
function escapeHTML(text: string): string {
return text.replace(/[&"<>]/g, c => ({ '&': '&amp;', '"': '&quot;', '<': '&lt;', '>': '&gt;' }[c]!));
}
export function formatCallLog(log: string[] | undefined): string {
if (!log || !log.some(l => !!l))
return '';
return `
Call log:
${'- ' + (log || []).join('\n - ')}
`;
}

View file

@ -98,7 +98,7 @@ export const TestResultView: React.FC<{
{!!errors.length && <AutoChip header='Errors'>
{errors.map((error, index) => {
if (error.type === 'screenshot')
return <TestScreenshotErrorView key={'test-result-error-message-' + index} errorPrefix={error.errorPrefix} diff={error.diff!} errorSuffix={error.errorSuffix}></TestScreenshotErrorView>;
return <TestScreenshotErrorView key={'test-result-error-message-' + index} error={error.error} diff={error.diff!}></TestScreenshotErrorView>;
return <TestErrorView key={'test-result-error-message-' + index} error={error.error!}></TestErrorView>;
})}
</AutoChip>}
@ -155,25 +155,18 @@ function classifyErrors(testErrors: ErrorDetails[], diffs: ImageDiff[]) {
if (error.shortMessage?.includes('Screenshot comparison failed:') && error.actual && error.expected) {
const matchingDiff = diffs.find(diff => {
const attachmentName = diff.actual?.attachment.name;
return attachmentName && error.actual.endsWith(attachmentName);
return attachmentName && error.actual?.endsWith(attachmentName);
});
const errorSuffix = ['Call log:',
...(error.log?.map(line => ' - ' + line) || []),
'',
error.snippet,
'',
error.callStack,
].join('\n');
if (matchingDiff) {
return {
type: 'screenshot',
diff: matchingDiff,
errorPrefix: error.shortMessage,
errorSuffix
error,
};
}
}
return { type: 'regular', error: error.message };
return {
type: 'regular',
error
};
});
}

View file

@ -87,9 +87,10 @@ export type ErrorDetails = {
message: string;
location?: Location;
shortMessage?: string;
locator?: string;
log?: string[];
expected?: any;
actual?: any;
expected?: string;
actual?: string;
snippet?: string;
callStack?: string;
};

View file

@ -135,10 +135,18 @@ const matchers: MatchersObject = {
);
};
const header = matcherHint(matcherName, undefined, undefined, options) + '\n';
const printedExpected = `${pass ? 'not ' : ''}${printExpected(expected)}`
const printedReceived = printReceived(received);
// Passing the actual and expected objects so that a custom reporter
// could access them, for example in order to display a custom visual diff,
// or create a different error message
return { actual: received, expected, message, name: matcherName, pass };
return { actual: received, expected, message, name: matcherName, pass,
header,
printedReceived,
printedExpected,
};
},
toBeCloseTo(received: number, expected: number, precision = 2) {

View file

@ -40,6 +40,12 @@ export type MatcherResult<E, A> = {
actual?: A;
log?: string[];
timeout?: number;
locator?: string;
header?: string;
printedReceived?: string;
printedExpected?: string;
};
export class ExpectError extends Error {

View file

@ -58,30 +58,35 @@ export async function toMatchText(
const timeout = options.timeout ?? this.timeout;
const { matches: pass, received, log, timedOut } = await query(!!this.isNot, timeout);
const stringSubstring = options.matchSubstring ? 'substring' : 'string';
const receivedString = received || '';
const messagePrefix = matcherHint(this, receiver, matcherName, 'locator', undefined, matcherOptions, timedOut ? timeout : undefined);
const notFound = received === kNoElementsFoundError;
const message = () => {
if (pass) {
if (typeof expected === 'string') {
if (notFound)
return messagePrefix + `Expected ${stringSubstring}: not ${this.utils.printExpected(expected)}\nReceived: ${received}` + callLogText(log);
const printedReceived = printReceivedStringContainExpectedSubstring(receivedString, receivedString.indexOf(expected), expected.length);
return messagePrefix + `Expected ${stringSubstring}: not ${this.utils.printExpected(expected)}\nReceived string: ${printedReceived}` + callLogText(log);
} else {
if (notFound)
return messagePrefix + `Expected pattern: not ${this.utils.printExpected(expected)}\nReceived: ${received}` + callLogText(log);
const printedReceived = printReceivedStringContainExpectedResult(receivedString, typeof expected.exec === 'function' ? expected.exec(receivedString) : null);
return messagePrefix + `Expected pattern: not ${this.utils.printExpected(expected)}\nReceived string: ${printedReceived}` + callLogText(log);
}
} else {
const labelExpected = `Expected ${typeof expected === 'string' ? stringSubstring : 'pattern'}`;
if (notFound)
return messagePrefix + `${labelExpected}: ${this.utils.printExpected(expected)}\nReceived: ${received}` + callLogText(log);
return messagePrefix + this.utils.printDiffOrStringify(expected, receivedString, labelExpected, 'Received string', false) + callLogText(log);
}
if (pass === !this.isNot) {
return {
name: matcherName,
message: () => '',
pass,
expected
};
}
const stringSubstring = options.matchSubstring ? 'substring' : 'string';
const headerWithLocator = matcherHint(this, receiver, matcherName, 'locator', undefined, matcherOptions, timedOut ? timeout : undefined);
// Pass timeout and locator as fields on the error?
const header = matcherHint(this, undefined, matcherName, 'locator', undefined, matcherOptions, timedOut ? timeout : undefined);
const notFound = received === kNoElementsFoundError;
let printedReceived;
// {not} {string/substring/pattern} {formatted}
let printedExpected = `${typeof expected === 'string' ? stringSubstring : 'pattern'} ${this.utils.printExpected(expected)}`;
if (pass) {
printedExpected = `not ${printedExpected}`;
const receivedString = received || '';
printedReceived = notFound
? received
: typeof expected === 'string'
? printReceivedStringContainExpectedSubstring(receivedString, receivedString.indexOf(expected), expected.length)
: printReceivedStringContainExpectedResult(receivedString, typeof expected.exec === 'function' ? expected.exec(receivedString) : null);
} else {
printedReceived = notFound ? received : `string ${this.utils.printReceived(received)}`;
}
const message = () => `${headerWithLocator}Expected: ${printedExpected}\nReceived: ${printedReceived}` + callLogText(log);
return {
name: matcherName,
@ -91,5 +96,10 @@ export async function toMatchText(
actual: received,
log,
timeout: timedOut ? timeout : undefined,
locator: receiver.toString(),
header,
printedReceived,
printedExpected,
};
}

View file

@ -38,9 +38,9 @@ type ErrorDetails = {
type TestResultErrorDetails = ErrorDetails & {
shortMessage?: string;
log?: string[];
expected?: any;
actual?: any;
locator?: string;
expected?: string;
actual?: string;
snippet?: string;
stack?: string;
};
@ -396,6 +396,7 @@ export function formatResultFailure(test: TestCase, result: TestResult, initialI
location: formattedError.location,
shortMessage: error.shortMessage,
log: error.log,
locator: error.locator,
expected: error.expected,
actual: error.actual,
snippet: error.snippet,

View file

@ -25,36 +25,22 @@ import type { TestInfoError } from './../types/test';
import type { Location } from './../types/testReporter';
import { calculateSha1, isRegExp, isString, sanitizeForFilePath, stringifyStackFrames } from 'playwright-core/lib/utils';
import type { RawStack } from 'playwright-core/lib/utils';
import type { MatcherResult } from './matchers/matcherHint';
const PLAYWRIGHT_TEST_PATH = path.join(__dirname, '..');
const PLAYWRIGHT_CORE_PATH = path.dirname(require.resolve('playwright-core/package.json'));
export function filterStackTrace(e: Error): Omit<TestInfoError, 'value'> {
export function filterStackTrace(e: Error): { message: string, stack: string } {
const name = e.name ? e.name + ': ' : '';
if (process.env.PWDEBUGIMPL)
return { message: name + e.message, stack: e.stack || '', ...filterExpectDetails(e) };
return { message: name + e.message, stack: e.stack || '' };
const stackLines = stringifyStackFrames(filteredStackTrace(e.stack?.split('\n') || []));
return {
...filterExpectDetails(e),
message: name + e.message,
stack: `${name}${e.message}${stackLines.map(line => '\n' + line).join('')}`
};
}
function filterExpectDetails(e: Error): Pick<TestInfoError, 'shortMessage'|'log'|'expected'|'actual'> {
const matcherResult = (e as any).matcherResult as MatcherResult<unknown, unknown>;
if (!matcherResult)
return {};
return {
shortMessage: matcherResult.shortMessage,
log: matcherResult.log,
expected: matcherResult.expected,
actual: matcherResult.actual,
};
}
export function filterStackFile(file: string) {
if (!process.env.PWDEBUGIMPL && file.startsWith(PLAYWRIGHT_TEST_PATH))
return false;

View file

@ -25,15 +25,16 @@ export function serializeWorkerError(error: Error | any): TestInfoError {
};
}
function serializeExpectDetails(e: Error): Pick<TestInfoError, 'shortMessage'|'log'|'expected'|'actual'> {
function serializeExpectDetails(e: Error): Pick<TestInfoError, 'shortMessage'|'log'|'expected'|'actual'|'locator'> {
const matcherResult = (e as any).matcherResult as MatcherResult<unknown, unknown>;
if (!matcherResult)
return {};
return {
shortMessage: matcherResult.shortMessage,
shortMessage: matcherResult.header,
log: matcherResult.log,
expected: matcherResult.expected,
actual: matcherResult.actual,
expected: matcherResult.printedExpected,
actual: matcherResult.printedReceived,
locator: matcherResult.locator,
};
}

View file

@ -9152,12 +9152,17 @@ export interface TestInfoError {
/**
* Actual value.
*/
actual?: any;
actual?: string;
/**
* Expected value.
*/
expected?: any;
expected?: string;
/**
* Receiver's locator.
*/
locator?: string;
/**
* Call log.

View file

@ -557,18 +557,23 @@ export interface TestError {
/**
* Actual value.
*/
actual?: any;
actual?: string;
/**
* Expected value.
*/
expected?: any;
expected?: string;
/**
* Error location in the source code.
*/
location?: Location;
/**
* Receiver's locator.
*/
locator?: string;
/**
* Call log.
*/

View file

@ -18,7 +18,10 @@
import { test as it, expect } from './pageTest';
it('should check the box @smoke', async ({ page }) => {
await page.setContent(`<input id='checkbox' type='checkbox'></input>`);
await page.setContent(`<div>foo</div>`);
// expect(['fobao', 'bar']).toBe(expect.arrayContaining([expect.stringContaining('oba'), expect.stringContaining('bar')]));
expect('fobaro').toBe('bar');
await expect(page.locator('div')).toHaveText('fobaro', { timeout: 500 });
await page.check('input');
expect(await page.evaluate(() => window['checkbox'].checked)).toBe(true);
});