Merge branch 'main' into test-filter

This commit is contained in:
Mathias Leppich 2024-10-11 16:27:20 +02:00
commit a457d169eb
21 changed files with 268 additions and 88 deletions

1
.gitignore vendored
View file

@ -35,3 +35,4 @@ test-results
.cache/
.eslintcache
playwright.env
firefox

View file

@ -587,6 +587,22 @@ export default defineConfig({
});
```
## property: TestConfig.tsconfig
* since: v1.49
- type: ?<[string]>
Path to a single `tsconfig` applicable to all imported files. By default, `tsconfig` for each imported file is looked up separately. Note that `tsconfig` property has no effect while the configuration file or any of its dependencies are loaded. Ignored when `--tsconfig` command line option is specified.
**Usage**
```js title="playwright.config.ts"
import { defineConfig } from '@playwright/test';
export default defineConfig({
tsconfig: './tsconfig.test.json',
});
```
## property: TestConfig.updateSnapshots
* since: v1.10
- type: ?<[UpdateSnapshots]<"all"|"none"|"missing">>

View file

@ -90,6 +90,16 @@ Alternatively, you can specify a single tsconfig file to use in the command line
npx playwright test --tsconfig=tsconfig.test.json
```
You can specify a single tsconfig file in the config file, that will be used for loading test files, reporters, etc. However, it will not be used while loading the playwright config itself or any files imported from it.
```js title="playwright.config.ts"
import { defineConfig } from '@playwright/test';
export default defineConfig({
tsconfig: './tsconfig.test.json',
});
```
## Manually compile tests with TypeScript
Sometimes, Playwright Test will not be able to transform your TypeScript code correctly, for example when you are using experimental or very recent features of TypeScript, usually configured in `tsconfig.json`.

View file

