feat(vrt): allow providing screenshot style (#28229)

This commit is contained in:
Pavel Feldman 2023-11-30 17:42:45 -08:00 committed by GitHub
parent da6a36062e
commit 0a7a10d0f6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 228 additions and 59 deletions

View file

@ -749,6 +749,9 @@ Returns the buffer with the captured screenshot.
### option: ElementHandle.screenshot.maskColor = %%-screenshot-option-mask-color-%%
* since: v1.34
### option: ElementHandle.screenshot.style = %%-screenshot-option-style-%%
* since: v1.41
## async method: ElementHandle.scrollIntoViewIfNeeded
* since: v1.8

View file

@ -1913,6 +1913,9 @@ Returns the buffer with the captured screenshot.
### option: Locator.screenshot.maskColor = %%-screenshot-option-mask-color-%%
* since: v1.34
### option: Locator.screenshot.style = %%-screenshot-option-style-%%
* since: v1.41
## async method: Locator.scrollIntoViewIfNeeded
* since: v1.14

View file

@ -1541,6 +1541,9 @@ Snapshot name.
### option: LocatorAssertions.toHaveScreenshot#1.maskColor = %%-screenshot-option-mask-color-%%
* since: v1.35
### option: LocatorAssertions.toHaveScreenshot#1.style = %%-screenshot-option-style-%%
* since: v1.41
### option: LocatorAssertions.toHaveScreenshot#1.omitBackground = %%-screenshot-option-omit-background-%%
* since: v1.23
@ -1587,6 +1590,9 @@ Note that screenshot assertions only work with Playwright test runner.
### option: LocatorAssertions.toHaveScreenshot#2.maskColor = %%-screenshot-option-mask-color-%%
* since: v1.35
### option: LocatorAssertions.toHaveScreenshot#2.style = %%-screenshot-option-style-%%
* since: v1.41
### option: LocatorAssertions.toHaveScreenshot#2.omitBackground = %%-screenshot-option-omit-background-%%
* since: v1.23

View file

@ -3399,6 +3399,9 @@ Returns the buffer with the captured screenshot.
### option: Page.screenshot.maskColor = %%-screenshot-option-mask-color-%%
* since: v1.34
### option: Page.screenshot.style = %%-screenshot-option-style-%%
* since: v1.41
## async method: Page.selectOption
* since: v1.8
* discouraged: Use locator-based [`method: Locator.selectOption`] instead. Read more about [locators](../locators.md).

View file

@ -161,6 +161,9 @@ Snapshot name.
### option: PageAssertions.toHaveScreenshot#1.maskColor = %%-screenshot-option-mask-color-%%
* since: v1.35
### option: PageAssertions.toHaveScreenshot#1.style = %%-screenshot-option-style-%%
* since: v1.41
### option: PageAssertions.toHaveScreenshot#1.omitBackground = %%-screenshot-option-omit-background-%%
* since: v1.23
@ -212,6 +215,9 @@ Note that screenshot assertions only work with Playwright test runner.
### option: PageAssertions.toHaveScreenshot#2.maskColor = %%-screenshot-option-mask-color-%%
* since: v1.35
### option: PageAssertions.toHaveScreenshot#2.style = %%-screenshot-option-style-%%
* since: v1.41
### option: PageAssertions.toHaveScreenshot#2.omitBackground = %%-screenshot-option-omit-background-%%
* since: v1.23

View file

@ -1132,6 +1132,13 @@ Defaults to `"css"`.
When set to `"hide"`, screenshot will hide text caret. When set to `"initial"`, text caret behavior will not be changed. Defaults to `"hide"`.
## screenshot-option-style
- `style` <string>
Stylesheet to apply while making the screenshot. This is where you can hide dynamic elements, make elements invisible
or change their properties to help you creating repeatable screenshots. This stylesheet pierces the Shadow DOM and applies
to the inner frames.
## screenshot-options-common-list-v1.8
- %%-screenshot-option-animations-%%
- %%-screenshot-option-omit-background-%%

View file

@ -47,6 +47,7 @@ export default defineConfig({
- `animations` ?<[ScreenshotAnimations]<"allow"|"disabled">> See [`option: animations`] in [`method: Page.screenshot`]. Defaults to `"disabled"`.
- `caret` ?<[ScreenshotCaret]<"hide"|"initial">> See [`option: caret`] in [`method: Page.screenshot`]. Defaults to `"hide"`.
- `scale` ?<[ScreenshotScale]<"css"|"device">> See [`option: scale`] in [`method: Page.screenshot`]. Defaults to `"css"`.
- `style` ?<[string]> See [`option: style`] in [`method: Page.screenshot`].
- `toMatchSnapshot` ?<[Object]> Configuration for the [`method: SnapshotAssertions.toMatchSnapshot#1`] method.
- `threshold` ?<[float]> an acceptable perceived color difference between the same pixel in compared images, ranging from `0` (strict) and `1` (lax). `"pixelmatch"` comparator computes color difference in [YIQ color space](https://en.wikipedia.org/wiki/YIQ) and defaults `threshold` value to `0.2`.
- `maxDiffPixels` ?<[int]> an acceptable amount of pixels that could be different, unset by default.

View file

@ -1071,6 +1071,7 @@ scheme.PageExpectScreenshotParams = tObject({
selector: tString,
}))),
maskColor: tOptional(tString),
style: tOptional(tString),
})),
});
scheme.PageExpectScreenshotResult = tObject({
@ -1095,6 +1096,7 @@ scheme.PageScreenshotParams = tObject({
selector: tString,
}))),
maskColor: tOptional(tString),
style: tOptional(tString),
});
scheme.PageScreenshotResult = tObject({
binary: tBinary,
@ -1896,6 +1898,7 @@ scheme.ElementHandleScreenshotParams = tObject({
selector: tString,
}))),
maskColor: tOptional(tString),
style: tOptional(tString),
});
scheme.ElementHandleScreenshotResult = tObject({
binary: tBinary,

View file

@ -847,7 +847,7 @@ export class Frame extends SdkObject {
return result;
}
async maskSelectors(selectors: ParsedSelector[], color?: string): Promise<void> {
async maskSelectors(selectors: ParsedSelector[], color: string): Promise<void> {
const context = await this._utilityContext();
const injectedScript = await context.injectedScript();
await injectedScript.evaluate((injected, { parsed, color }) => {

View file

@ -118,8 +118,8 @@ export class Highlight {
this._innerUpdateHighlight(elements, options);
}
maskElements(elements: Element[], color?: string) {
this._innerUpdateHighlight(elements, { color: color ? color : '#F0F' });
maskElements(elements: Element[], color: string) {
this._innerUpdateHighlight(elements, { color: color });
}
private _innerUpdateHighlight(elements: Element[], options: HighlightOptions) {

View file

@ -1125,7 +1125,7 @@ export class InjectedScript {
return error;
}
maskSelectors(selectors: ParsedSelector[], color?: string) {
maskSelectors(selectors: ParsedSelector[], color: string) {
if (this._highlight)
this.hideHighlight();
this._highlight = new Highlight(this);

View file

@ -28,24 +28,25 @@ import { MultiMap } from '../utils/multimap';
declare global {
interface Window {
__cleanupScreenshot?: () => void;
__pwCleanupScreenshot?: () => void;
}
}
export type ScreenshotOptions = {
type?: 'png' | 'jpeg',
quality?: number,
omitBackground?: boolean,
animations?: 'disabled' | 'allow',
mask?: { frame: Frame, selector: string}[],
maskColor?: string,
fullPage?: boolean,
clip?: Rect,
scale?: 'css' | 'device',
caret?: 'hide' | 'initial',
type?: 'png' | 'jpeg';
quality?: number;
omitBackground?: boolean;
animations?: 'disabled' | 'allow';
mask?: { frame: Frame, selector: string}[];
maskColor?: string;
fullPage?: boolean;
clip?: Rect;
scale?: 'css' | 'device';
caret?: 'hide' | 'initial';
style?: string;
};
function inPagePrepareForScreenshots(hideCaret: boolean, disableAnimations: boolean) {
function inPagePrepareForScreenshots(screenshotStyle: string, disableAnimations: boolean) {
const collectRoots = (root: Document | ShadowRoot, roots: (Document|ShadowRoot)[] = []): (Document|ShadowRoot)[] => {
roots.push(root);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
@ -58,29 +59,23 @@ function inPagePrepareForScreenshots(hideCaret: boolean, disableAnimations: bool
return roots;
};
let documentRoots: (Document|ShadowRoot)[] | undefined;
const memoizedRoots = () => documentRoots ??= collectRoots(document);
const styleTags: Element[] = [];
if (hideCaret) {
for (const root of memoizedRoots()) {
const styleTag = document.createElement('style');
styleTag.textContent = `
*:not(#playwright-aaaaaaaaaa.playwright-bbbbbbbbbbb.playwright-cccccccccc.playwright-dddddddddd.playwright-eeeeeeeee) {
caret-color: transparent !important;
}
`;
if (root === document)
document.documentElement.append(styleTag);
else
root.append(styleTag);
styleTags.push(styleTag);
}
}
const infiniteAnimationsToResume: Set<Animation> = new Set();
const roots = collectRoots(document);
const cleanupCallbacks: (() => void)[] = [];
for (const root of roots) {
const styleTag = document.createElement('style');
styleTag.textContent = screenshotStyle;
if (root === document)
document.documentElement.append(styleTag);
else
root.append(styleTag);
cleanupCallbacks.push(() => {
styleTag.remove();
});
}
if (disableAnimations) {
const infiniteAnimationsToResume: Set<Animation> = new Set();
const handleAnimations = (root: Document|ShadowRoot): void => {
for (const animation of root.getAnimations()) {
if (!animation.effect || animation.playbackRate === 0 || infiniteAnimationsToResume.has(animation))
@ -106,7 +101,7 @@ function inPagePrepareForScreenshots(hideCaret: boolean, disableAnimations: bool
}
}
};
for (const root of memoizedRoots()) {
for (const root of roots) {
const handleRootAnimations: (() => void) = handleAnimations.bind(null, root);
handleRootAnimations();
root.addEventListener('transitionrun', handleRootAnimations);
@ -116,23 +111,22 @@ function inPagePrepareForScreenshots(hideCaret: boolean, disableAnimations: bool
root.removeEventListener('animationstart', handleRootAnimations);
});
}
cleanupCallbacks.push(() => {
for (const animation of infiniteAnimationsToResume) {
try {
animation.play();
} catch (e) {
// animation.play() should never throw, but
// we'd like to be on the safe side.
}
}
});
}
window.__cleanupScreenshot = () => {
for (const styleTag of styleTags)
styleTag.remove();
for (const animation of infiniteAnimationsToResume) {
try {
animation.play();
} catch (e) {
// animation.play() should never throw, but
// we'd like to be on the safe side.
}
}
window.__pwCleanupScreenshot = () => {
for (const cleanupCallback of cleanupCallbacks)
cleanupCallback();
delete window.__cleanupScreenshot;
delete window.__pwCleanupScreenshot;
};
}
@ -178,7 +172,7 @@ export class Screenshotter {
return this._queue.postTask(async () => {
progress.log('taking page screenshot');
const { viewportSize } = await this._originalViewportSize(progress);
await this._preparePageForScreenshot(progress, options.caret !== 'initial', options.animations === 'disabled');
await this._preparePageForScreenshot(progress, screenshotStyle(options), options.animations === 'disabled');
progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup.
if (options.fullPage) {
@ -207,7 +201,7 @@ export class Screenshotter {
progress.log('taking element screenshot');
const { viewportSize } = await this._originalViewportSize(progress);
await this._preparePageForScreenshot(progress, options.caret !== 'initial', options.animations === 'disabled');
await this._preparePageForScreenshot(progress, screenshotStyle(options), options.animations === 'disabled');
progress.throwIfAborted(); // Do not do extra work.
await handle._waitAndScrollIntoViewIfNeeded(progress, true /* waitForVisible */);
@ -231,14 +225,11 @@ export class Screenshotter {
});
}
async _preparePageForScreenshot(progress: Progress, hideCaret: boolean, disableAnimations: boolean) {
if (!hideCaret && !disableAnimations)
return;
async _preparePageForScreenshot(progress: Progress, screenshotStyle: string, disableAnimations: boolean) {
if (disableAnimations)
progress.log(' disabled all CSS animations');
await Promise.all(this._page.frames().map(async frame => {
await frame.nonStallingEvaluateInExistingContext('(' + inPagePrepareForScreenshots.toString() + `)(${hideCaret}, ${disableAnimations})`, false, 'utility').catch(() => {});
await frame.nonStallingEvaluateInExistingContext('(' + inPagePrepareForScreenshots.toString() + `)(${JSON.stringify(screenshotStyle)}, ${disableAnimations})`, false, 'utility').catch(() => {});
}));
if (!process.env.PW_TEST_SCREENSHOT_NO_FONTS_READY) {
progress.log('waiting for fonts to load...');
@ -252,7 +243,7 @@ export class Screenshotter {
async _restorePageAfterScreenshot() {
await Promise.all(this._page.frames().map(async frame => {
frame.nonStallingEvaluateInExistingContext('window.__cleanupScreenshot && window.__cleanupScreenshot()', false, 'utility').catch(() => {});
frame.nonStallingEvaluateInExistingContext('window.__pwCleanupScreenshot && window.__pwCleanupScreenshot()', false, 'utility').catch(() => {});
}));
}
@ -276,7 +267,7 @@ export class Screenshotter {
progress.throwIfAborted(); // Avoid extra work.
await Promise.all([...framesToParsedSelectors.keys()].map(async frame => {
await frame.maskSelectors(framesToParsedSelectors.get(frame), options.maskColor);
await frame.maskSelectors(framesToParsedSelectors.get(frame), options.maskColor || '#F0F');
}));
progress.cleanupWhenAborted(cleanup);
return cleanup;
@ -368,3 +359,16 @@ export function validateScreenshotOptions(options: ScreenshotOptions): 'png' | '
}
return format;
}
function screenshotStyle(options: ScreenshotOptions): string {
const parts: string[] = [];
if (options.caret !== 'initial') {
parts.push(`
*:not(#playwright-aaaaaaaaaa.playwright-bbbbbbbbbbb.playwright-cccccccccc.playwright-dddddddddd.playwright-eeeeeeeee) {
caret-color: transparent !important;
}`);
}
if (options.style)
parts.push(options.style);
return parts.join('\n');
}

View file

@ -9967,6 +9967,13 @@ export interface ElementHandle<T=Node> extends JSHandle<T> {
*/
scale?: "css"|"device";
/**
* Stylesheet to apply while making the screenshot. This is where you can hide dynamic elements, make elements
* invisible or change their properties to help you creating repeatable screenshots. This stylesheet pierces the
* Shadow DOM and applies to the inner frames.
*/
style?: string;
/**
* Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout`
* option in the config, or by using the
@ -20037,6 +20044,13 @@ export interface LocatorScreenshotOptions {
*/
scale?: "css"|"device";
/**
* Stylesheet to apply while making the screenshot. This is where you can hide dynamic elements, make elements
* invisible or change their properties to help you creating repeatable screenshots. This stylesheet pierces the
* Shadow DOM and applies to the inner frames.
*/
style?: string;
/**
* Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout`
* option in the config, or by using the
@ -20230,6 +20244,13 @@ export interface PageScreenshotOptions {
*/
scale?: "css"|"device";
/**
* Stylesheet to apply while making the screenshot. This is where you can hide dynamic elements, make elements
* invisible or change their properties to help you creating repeatable screenshots. This stylesheet pierces the
* Shadow DOM and applies to the inner frames.
*/
style?: string;
/**
* Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout`
* option in the config, or by using the

View file

@ -356,6 +356,7 @@ export async function toHaveScreenshot(
animations: config?.animations ?? 'disabled',
scale: config?.scale ?? 'css',
caret: config?.caret ?? 'hide',
style: config?.style ?? '',
...helper.allOptions,
mask: (helper.allOptions.mask || []) as LocatorEx[],
maskColor: helper.allOptions.maskColor,

View file

@ -662,6 +662,11 @@ interface TestConfig {
* to `"css"`.
*/
scale?: "css"|"device";
/**
* See `style` in [page.screenshot([options])](https://playwright.dev/docs/api/class-page#page-screenshot).
*/
style?: string;
};
/**
@ -5951,6 +5956,13 @@ interface LocatorAssertions {
*/
scale?: "css"|"device";
/**
* Stylesheet to apply while making the screenshot. This is where you can hide dynamic elements, make elements
* invisible or change their properties to help you creating repeatable screenshots. This stylesheet pierces the
* Shadow DOM and applies to the inner frames.
*/
style?: string;
/**
* An acceptable perceived color difference in the [YIQ color space](https://en.wikipedia.org/wiki/YIQ) between the
* same pixel in compared images, between zero (strict) and one (lax), default is configurable with
@ -6034,6 +6046,13 @@ interface LocatorAssertions {
*/
scale?: "css"|"device";
/**
* Stylesheet to apply while making the screenshot. This is where you can hide dynamic elements, make elements
* invisible or change their properties to help you creating repeatable screenshots. This stylesheet pierces the
* Shadow DOM and applies to the inner frames.
*/
style?: string;
/**
* An acceptable perceived color difference in the [YIQ color space](https://en.wikipedia.org/wiki/YIQ) between the
* same pixel in compared images, between zero (strict) and one (lax), default is configurable with
@ -6298,6 +6317,13 @@ interface PageAssertions {
*/
scale?: "css"|"device";
/**
* Stylesheet to apply while making the screenshot. This is where you can hide dynamic elements, make elements
* invisible or change their properties to help you creating repeatable screenshots. This stylesheet pierces the
* Shadow DOM and applies to the inner frames.
*/
style?: string;
/**
* An acceptable perceived color difference in the [YIQ color space](https://en.wikipedia.org/wiki/YIQ) between the
* same pixel in compared images, between zero (strict) and one (lax), default is configurable with
@ -6411,6 +6437,13 @@ interface PageAssertions {
*/
scale?: "css"|"device";
/**
* Stylesheet to apply while making the screenshot. This is where you can hide dynamic elements, make elements
* invisible or change their properties to help you creating repeatable screenshots. This stylesheet pierces the
* Shadow DOM and applies to the inner frames.
*/
style?: string;
/**
* An acceptable perceived color difference in the [YIQ color space](https://en.wikipedia.org/wiki/YIQ) between the
* same pixel in compared images, between zero (strict) and one (lax), default is configurable with

View file

@ -1944,6 +1944,7 @@ export type PageExpectScreenshotParams = {
selector: string,
}[],
maskColor?: string,
style?: string,
},
};
export type PageExpectScreenshotOptions = {
@ -1971,6 +1972,7 @@ export type PageExpectScreenshotOptions = {
selector: string,
}[],
maskColor?: string,
style?: string,
},
};
export type PageExpectScreenshotResult = {
@ -1995,6 +1997,7 @@ export type PageScreenshotParams = {
selector: string,
}[],
maskColor?: string,
style?: string,
};
export type PageScreenshotOptions = {
timeout?: number,
@ -2011,6 +2014,7 @@ export type PageScreenshotOptions = {
selector: string,
}[],
maskColor?: string,
style?: string,
};
export type PageScreenshotResult = {
binary: Binary,
@ -3355,6 +3359,7 @@ export type ElementHandleScreenshotParams = {
selector: string,
}[],
maskColor?: string,
style?: string,
};
export type ElementHandleScreenshotOptions = {
timeout?: number,
@ -3369,6 +3374,7 @@ export type ElementHandleScreenshotOptions = {
selector: string,
}[],
maskColor?: string,
style?: string,
};
export type ElementHandleScreenshotResult = {
binary: Binary,

View file

@ -379,6 +379,7 @@ CommonScreenshotOptions:
frame: Frame
selector: string
maskColor: string?
style: string?
LaunchOptions:
type: mixin

View file

@ -543,6 +543,36 @@ it.describe('page screenshot', () => {
maskColor: '#00FF00',
})).toMatchSnapshot('mask-color-should-work.png');
});
it('should hide elements based on attr', async ({ page, server }) => {
await page.setViewportSize({ width: 500, height: 500 });
await page.goto(server.PREFIX + '/grid.html');
await page.locator('div').nth(5).evaluate(element => {
element.setAttribute('data-test-screenshot', 'hide');
});
expect(await page.screenshot({
style: `[data-test-screenshot="hide"] {
visibility: hidden;
}`
})).toMatchSnapshot('hide-should-work.png');
const visibility = await page.locator('div').nth(5).evaluate(element => element.style.visibility);
expect(visibility).toBe('');
});
it('should remove elements based on attr', async ({ page, server }) => {
await page.setViewportSize({ width: 500, height: 500 });
await page.goto(server.PREFIX + '/grid.html');
await page.locator('div').nth(5).evaluate(element => {
element.setAttribute('data-test-screenshot', 'remove');
});
expect(await page.screenshot({
style: `[data-test-screenshot="remove"] {
display: none;
}`
})).toMatchSnapshot('remove-should-work.png');
const display = await page.locator('div').nth(5).evaluate(element => element.style.display);
expect(display).toBe('');
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View file

@ -1216,6 +1216,47 @@ test('should support maskColor option', async ({ runInlineTest }) => {
expect(result.exitCode).toBe(0);
});
test('should support style option', async ({ runInlineTest }) => {
const result = await runInlineTest({
...playwrightConfig({
snapshotPathTemplate: '__screenshots__/{testFilePath}/{arg}{ext}',
}),
'__screenshots__/a.spec.js/snapshot.png': createImage(IMG_WIDTH, IMG_HEIGHT, 0, 255, 0),
'a.spec.js': `
const { test, expect } = require('@playwright/test');
test('png', async ({ page }) => {
await page.setContent('<style> html,body { padding: 0; margin: 0; }</style>');
await expect(page).toHaveScreenshot('snapshot.png', {
style: 'body { background: #00FF00; }',
});
});
`,
});
expect(result.exitCode).toBe(0);
});
test('should support style option in config', async ({ runInlineTest }) => {
const result = await runInlineTest({
...playwrightConfig({
snapshotPathTemplate: '__screenshots__/{testFilePath}/{arg}{ext}',
expect: {
toHaveScreenshot: {
style: 'body { background: #00FF00; }',
},
},
}),
'__screenshots__/a.spec.js/snapshot.png': createImage(IMG_WIDTH, IMG_HEIGHT, 0, 255, 0),
'a.spec.js': `
const { test, expect } = require('@playwright/test');
test('png', async ({ page }) => {
await page.setContent('<style> html,body { padding: 0; margin: 0; }</style>');
await expect(page).toHaveScreenshot('snapshot.png');
});
`,
});
expect(result.exitCode).toBe(0);
});
function playwrightConfig(obj: any) {
return {
'playwright.config.js': `