@percy/playwright 1.0.10-alpha.0 → 1.0.11-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +133 -123
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -8,90 +8,18 @@ const CLIENT_INFO = `${sdkPkg.name}/${sdkPkg.version}`;
|
|
|
8
8
|
const ENV_INFO = `${playwrightPkg.name}/${playwrightPkg.version}`;
|
|
9
9
|
const log = utils.logger('playwright');
|
|
10
10
|
|
|
11
|
-
// This function is executed in the browser context to handle dynamic resources.
|
|
12
|
-
/* istanbul ignore next: browser-executed function difficult to instrument */
|
|
13
|
-
const handleDynamicResources = async () => {
|
|
14
|
-
// Handle lazy-loaded images
|
|
15
|
-
document.querySelectorAll('img').forEach(img => {
|
|
16
|
-
const dataSrc = img.getAttribute('data-src');
|
|
17
|
-
if (dataSrc) {
|
|
18
|
-
try {
|
|
19
|
-
// Only allow http, https, data, or blob URLs
|
|
20
|
-
const url = new URL(dataSrc, window.location.origin);
|
|
21
|
-
if (
|
|
22
|
-
url.protocol === 'http:' ||
|
|
23
|
-
url.protocol === 'https:' ||
|
|
24
|
-
url.protocol === 'data:' ||
|
|
25
|
-
url.protocol === 'blob:'
|
|
26
|
-
) {
|
|
27
|
-
img.src = url.href;
|
|
28
|
-
} else {
|
|
29
|
-
// Ignored unsafe data-src value
|
|
30
|
-
}
|
|
31
|
-
} catch (e) {
|
|
32
|
-
// If dataSrc is not a valid URL, ignore it
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
// Handle blob background images
|
|
38
|
-
const elements = Array.from(document.querySelectorAll('*'));
|
|
39
|
-
const promises = [];
|
|
40
|
-
|
|
41
|
-
for (const el of elements) {
|
|
42
|
-
const style = window.getComputedStyle(el);
|
|
43
|
-
const backgroundImage = style.getPropertyValue('background-image');
|
|
44
|
-
|
|
45
|
-
if (backgroundImage && backgroundImage.includes('blob:')) {
|
|
46
|
-
const blobUrlMatch = backgroundImage.match(/url\("?(blob:.+?)"?\)/);
|
|
47
|
-
if (blobUrlMatch && blobUrlMatch[1]) {
|
|
48
|
-
const blobUrl = blobUrlMatch[1];
|
|
49
|
-
|
|
50
|
-
/* eslint-disable-next-line no-undef */
|
|
51
|
-
const promise = fetch(blobUrl)
|
|
52
|
-
.then(res => res.blob())
|
|
53
|
-
.then(blob => new Promise((resolve, reject) => {
|
|
54
|
-
/* eslint-disable-next-line no-undef */
|
|
55
|
-
const reader = new FileReader();
|
|
56
|
-
reader.onloadend = () => {
|
|
57
|
-
el.style.backgroundImage = style.getPropertyValue('background-image').replace(blobUrl, reader.result);
|
|
58
|
-
resolve();
|
|
59
|
-
};
|
|
60
|
-
reader.onerror = reject;
|
|
61
|
-
reader.readAsDataURL(blob);
|
|
62
|
-
}))
|
|
63
|
-
.catch(err => {
|
|
64
|
-
log.warn(`Failed to process blob URL "${blobUrl}": ${err.message}`);
|
|
65
|
-
// Silently handle errors
|
|
66
|
-
});
|
|
67
|
-
promises.push(promise);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
await Promise.all(promises);
|
|
72
|
-
};
|
|
73
|
-
|
|
74
11
|
// Processes a single cross-origin frame to capture its snapshot and resources.
|
|
75
12
|
async function processFrame(page, frame, options, percyDOM) {
|
|
76
13
|
const frameUrl = frame.url();
|
|
77
14
|
|
|
78
|
-
// Execute pre-serialization transformations in the iframe
|
|
79
|
-
/* istanbul ignore next: browser-executed function call */
|
|
80
|
-
await frame.evaluate(handleDynamicResources);
|
|
81
|
-
|
|
82
15
|
/* istanbul ignore next: browser-executed iframe serialization */
|
|
16
|
+
// enableJavaScript: true prevents the standard iframe serialization logic from running.
|
|
17
|
+
// This is necessary because we're manually handling cross-origin iframe serialization here.
|
|
83
18
|
const iframeSnapshot = await frame.evaluate((opts) => {
|
|
84
19
|
/* eslint-disable-next-line no-undef */
|
|
85
20
|
return PercyDOM.serialize(opts);
|
|
86
21
|
}, { ...options, enableJavascript: true });
|
|
87
22
|
|
|
88
|
-
// Create a new resource for the iframe's HTML
|
|
89
|
-
const iframeResource = {
|
|
90
|
-
url: frameUrl,
|
|
91
|
-
content: iframeSnapshot.html,
|
|
92
|
-
mimetype: 'text/html'
|
|
93
|
-
};
|
|
94
|
-
|
|
95
23
|
// Get the iframe's element data from the main page context
|
|
96
24
|
/* istanbul ignore next: browser-executed evaluation function */
|
|
97
25
|
const iframeData = await page.evaluate((fUrl) => {
|
|
@@ -106,12 +34,141 @@ async function processFrame(page, frame, options, percyDOM) {
|
|
|
106
34
|
|
|
107
35
|
return {
|
|
108
36
|
iframeData,
|
|
109
|
-
iframeResource,
|
|
110
37
|
iframeSnapshot,
|
|
111
38
|
frameUrl
|
|
112
39
|
};
|
|
113
40
|
}
|
|
114
41
|
|
|
42
|
+
async function captureSerializedDOM(page, options, percyDOM) {
|
|
43
|
+
/* istanbul ignore next: no instrumenting injected code */
|
|
44
|
+
let domSnapshot = await page.evaluate((options) => {
|
|
45
|
+
/* eslint-disable-next-line no-undef */
|
|
46
|
+
return PercyDOM.serialize(options);
|
|
47
|
+
}, options);
|
|
48
|
+
|
|
49
|
+
// Process CORS IFrames
|
|
50
|
+
// Note: Blob URL handling (data-src images, blob background images) is now handled
|
|
51
|
+
// in the CLI via async DOM serialization. See: percy/cli packages/dom/src/serialize-blob-urls.js
|
|
52
|
+
// This section only handles cross-origin iframe serialization and resource merging.
|
|
53
|
+
const pageUrl = new URL(page.url());
|
|
54
|
+
const crossOriginFrames = page.frames()
|
|
55
|
+
.filter(frame => frame.url() !== 'about:blank' && new URL(frame.url()).origin !== pageUrl.origin);
|
|
56
|
+
|
|
57
|
+
// Inject Percy DOM into all cross-origin frames before processing them in parallel
|
|
58
|
+
await Promise.all(crossOriginFrames.map(frame => frame.evaluate(percyDOM)));
|
|
59
|
+
|
|
60
|
+
const processedFrames = await Promise.all(
|
|
61
|
+
crossOriginFrames.map(frame => processFrame(page, frame, options, percyDOM))
|
|
62
|
+
);
|
|
63
|
+
domSnapshot.corsIframes = processedFrames;
|
|
64
|
+
domSnapshot.cookies = await page.context().cookies();
|
|
65
|
+
return domSnapshot;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function changeViewportAndWait(page, width, height, resizeCount) {
|
|
69
|
+
try {
|
|
70
|
+
await page.setViewportSize({ width, height });
|
|
71
|
+
} catch (error) {
|
|
72
|
+
log.debug(`Resizing using setViewportSize failed for width ${width}`, error);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
/* istanbul ignore next: no instrumenting injected code */
|
|
78
|
+
await page.waitForFunction((count) => window.resizeCount === count, resizeCount, { timeout: 1000 });
|
|
79
|
+
} catch (error) {
|
|
80
|
+
log.debug(`Timed out waiting for window resize event for width ${width}`, error);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isResponsiveDOMCaptureValid(options) {
|
|
85
|
+
if (utils.percy?.config?.percy?.deferUploads) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
return (
|
|
89
|
+
options?.responsive_snapshot_capture ||
|
|
90
|
+
options?.responsiveSnapshotCapture ||
|
|
91
|
+
utils.percy?.config?.snapshot?.responsiveSnapshotCapture ||
|
|
92
|
+
false
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function captureResponsiveDOM(page, options, percyDOM) {
|
|
97
|
+
const domSnapshots = [];
|
|
98
|
+
/* istanbul ignore next: no instrumenting injected code */
|
|
99
|
+
const currentViewport = page.viewportSize() || await page.evaluate(() => ({
|
|
100
|
+
width: window.innerWidth,
|
|
101
|
+
height: window.innerHeight
|
|
102
|
+
}));
|
|
103
|
+
let currentWidth = currentViewport.width;
|
|
104
|
+
let currentHeight = currentViewport.height;
|
|
105
|
+
let lastWindowWidth = currentWidth;
|
|
106
|
+
let resizeCount = 0;
|
|
107
|
+
|
|
108
|
+
/* istanbul ignore next: no instrumenting injected code */
|
|
109
|
+
await page.evaluate(() => {
|
|
110
|
+
/* eslint-disable-next-line no-undef */
|
|
111
|
+
PercyDOM.waitForResize();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Calculate default height for non-mobile widths
|
|
115
|
+
let defaultHeight = currentHeight;
|
|
116
|
+
if (process.env.PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT?.toLowerCase() === 'true') {
|
|
117
|
+
const minHeight = utils.percy?.config?.snapshot?.minHeight;
|
|
118
|
+
/* istanbul ignore next: no instrumenting injected code */
|
|
119
|
+
defaultHeight = await page.evaluate((minH) => window.outerHeight - window.innerHeight + minH, minHeight);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Get width and height combinations
|
|
123
|
+
/* istanbul ignore next: CLI version compatibility check */
|
|
124
|
+
if (!utils.getResponsiveWidths) {
|
|
125
|
+
throw new Error('Update Percy CLI to the latest version to use responsiveSnapshotCapture');
|
|
126
|
+
}
|
|
127
|
+
const widthHeights = await utils.getResponsiveWidths(options.widths || []);
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
for (let { width, height } of widthHeights) {
|
|
131
|
+
height = height || defaultHeight;
|
|
132
|
+
if (lastWindowWidth !== width) {
|
|
133
|
+
resizeCount++;
|
|
134
|
+
await changeViewportAndWait(page, width, height, resizeCount);
|
|
135
|
+
lastWindowWidth = width;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (process.env.PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE?.toLowerCase() === 'true') {
|
|
139
|
+
await page.reload();
|
|
140
|
+
await page.evaluate(percyDOM);
|
|
141
|
+
/* istanbul ignore next: no instrumenting injected code */
|
|
142
|
+
await page.evaluate(() => {
|
|
143
|
+
/* eslint-disable-next-line no-undef */
|
|
144
|
+
PercyDOM.waitForResize();
|
|
145
|
+
});
|
|
146
|
+
resizeCount = 0; // Reset local counter to match window.resizeCount after reload
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (process.env.RESPONSIVE_CAPTURE_SLEEP_TIME) {
|
|
150
|
+
await new Promise(resolve => setTimeout(resolve, parseInt(process.env.RESPONSIVE_CAPTURE_SLEEP_TIME) * 1000));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let domSnapshot = await captureSerializedDOM(page, options, percyDOM);
|
|
154
|
+
domSnapshot.width = width;
|
|
155
|
+
domSnapshots.push(domSnapshot);
|
|
156
|
+
}
|
|
157
|
+
} finally {
|
|
158
|
+
await changeViewportAndWait(page, currentWidth, currentHeight, resizeCount + 1);
|
|
159
|
+
}
|
|
160
|
+
return domSnapshots;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function captureDOM(page, options, percyDOM) {
|
|
164
|
+
const responsiveSnapshotCapture = isResponsiveDOMCaptureValid(options);
|
|
165
|
+
if (responsiveSnapshotCapture) {
|
|
166
|
+
return await captureResponsiveDOM(page, options, percyDOM);
|
|
167
|
+
} else {
|
|
168
|
+
return await captureSerializedDOM(page, options, percyDOM);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
115
172
|
// Take a DOM snapshot and post it to the snapshot endpoint
|
|
116
173
|
const percySnapshot = async function(page, name, options) {
|
|
117
174
|
if (!page) throw new Error('A Playwright `page` object is required.');
|
|
@@ -123,54 +180,7 @@ const percySnapshot = async function(page, name, options) {
|
|
|
123
180
|
const percyDOM = await utils.fetchPercyDOM();
|
|
124
181
|
await page.evaluate(percyDOM);
|
|
125
182
|
|
|
126
|
-
|
|
127
|
-
try {
|
|
128
|
-
/* istanbul ignore next: browser-executed function call */
|
|
129
|
-
await page.evaluate(handleDynamicResources);
|
|
130
|
-
} catch (error) {
|
|
131
|
-
// Silently handle any errors from handleDynamicResources to not disrupt snapshots
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Serialize and capture the DOM
|
|
135
|
-
/* istanbul ignore next: no instrumenting injected code */
|
|
136
|
-
let domSnapshot = await page.evaluate((options) => {
|
|
137
|
-
/* eslint-disable-next-line no-undef */
|
|
138
|
-
return PercyDOM.serialize(options);
|
|
139
|
-
}, options);
|
|
140
|
-
|
|
141
|
-
// Process CORS IFrames
|
|
142
|
-
const pageUrl = new URL(page.url());
|
|
143
|
-
const crossOriginFrames = page.frames()
|
|
144
|
-
.filter(frame => frame.url() !== 'about:blank' && new URL(frame.url()).origin !== pageUrl.origin);
|
|
145
|
-
|
|
146
|
-
// Inject Percy DOM into all cross-origin frames before processing them in parallel
|
|
147
|
-
await Promise.all(crossOriginFrames.map(frame => frame.evaluate(percyDOM)));
|
|
148
|
-
|
|
149
|
-
const processedFrames = await Promise.all(
|
|
150
|
-
crossOriginFrames.map(frame => processFrame(page, frame, options, percyDOM))
|
|
151
|
-
);
|
|
152
|
-
|
|
153
|
-
for (const { iframeData, iframeResource, iframeSnapshot, frameUrl } of processedFrames) {
|
|
154
|
-
// Add the iframe's own resources to the main snapshot
|
|
155
|
-
domSnapshot.resources.push(...iframeSnapshot.resources);
|
|
156
|
-
// Add the iframe HTML resource itself
|
|
157
|
-
domSnapshot.resources.push(iframeResource);
|
|
158
|
-
|
|
159
|
-
if (iframeData && iframeData.percyElementId) {
|
|
160
|
-
const regex = new RegExp(`(<iframe[^>]*data-percy-element-id=["']${iframeData.percyElementId}["'][^>]*>)`);
|
|
161
|
-
const match = domSnapshot.html.match(regex);
|
|
162
|
-
|
|
163
|
-
/* istanbul ignore next: iframe matching logic depends on DOM structure */
|
|
164
|
-
if (match) {
|
|
165
|
-
const iframeTag = match[1];
|
|
166
|
-
// Replace the original iframe tag with one that points to the new resource.
|
|
167
|
-
const newIframeTag = iframeTag.replace(/src="[^"]*"/i, `src="${frameUrl}"`);
|
|
168
|
-
domSnapshot.html = domSnapshot.html.replace(iframeTag, newIframeTag);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
domSnapshot.cookies = await page.context().cookies();
|
|
183
|
+
let domSnapshot = await captureDOM(page, options || {}, percyDOM);
|
|
174
184
|
|
|
175
185
|
// Post the DOM to the snapshot endpoint with snapshot options and other info
|
|
176
186
|
const response = await utils.postSnapshot({
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@percy/playwright",
|
|
3
3
|
"description": "Playwright client library for visual testing with Percy",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.11-alpha.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Perceptual Inc.",
|
|
7
7
|
"repository": "https://github.com/percy/percy-playwright",
|