@ -14,9 +14,8 @@
limitations under the License.
*/
.test-error-message {
.test-error-view {
white-space: pre;
font-family: monospace;
overflow: auto;
flex: none;
padding: 0;
@ -26,3 +25,7 @@
line-height: initial;
margin-bottom: 6px;
}
.test-error-text {
font-family: monospace;
}

View file

@ -17,20 +17,38 @@
import ansi2html from 'ansi-to-html';
import * as React from 'react';
import './testErrorView.css';
import type { ImageDiff } from '@web/shared/imageDiffView';
import { ImageDiffView } from '@web/shared/imageDiffView';
export const TestErrorView: React.FC<{
error: string;
}> = ({ error }) => {
const html = React.useMemo(() => {
const html = React.useMemo(() => ansiErrorToHtml(error), [error]);
return <div className='test-error-view test-error-text' dangerouslySetInnerHTML={{ __html: html || '' }}></div>;
};
export const TestScreenshotErrorView: React.FC<{
errorPrefix?: string,
diff: ImageDiff,
errorSuffix?: string,
}> = ({ errorPrefix, diff, errorSuffix }) => {
const prefixHtml = React.useMemo(() => ansiErrorToHtml(errorPrefix), [errorPrefix]);
const suffixHtml = React.useMemo(() => ansiErrorToHtml(errorSuffix), [errorSuffix]);
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>
<div data-testid='error-suffix' dangerouslySetInnerHTML={{ __html: suffixHtml || '' }} className='test-error-text'></div>
</div>;
};
function ansiErrorToHtml(text?: string): string {
const config: any = {
bg: 'var(--color-canvas-subtle)',
fg: 'var(--color-fg-default)',
};
config.colors = ansiColors;
return new ansi2html(config).toHtml(escapeHTML(error));
}, [error]);
return <div className='test-error-message' dangerouslySetInnerHTML={{ __html: html || '' }}></div>;
};
return new ansi2html(config).toHtml(escapeHTML(text || ''));
}
const ansiColors = {
0: '#000',

View file

@ -24,7 +24,7 @@ import { AttachmentLink, generateTraceUrl } from './links';
import { statusIcon } from './statusIcon';
import type { ImageDiff } from '@web/shared/imageDiffView';
import { ImageDiffView } from '@web/shared/imageDiffView';
import { TestErrorView } from './testErrorView';
import { TestErrorView, TestScreenshotErrorView } from './testErrorView';
import './testResultView.css';
function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiff[] {
@ -67,7 +67,7 @@ export const TestResultView: React.FC<{
anchor: 'video' | 'diff' | '',
}> = ({ result, anchor }) => {
const { screenshots, videos, traces, otherAttachments, diffs, htmls } = React.useMemo(() => {
const { screenshots, videos, traces, otherAttachments, diffs, errors, htmls } = React.useMemo(() => {
const attachments = result?.attachments || [];
const screenshots = new Set(attachments.filter(a => a.contentType.startsWith('image/')));
const videos = attachments.filter(a => a.name === 'video');
@ -76,7 +76,8 @@ export const TestResultView: React.FC<{
const otherAttachments = new Set<TestAttachment>(attachments);
[...screenshots, ...videos, ...traces, ...htmls].forEach(a => otherAttachments.delete(a));
const diffs = groupImageDiffs(screenshots);
return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, htmls };
const errors = classifyErrors(result.errors, diffs);
return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, htmls };
}, [result]);
const videoRef = React.useRef<HTMLDivElement>(null);
@ -94,15 +95,19 @@ export const TestResultView: React.FC<{
}, [scrolled, anchor, setScrolled, videoRef]);
return <div className='test-result'>
{!!result.errors.length && <AutoChip header='Errors'>
{result.errors.map((error, index) => <TestErrorView key={'test-result-error-message-' + index} error={error}></TestErrorView>)}
{!!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 <TestErrorView key={'test-result-error-message-' + index} error={error.error!}></TestErrorView>;
})}
</AutoChip>}
{!!result.steps.length && <AutoChip header='Test Steps'>
{result.steps.map((step, i) => <StepTreeItem key={`step-${i}`} step={step} depth={0}></StepTreeItem>)}
</AutoChip>}
{diffs.map((diff, index) =>
<AutoChip key={`diff-${index}`} header={`Image mismatch: ${diff.name}`} targetRef={imageDiffRef}>
<AutoChip key={`diff-${index}`} dataTestId='test-results-image-diff' header={`Image mismatch: ${diff.name}`} targetRef={imageDiffRef}>
<ImageDiffView key='image-diff' diff={diff}></ImageDiffView>
</AutoChip>
)}
@ -145,6 +150,29 @@ export const TestResultView: React.FC<{
</div>;
};
function classifyErrors(testErrors: string[], diffs: ImageDiff[]) {
return testErrors.map(error => {
if (error.includes('Screenshot comparison failed:')) {
const matchingDiff = diffs.find(diff => {
const attachmentName = diff.actual?.attachment.name;
return attachmentName && error.includes(attachmentName);
});
if (matchingDiff) {
const lines = error.split('\n');
const index = lines.findIndex(line => /Expected:|Previous:|Received:/.test(line));
const errorPrefix = index !== -1 ? lines.slice(0, index).join('\n') : lines[0];
const diffIndex = lines.findIndex(line => / +Diff:/.test(line));
const errorSuffix = diffIndex !== -1 ? lines.slice(diffIndex + 2).join('\n') : lines.slice(1).join('\n');
return { type: 'screenshot', diff: matchingDiff, errorPrefix, errorSuffix };
}
}
return { type: 'regular', error };
});
}
const StepTreeItem: React.FC<{
step: TestStep;
depth: number,

View file

@ -9,9 +9,9 @@
},
{
"name": "chromium-tip-of-tree",
"revision": "1267",
"revision": "1268",
"installByDefault": false,
"browserVersion": "131.0.6764.0"
"browserVersion": "131.0.6768.0"
},
{
"name": "firefox",
@ -27,7 +27,7 @@
},
{
"name": "webkit",
"revision": "2084",
"revision": "2090",
"installByDefault": true,
"revisionOverrides": {
"mac10.14": "1446",

View file

@ -43,7 +43,7 @@ import type { TimeoutOptions } from '../common/types';
import { isInvalidSelectorError } from '../utils/isomorphic/selectorParser';
import { parseEvaluationResultValue, source } from './isomorphic/utilityScriptSerializers';
import type { SerializedValue } from './isomorphic/utilityScriptSerializers';
import { TargetClosedError } from './errors';
import { TargetClosedError, TimeoutError } from './errors';
import { asLocator } from '../utils';
import { helper } from './helper';
@ -662,7 +662,7 @@ export class Page extends SdkObject {
return {};
}
if (areEqualScreenshots(actual, options.expected, previous)) {
if (areEqualScreenshots(actual, options.expected, undefined)) {
progress.log(`screenshot matched expectation`);
return {};
}
@ -672,10 +672,13 @@ export class Page extends SdkObject {
// A: We want user to receive a friendly diff between actual and expected/previous.
if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e))
throw e;
let errorMessage = e.message;
if (e instanceof TimeoutError && intermediateResult?.previous)
errorMessage = `Failed to take two consecutive stable screenshots. ${e.message}`;
return {
log: e.message ? [...metadata.log, e.message] : metadata.log,
...intermediateResult,
errorMessage: e.message,
errorMessage,
};
});
}

View file

@ -6510,7 +6510,7 @@ the top of the viewport and Y increases as it proceeds towards the bottom of the
/**
* List of settings able to be overridden by WebInspector.
*/
export type Setting = "PrivateClickMeasurementDebugModeEnabled"|"AuthorAndUserStylesEnabled"|"ICECandidateFilteringEnabled"|"ITPDebugModeEnabled"|"ImagesEnabled"|"MediaCaptureRequiresSecureConnection"|"MockCaptureDevicesEnabled"|"NeedsSiteSpecificQuirks"|"ScriptEnabled"|"ShowDebugBorders"|"ShowRepaintCounter"|"WebSecurityEnabled"|"DeviceOrientationEventEnabled"|"SpeechRecognitionEnabled"|"PointerLockEnabled"|"NotificationsEnabled"|"FullScreenEnabled"|"InputTypeMonthEnabled"|"InputTypeWeekEnabled";
export type Setting = "PrivateClickMeasurementDebugModeEnabled"|"AuthorAndUserStylesEnabled"|"ICECandidateFilteringEnabled"|"ITPDebugModeEnabled"|"ImagesEnabled"|"MediaCaptureRequiresSecureConnection"|"MockCaptureDevicesEnabled"|"NeedsSiteSpecificQuirks"|"ScriptEnabled"|"ShowDebugBorders"|"ShowRepaintCounter"|"WebSecurityEnabled"|"DeviceOrientationEventEnabled"|"SpeechRecognitionEnabled"|"PointerLockEnabled"|"NotificationsEnabled"|"FullScreenEnabled"|"InputTypeMonthEnabled"|"InputTypeWeekEnabled"|"FixedBackgroundsPaintRelativeToDocument";
/**
* A user preference that can be overriden by Web Inspector, like an accessibility preference.
*/

View file

@ -231,6 +231,7 @@ export class WKPage implements PageDelegate {
promises.push(session.send('Page.overrideSetting', { setting: 'PointerLockEnabled', value: !contextOptions.isMobile }));
promises.push(session.send('Page.overrideSetting', { setting: 'InputTypeMonthEnabled', value: contextOptions.isMobile }));
promises.push(session.send('Page.overrideSetting', { setting: 'InputTypeWeekEnabled', value: contextOptions.isMobile }));
promises.push(session.send('Page.overrideSetting', { setting: 'FixedBackgroundsPaintRelativeToDocument', value: contextOptions.isMobile }));
await Promise.all(promises);
}

View file

@ -45,6 +45,7 @@ export class FullConfigInternal {
readonly webServers: NonNullable<FullConfig['webServer']>[];
readonly plugins: TestRunnerPluginRegistration[];
readonly projects: FullProjectInternal[] = [];
readonly singleTSConfigPath?: string;
cliArgs: string[] = [];
cliGrep: string | undefined;
cliGrepInvert: string | undefined;
@ -69,6 +70,7 @@ export class FullConfigInternal {
this.configCLIOverrides = configCLIOverrides;
const privateConfiguration = (userConfig as any)['@playwright/test'];
this.plugins = (privateConfiguration?.plugins || []).map((p: any) => ({ factory: p }));
this.singleTSConfigPath = pathResolve(configDir, userConfig.tsconfig);
this.config = {
configFile: resolvedConfigFile,

View file

@ -118,6 +118,8 @@ export async function loadConfig(location: ConfigLocation, overrides?: ConfigCLI
const babelPlugins = (userConfig as any)['@playwright/test']?.babelPlugins || [];
const external = userConfig.build?.external || [];
setTransformConfig({ babelPlugins, external });
if (!overrides?.tsconfig)
setSingleTSConfig(fullConfig?.singleTSConfigPath);
// 4. Send transform options to ESM loader.
await configureESMLoaderTransformConfig();

View file

@ -77,5 +77,6 @@ export async function configureESMLoader() {
export async function configureESMLoaderTransformConfig() {
if (!loaderChannel)
return;
await loaderChannel.send('setSingleTSConfig', { tsconfig: singleTSConfig() });
await loaderChannel.send('setTransformConfig', { config: transformConfig() });
}

View file

@ -423,7 +423,7 @@ export async function toHaveScreenshot(
// - regular matcher (i.e. not a `.not`)
// - perhaps an 'all' flag to update non-matching screenshots
expectScreenshotOptions.expected = await fs.promises.readFile(helper.expectedPath);
const { actual, diff, errorMessage, log } = await page._expectScreenshot(expectScreenshotOptions);
const { actual, previous, diff, errorMessage, log } = await page._expectScreenshot(expectScreenshotOptions);
if (!errorMessage)
return helper.handleMatching();
@ -436,7 +436,7 @@ export async function toHaveScreenshot(
return helper.createMatcherResult(helper.expectedPath + ' running with --update-snapshots, writing actual.', true);
}
return helper.handleDifferent(actual, expectScreenshotOptions.expected, undefined, diff, errorMessage, log);
return helper.handleDifferent(actual, expectScreenshotOptions.expected, previous, diff, errorMessage, log);
}
function writeFileSync(aPath: string, content: Buffer | string) {

View file

@ -302,10 +302,11 @@ export class TestServerDispatcher implements TestServerInterface {
preserveOutputDir: true,
reporter: params.reporters ? params.reporters.map(r => [r]) : undefined,
use: {
...(this._configCLIOverrides.use || {}),
trace: params.trace === 'on' ? { mode: 'on', sources: false, _live: true } : (params.trace === 'off' ? 'off' : undefined),
video: params.video === 'on' ? 'on' : (params.video === 'off' ? 'off' : undefined),
headless: params.headed ? false : undefined,
...this._configCLIOverrides.use,
...(params.trace === 'on' ? { trace: { mode: 'on', sources: false, _live: true } } : {}),
...(params.trace === 'off' ? { trace: 'off' } : {}),
...(params.video === 'on' || params.video === 'off' ? { video: params.video } : {}),
...(params.headed !== undefined ? { headless: !params.headed } : {}),
_optionContextReuseMode: params.reuseContext ? 'when-possible' : undefined,
_optionConnectOptions: params.connectWsEndpoint ? { wsEndpoint: params.connectWsEndpoint } : undefined,
},

View file

@ -1684,6 +1684,25 @@ interface TestConfig<TestArgs = {}, WorkerArgs = {}> {
*/
timeout?: number;
/**
* Path to a single `tsconfig` applicable to all imported files. By default, `tsconfig` for each imported file is
* looked up separately. Note that `tsconfig` property has no effect while the configuration file or any of its
* dependencies are loaded. Ignored when `--tsconfig` command line option is specified.
*
* **Usage**
*
* ```js
* // playwright.config.ts
* import { defineConfig } from '@playwright/test';
*
* export default defineConfig({
* tsconfig: './tsconfig.test.json',
* });
* ```
*
*/
tsconfig?: string;
/**
* Whether to update expected snapshots with the actual results produced by the test run. Defaults to `'missing'`.
* - `'all'` - All tests that are executed will update snapshots that did not match. Matching snapshots will not be

View file

@ -61,11 +61,13 @@ const checkerboardStyle: React.CSSProperties = {
export const ImageDiffView: React.FC<{
diff: ImageDiff,
noTargetBlank?: boolean,
}> = ({ diff, noTargetBlank }) => {
hideDetails?: boolean,
}> = ({ diff, noTargetBlank, hideDetails }) => {
const [mode, setMode] = React.useState<'diff' | 'actual' | 'expected' | 'slider' | 'sxs'>(diff.diff ? 'diff' : 'actual');
const [showSxsDiff, setShowSxsDiff] = React.useState<boolean>(false);
const [expectedImage, setExpectedImage] = React.useState<HTMLImageElement | null>(null);
const [expectedImageTitle, setExpectedImageTitle] = React.useState<string>('Expected');
const [actualImage, setActualImage] = React.useState<HTMLImageElement | null>(null);
const [diffImage, setDiffImage] = React.useState<HTMLImageElement | null>(null);
const [measure, ref] = useMeasure<HTMLDivElement>();
@ -73,6 +75,7 @@ export const ImageDiffView: React.FC<{
React.useEffect(() => {
(async () => {
setExpectedImage(await loadImage(diff.expected?.attachment.path));
setExpectedImageTitle(diff.expected?.title || 'Expected');
setActualImage(await loadImage(diff.actual?.attachment.path));
setDiffImage(await loadImage(diff.diff?.attachment.path));
})();
@ -98,31 +101,31 @@ export const ImageDiffView: React.FC<{
<div data-testid='test-result-image-mismatch-tabs' style={{ display: 'flex', margin: '10px 0 20px' }}>
{diff.diff && <div style={{ ...modeStyle, fontWeight: mode === 'diff' ? 600 : 'initial' }} onClick={() => setMode('diff')}>Diff</div>}
<div style={{ ...modeStyle, fontWeight: mode === 'actual' ? 600 : 'initial' }} onClick={() => setMode('actual')}>Actual</div>
<div style={{ ...modeStyle, fontWeight: mode === 'expected' ? 600 : 'initial' }} onClick={() => setMode('expected')}>Expected</div>
<div style={{ ...modeStyle, fontWeight: mode === 'expected' ? 600 : 'initial' }} onClick={() => setMode('expected')}>{expectedImageTitle}</div>
<div style={{ ...modeStyle, fontWeight: mode === 'sxs' ? 600 : 'initial' }} onClick={() => setMode('sxs')}>Side by side</div>
<div style={{ ...modeStyle, fontWeight: mode === 'slider' ? 600 : 'initial' }} onClick={() => setMode('slider')}>Slider</div>
</div>
<div style={{ display: 'flex', justifyContent: 'center', flex: 'auto', minHeight: fitHeight + 60 }}>
{diff.diff && mode === 'diff' && <ImageWithSize image={diffImage} alt='Diff' canvasWidth={fitWidth} canvasHeight={fitHeight} scale={scale}/>}
{diff.diff && mode === 'actual' && <ImageWithSize image={actualImage} alt='Actual' canvasWidth={fitWidth} canvasHeight={fitHeight} scale={scale}/>}
{diff.diff && mode === 'expected' && <ImageWithSize image={expectedImage} alt='Expected' canvasWidth={fitWidth} canvasHeight={fitHeight} scale={scale}/>}
{diff.diff && mode === 'slider' && <ImageDiffSlider expectedImage={expectedImage} actualImage={actualImage} canvasWidth={fitWidth} canvasHeight={fitHeight} scale={scale} />}
{diff.diff && mode === 'diff' && <ImageWithSize image={diffImage} alt='Diff' hideSize={hideDetails} canvasWidth={fitWidth} canvasHeight={fitHeight} scale={scale}/>}
{diff.diff && mode === 'actual' && <ImageWithSize image={actualImage} alt='Actual' hideSize={hideDetails} canvasWidth={fitWidth} canvasHeight={fitHeight} scale={scale}/>}
{diff.diff && mode === 'expected' && <ImageWithSize image={expectedImage} alt={expectedImageTitle} hideSize={hideDetails} canvasWidth={fitWidth} canvasHeight={fitHeight} scale={scale}/>}
{diff.diff && mode === 'slider' && <ImageDiffSlider expectedImage={expectedImage} actualImage={actualImage} hideSize={hideDetails} canvasWidth={fitWidth} canvasHeight={fitHeight} scale={scale} expectedTitle={expectedImageTitle} />}
{diff.diff && mode === 'sxs' && <div style={{ display: 'flex' }}>
<ImageWithSize image={expectedImage} title='Expected' canvasWidth={sxsScale * imageWidth} canvasHeight={sxsScale * imageHeight} scale={sxsScale} />
<ImageWithSize image={showSxsDiff ? diffImage : actualImage} title={showSxsDiff ? 'Diff' : 'Actual'} onClick={() => setShowSxsDiff(!showSxsDiff)} canvasWidth={sxsScale * imageWidth} canvasHeight={sxsScale * imageHeight} scale={sxsScale} />
<ImageWithSize image={expectedImage} title={expectedImageTitle} hideSize={hideDetails} canvasWidth={sxsScale * imageWidth} canvasHeight={sxsScale * imageHeight} scale={sxsScale} />
<ImageWithSize image={showSxsDiff ? diffImage : actualImage} title={showSxsDiff ? 'Diff' : 'Actual'} onClick={() => setShowSxsDiff(!showSxsDiff)} hideSize={hideDetails} canvasWidth={sxsScale * imageWidth} canvasHeight={sxsScale * imageHeight} scale={sxsScale} />
</div>}
{!diff.diff && mode === 'actual' && <ImageWithSize image={actualImage} title='Actual' canvasWidth={fitWidth} canvasHeight={fitHeight} scale={scale}/>}
{!diff.diff && mode === 'expected' && <ImageWithSize image={expectedImage} title='Expected' canvasWidth={fitWidth} canvasHeight={fitHeight} scale={scale}/>}
{!diff.diff && mode === 'actual' && <ImageWithSize image={actualImage} title='Actual' hideSize={hideDetails} canvasWidth={fitWidth} canvasHeight={fitHeight} scale={scale}/>}
{!diff.diff && mode === 'expected' && <ImageWithSize image={expectedImage} title={expectedImageTitle} hideSize={hideDetails} canvasWidth={fitWidth} canvasHeight={fitHeight} scale={scale}/>}
{!diff.diff && mode === 'sxs' && <div style={{ display: 'flex' }}>
<ImageWithSize image={expectedImage} title='Expected' canvasWidth={sxsScale * imageWidth} canvasHeight={sxsScale * imageHeight} scale={sxsScale} />
<ImageWithSize image={expectedImage} title={expectedImageTitle} canvasWidth={sxsScale * imageWidth} canvasHeight={sxsScale * imageHeight} scale={sxsScale} />
<ImageWithSize image={actualImage} title='Actual' canvasWidth={sxsScale * imageWidth} canvasHeight={sxsScale * imageHeight} scale={sxsScale} />
</div>}
</div>
<div style={{ alignSelf: 'start', lineHeight: '18px', marginLeft: '15px' }}>
{!hideDetails && <div style={{ alignSelf: 'start', lineHeight: '18px', marginLeft: '15px' }}>
<div>{diff.diff && <a target='_blank' href={diff.diff.attachment.path} rel='noreferrer'>{diff.diff.attachment.name}</a>}</div>
<div><a target={noTargetBlank ? '' : '_blank'} href={diff.actual!.attachment.path} rel='noreferrer'>{diff.actual!.attachment.name}</a></div>
<div><a target={noTargetBlank ? '' : '_blank'} href={diff.expected!.attachment.path} rel='noreferrer'>{diff.expected!.attachment.name}</a></div>
</div>
</div>}
</>}
</div>;
};
@ -133,7 +136,9 @@ export const ImageDiffSlider: React.FC<{
canvasWidth: number,
canvasHeight: number,
scale: number,
}> = ({ expectedImage, actualImage, canvasWidth, canvasHeight, scale }) => {
expectedTitle: string,
hideSize?: boolean,
}> = ({ expectedImage, actualImage, canvasWidth, canvasHeight, scale, expectedTitle, hideSize }) => {
const absoluteStyle: React.CSSProperties = {
position: 'absolute',
top: 0,
@ -144,7 +149,7 @@ export const ImageDiffSlider: React.FC<{
const sameSize = expectedImage.naturalWidth === actualImage.naturalWidth && expectedImage.naturalHeight === actualImage.naturalHeight;
return <div style={{ flex: 'none', display: 'flex', alignItems: 'center', flexDirection: 'column', userSelect: 'none' }}>
<div style={{ margin: 5 }}>
{!hideSize && <div style={{ margin: 5 }}>
{!sameSize && <span style={{ flex: 'none', margin: '0 5px' }}>Expected </span>}
<span>{expectedImage.naturalWidth}</span>
<span style={{ flex: 'none', margin: '0 5px' }}>x</span>
@ -153,7 +158,7 @@ export const ImageDiffSlider: React.FC<{
{!sameSize && <span>{actualImage.naturalWidth}</span>}
{!sameSize && <span style={{ flex: 'none', margin: '0 5px' }}>x</span>}
{!sameSize && <span>{actualImage.naturalHeight}</span>}
</div>
</div>}
<div style={{ position: 'relative', width: canvasWidth, height: canvasHeight, margin: 15, ...checkerboardStyle }}>
<ResizeView
orientation={'horizontal'}
@ -161,7 +166,7 @@ export const ImageDiffSlider: React.FC<{
setOffsets={offsets => setSlider(offsets[0])}
resizerColor={'#57606a80'}
resizerWidth={6}></ResizeView>
<img alt='Expected' style={{
<img alt={expectedTitle} style={{
width: expectedImage.naturalWidth * scale,
height: expectedImage.naturalHeight * scale,
}} draggable='false' src={expectedImage.src} />
@ -179,18 +184,19 @@ const ImageWithSize: React.FunctionComponent<{
image: HTMLImageElement,
title?: string,
alt?: string,
hideSize?: boolean,
canvasWidth: number,
canvasHeight: number,
scale: number,
onClick?: () => void;
}> = ({ image, title, alt, canvasWidth, canvasHeight, scale, onClick }) => {
}> = ({ image, title, alt, hideSize, canvasWidth, canvasHeight, scale, onClick }) => {
return <div style={{ flex: 'none', display: 'flex', alignItems: 'center', flexDirection: 'column' }}>
<div style={{ margin: 5 }}>
{!hideSize && <div style={{ margin: 5 }}>
{title && <span style={{ flex: 'none', margin: '0 5px' }}>{title}</span>}
<span>{image.naturalWidth}</span>
<span style={{ flex: 'none', margin: '0 5px' }}>x</span>
<span>{image.naturalHeight}</span>
</div>
</div>}
<div style={{ display: 'flex', flex: 'none', width: canvasWidth, height: canvasHeight, margin: 15, ...checkerboardStyle }}>
<img
width={image.naturalWidth * scale}

View file

@ -54,7 +54,7 @@ it.describe('mobile viewport', () => {
}
});
it('should be detectable', async ({ playwright, browser, server, browserName, platform }) => {
it('should be detectable', async ({ playwright, browser }) => {
const iPhone = playwright.devices['iPhone 6'];
const context = await browser.newContext({ ...iPhone });
const page = await context.newPage();
@ -62,7 +62,7 @@ it.describe('mobile viewport', () => {
await context.close();
});
it('should detect touch when applying viewport with touches', async ({ browser, server, browserName, platform }) => {
it('should detect touch when applying viewport with touches', async ({ browser, server }) => {
const context = await browser.newContext({ viewport: { width: 800, height: 600 }, hasTouch: true });
const page = await context.newPage();
await page.goto(server.EMPTY_PAGE);
@ -154,7 +154,7 @@ it.describe('mobile viewport', () => {
await desktopPage.close();
});
it('mouse should work with mobile viewports and cross process navigations', async ({ browser, server, browserName }) => {
it('mouse should work with mobile viewports and cross process navigations', async ({ browser, server }) => {
// @see https://crbug.com/929806
const context = await browser.newContext({ viewport: { width: 360, height: 640 }, isMobile: true });
const page = await context.newPage();
@ -193,8 +193,7 @@ it.describe('mobile viewport', () => {
{ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/31551' },
{ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/23573' },
]
}, async ({ playwright, browser, server, browserName, isLinux, headless }) => {
it.fixme(browserName === 'webkit' && isLinux && headless, 'Fails on WPE apparently due to accelerated compositing + fixed layout');
}, async ({ playwright, browser, server }) => {
const iPhone = playwright.devices['iPhone 12'];
const context = await browser.newContext({ ...iPhone });
const page = await context.newPage();
@ -204,7 +203,7 @@ it.describe('mobile viewport', () => {
await context.close();
});
it('view scale should reset after navigation', async ({ browser, browserName }) => {
it('view scale should reset after navigation', async ({ browser }) => {
it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/26876' });
const context = await browser.newContext({
viewport: { width: 390, height: 664 },

View file

@ -258,7 +258,8 @@ it('should work with subframes return 204 with domcontentloaded', async ({ page,
await page.goto(server.PREFIX + '/frames/one-frame.html', { waitUntil: 'domcontentloaded' });
});
it('should fail when server returns 204', async ({ page, server, browserName }) => {
it('should fail when server returns 204', async ({ page, server, browserName, isLinux }) => {
it.fixme(browserName === 'webkit' && isLinux, 'Regressed in https://github.com/microsoft/playwright-browsers/pull/1297');
// WebKit just loads an empty page.
server.setRoute('/empty.html', (req, res) => {
res.statusCode = 204;

View file

@ -179,7 +179,7 @@ for (const useIntermediateMergeReport of [false] as const) {
await expect(page.locator('text=Image mismatch')).toBeVisible();
await expect(page.locator('text=Snapshot mismatch')).toHaveCount(0);
await expect(page.getByTestId('test-result-image-mismatch-tabs').locator('div')).toHaveText([
await expect(page.getByTestId('test-screenshot-error-view').getByTestId('test-result-image-mismatch-tabs').locator('div')).toHaveText([
'Diff',
'Actual',
'Expected',
@ -187,7 +187,9 @@ for (const useIntermediateMergeReport of [false] as const) {
'Slider',
]);
const imageDiff = page.getByTestId('test-result-image-mismatch');
for (const testId of ['test-results-image-diff', 'test-screenshot-error-view']) {
await test.step(testId, async () => {
const imageDiff = page.getByTestId(testId).getByTestId('test-result-image-mismatch');
await test.step('Diff', async () => {
await expect(imageDiff.locator('img')).toHaveAttribute('alt', 'Diff');
});
@ -218,6 +220,8 @@ for (const useIntermediateMergeReport of [false] as const) {
await expect(imageDiff.locator('img').last()).toHaveAttribute('alt', 'Actual');
});
});
}
});
test('should include multiple image diffs', async ({ runInlineTest, page, showReport }) => {
const IMG_WIDTH = 200;
@ -285,8 +289,14 @@ for (const useIntermediateMergeReport of [false] as const) {
await showReport();
await page.click('text=fails');
await expect(page.locator('data-testid=test-result-image-mismatch')).toHaveCount(3);
await expect(page.locator('text=Image mismatch:')).toHaveText([
await expect(page.getByTestId('test-screenshot-error-view').getByTestId('error-suffix')).toContainText([
`> 6 | await expect.soft(screenshot).toMatchSnapshot('expected.png');`,
`> 7 | await expect.soft(screenshot).toMatchSnapshot('expected.png');`,
`> 8 | await expect.soft(screenshot).toMatchSnapshot('expected.png');`,
]);
const imageDiffs = page.getByTestId('test-results-image-diff');
await expect(imageDiffs.getByTestId('test-result-image-mismatch')).toHaveCount(3);
await expect(imageDiffs.getByText('Image mismatch:')).toHaveText([
'Image mismatch: expected.png',
'Image mismatch: expected-1.png',
'Image mismatch: expected-2.png',
@ -323,7 +333,7 @@ for (const useIntermediateMergeReport of [false] as const) {
await expect(page.getByTestId('test-result-image-mismatch-tabs').locator('div')).toHaveText([
'Diff',
'Actual',
'Expected',
'Previous',
'Side by side',
'Slider',
]);
@ -460,7 +470,7 @@ for (const useIntermediateMergeReport of [false] as const) {
await showReport();
await page.click('text=fails');
await expect(page.locator('.test-error-message span:has-text("received")').nth(1)).toHaveCSS('color', 'rgb(204, 0, 0)');
await expect(page.locator('.test-error-view span:has-text("received")').nth(1)).toHaveCSS('color', 'rgb(204, 0, 0)');
});
test('should show trace source', async ({ runInlineTest, page, showReport }) => {

View file

@ -675,6 +675,65 @@ test('should respect --tsconfig option', async ({ runInlineTest }) => {
expect(result.output).not.toContain(`Could not`);
});
test('should respect config.tsconfig option', async ({ runInlineTest }) => {
const result = await runInlineTest({
'playwright.config.ts': `
export { configFoo } from '~/foo';
export default {
testDir: './tests',
tsconfig: './tsconfig.tests.json',
};
`,
'tsconfig.json': `{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["./mapped-from-config/*"],
},
},
}`,
'mapped-from-config/foo.ts': `
export const configFoo = 17;
`,
'tsconfig.tests.json': `{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["./mapped-from-tests/*"],
},
},
}`,
'mapped-from-tests/foo.ts': `
export const testFoo = 42;
`,
'tests/tsconfig.json': `{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["../should-be-ignored/*"],
},
},
}`,
'tests/a.test.ts': `
import { testFoo } from '~/foo';
import { configFoo } from '../playwright.config';
import { test, expect } from '@playwright/test';
test('test', ({}) => {
expect(testFoo).toBe(42);
expect(configFoo).toBe(17);
});
`,
'should-be-ignored/foo.ts': `
export const testFoo = 43;
export const configFoo = 18;
`,
});
expect(result.passed).toBe(1);
expect(result.exitCode).toBe(0);
expect(result.output).not.toContain(`Could not`);
});
test.describe('directory imports', () => {
test('should resolve index.js without path mapping in CJS', async ({ runInlineTest, runTSC }) => {
const files = {