Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for image srcset capturing #1534

Merged
merged 5 commits into from Mar 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/core/src/config.js
Expand Up @@ -190,6 +190,9 @@ export const configSchema = {
type: 'boolean',
default: false
},
captureSrcset: {
type: 'boolean'
},
requestHeaders: {
type: 'object',
normalize: false,
Expand Down Expand Up @@ -276,6 +279,7 @@ export const snapshotSchema = {
authorization: { $ref: '/config/discovery#/properties/authorization' },
disableCache: { $ref: '/config/discovery#/properties/disableCache' },
captureMockedServiceWorker: { $ref: '/config/discovery#/properties/captureMockedServiceWorker' },
captureSrcset: { $ref: '/config/discovery#/properties/captureSrcset' },
userAgent: { $ref: '/config/discovery#/properties/userAgent' },
devicePixelRatio: { $ref: '/config/discovery#/properties/devicePixelRatio' }
}
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/discovery.js
Expand Up @@ -55,6 +55,7 @@ function debugSnapshotOptions(snapshot) {
debugProp(snapshot, 'discovery.authorization', JSON.stringify);
debugProp(snapshot, 'discovery.disableCache');
debugProp(snapshot, 'discovery.captureMockedServiceWorker');
debugProp(snapshot, 'discovery.captureSrcset');
debugProp(snapshot, 'discovery.userAgent');
debugProp(snapshot, 'clientInfo');
debugProp(snapshot, 'environmentInfo');
Expand Down Expand Up @@ -171,6 +172,14 @@ async function* captureSnapshotResources(page, snapshot, options) {
yield page.evaluate(snapshot.execute.afterNavigation);
}

// Running before page idle since this will trigger many network calls
// so need to run as early as possible. plus it is just reading urls from dom srcset
// which will be already loaded after navigation complete
if (discovery.captureSrcset) {
await page.insertPercyDom();
yield page.eval('window.PercyDOM.loadAllSrcsetLinks()');
}

// iterate over additional snapshots for proper DOM capturing
for (let additionalSnapshot of [baseSnapshot, ...additionalSnapshots]) {
let isBaseSnapshot = additionalSnapshot === baseSnapshot;
Expand Down
20 changes: 12 additions & 8 deletions packages/core/src/page.js
Expand Up @@ -132,6 +132,17 @@ export class Page {
for (let script of scripts) await this.eval(script);
}

async insertPercyDom() {
// inject @percy/dom for serialization by evaluating the file contents which adds a global
// PercyDOM object that we can later check against
/* istanbul ignore next: no instrumenting injected code */
if (await this.eval(() => !window.PercyDOM)) {
this.log.debug('Inject @percy/dom', this.meta);
let script = await fs.promises.readFile(PERCY_DOM, 'utf-8');
await this.eval(new Function(script)); /* eslint-disable-line no-new-func */
}
}

// Takes a snapshot after waiting for any timeout, waiting for any selector, executing any
// scripts, and waiting for the network idle. Returns all other provided snapshot options along
// with the captured URL and DOM snapshot.
Expand Down Expand Up @@ -165,14 +176,7 @@ export class Page {
// wait for any final network activity before capturing the dom snapshot
await this.network.idle();

// inject @percy/dom for serialization by evaluating the file contents which adds a global
// PercyDOM object that we can later check against
/* istanbul ignore next: no instrumenting injected code */
if (await this.eval(() => !window.PercyDOM)) {
this.log.debug('Inject @percy/dom', this.meta);
let script = await fs.promises.readFile(PERCY_DOM, 'utf-8');
await this.eval(new Function(script)); /* eslint-disable-line no-new-func */
}
await this.insertPercyDom();

// serialize and capture a DOM snapshot
this.log.debug('Serialize DOM', this.meta);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/snapshot.js
Expand Up @@ -117,6 +117,7 @@ function getSnapshotOptions(options, { config, meta }) {
authorization: config.discovery.authorization,
disableCache: config.discovery.disableCache,
captureMockedServiceWorker: config.discovery.captureMockedServiceWorker,
captureSrcset: config.discovery.captureSrcset,
userAgent: config.discovery.userAgent
}
}, options], (path, prev, next) => {
Expand Down
103 changes: 103 additions & 0 deletions packages/core/test/discovery.test.js
Expand Up @@ -2039,4 +2039,107 @@ describe('Discovery', () => {
]));
});
});

describe('Capture image srcset =>', () => {
it('make request call to capture resource', async () => {
let responsiveDOM = dedent`
<html>
<head><link href="style.css" rel="stylesheet"/></head>
<body>
<p>Hello Percy!<p>
<img srcset="/img-fromsrcset.png 400w, /img-throwserror.gif 600w, /img-withdifferentcontenttype.gif 800w"
sizes="(max-width: 600px) 400px, (max-width: 800px) 600px, 800px"
src="/img-already-captured.png">
</body>
</html>
`;
server.reply('/img-fromsrcset.png', () => [200, 'image/png', pixel]);
server.reply('/img-already-captured.png', () => [200, 'image/png', pixel]);
server.reply('/img-throwserror.gif', () => [404]);
server.reply('/img-withdifferentcontenttype.gif', () => [200, 'image/gif', pixel]);

let capturedResource = {
url: 'http://localhost:8000/img-already-captured.png',
content: 'R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==',
mimetype: 'image/png'
};
await percy.snapshot({
name: 'test responsive',
url: 'http://localhost:8000',
domSnapshot: {
html: responsiveDOM,
resources: [capturedResource]
},
discovery: {
captureSrcset: true
}
});

await percy.idle();

let resource = path => jasmine.objectContaining({
attributes: jasmine.objectContaining({
'resource-url': `http://localhost:8000${path}`
})
});

let paths = server.requests.map(r => r[0]);
expect(paths).toContain('/img-fromsrcset.png');
expect(paths).toContain('/img-withdifferentcontenttype.gif');
expect(paths).toContain('/img-throwserror.gif');
expect(captured[0]).toEqual(jasmine.arrayContaining([
resource('/img-fromsrcset.png'),
resource('/img-withdifferentcontenttype.gif')
]));
});

it('using snapshot command capture srcset', async () => {
let responsiveDOM = dedent`
<html>
<head></head>
<body>
<p>Hello Percy!<p>
<img srcset="/img-fromsrcset.png 2x, /img-throwserror.jpeg 3x, /img-withdifferentcontenttype.gif 4x, https://remote.resource.com/img-shouldnotcaptured.png 5x"
sizes="(max-width: 600px) 400px, (max-width: 800px) 600px, 800px"
src="/img-already-captured.png">
</body>
</html>
`;

server.reply('/', () => [200, 'text/html', responsiveDOM]);
server.reply('/img-fromsrcset.png', () => [200, 'image/png', pixel]);
server.reply('/img-already-captured.png', () => [200, 'image/png', pixel]);
server.reply('/img-throwserror.jpeg', () => [404]);
server.reply('/img-withdifferentcontenttype.gif', () => [200, 'image/gif', pixel]);

await percy.snapshot({
name: 'image srcset',
url: 'http://localhost:8000',
widths: [1024],
discovery: {
captureSrcset: true
}
});

await percy.idle();

let resource = path => jasmine.objectContaining({
attributes: jasmine.objectContaining({
'resource-url': `http://localhost:8000${path}`
})
});

expect(captured[0]).toEqual(jasmine.arrayContaining([
resource('/img-fromsrcset.png'),
resource('/img-withdifferentcontenttype.gif'),
resource('/img-already-captured.png')
]));

expect(captured[0]).not.toContain(jasmine.objectContaining({
attributes: jasmine.objectContaining({
'resource-url': 'https://remote.resource.com/img-shouldnotcaptured.png'
})
}));
});
});
});
1 change: 1 addition & 0 deletions packages/core/types/index.d.ts
Expand Up @@ -19,6 +19,7 @@ interface DiscoveryOptions {
allowedHostnames?: string[];
disableCache?: boolean;
captureMockedServiceWorker?: boolean;
captureSrcset?: boolean;
}

interface ScopeOptions {
Expand Down
2 changes: 2 additions & 0 deletions packages/dom/src/index.js
Expand Up @@ -4,3 +4,5 @@ export {
// namespace alias
serializeDOM as serialize
} from './serialize-dom';

export { loadAllSrcsetLinks } from './serialize-image-srcset';
50 changes: 50 additions & 0 deletions packages/dom/src/serialize-image-srcset.js
@@ -0,0 +1,50 @@
function getSrcsets(dom) {
const links = new Set();

for (let img of dom.querySelectorAll('img[srcset]')) {
handleSrcSet(img.srcset, links);
}

for (let picture of dom.querySelectorAll('picture')) {
for (let source of picture.querySelectorAll('source')) {
handleSrcSet(source.srcset, links);
}
}
return Array.from(links);
}

function handleSrcSet(srcSet, links) {
let pattern = /,\s+/;

// We found couple of combination of srcset which needs different regex.
// example - https://url.com?param=a,b <--- here only separeting with , will cause incorrect capture.
// srcset = https://abc.com 320w,https://abc.com/a 400 <--- here srcset doesnot have space after comm.
if (!srcSet.match(pattern)) {
pattern = /,/;
}
srcSet = srcSet.split(pattern);
for (let src of srcSet) {
src = src.trim();
src = src.split(' ')[0];
links.add(getFormattedLink(src));
}
}
function getFormattedLink(src) {
const anchor = document.createElement('a');
anchor.href = src;
return anchor.href;
}

export function loadAllSrcsetLinks() {
const allImgTags = [];
const links = getSrcsets(document);
for (const link of links) {
const img = document.createElement('img');
img.src = link;
allImgTags.push(img);
}
// Adding to window so GC won't abort request
window.allImgTags = allImgTags;
return allImgTags;
}
export default loadAllSrcsetLinks;
61 changes: 61 additions & 0 deletions packages/dom/test/serialize-image-srcset.test.js
@@ -0,0 +1,61 @@
import { withExample, platforms } from './helpers';

import { loadAllSrcsetLinks } from '@percy/dom';

describe('loadAllSrcsetLinks', () => {
let imgTags;

platforms.forEach((platform) => {
it(`${platform}: capture url from img srcset`, async () => {
withExample(`
<img srcset="base/test/assets/example.webp, base/test/assets/example.png 100px" />
`);

imgTags = loadAllSrcsetLinks();

expect(imgTags.map(s => s.src)).toEqual(['http://localhost:9876/base/test/assets/example.webp', 'http://localhost:9876/base/test/assets/example.png']);
});

it(`${platform}: capture url from img srcset where there is no space after ,`, async () => {
withExample(`
<img srcset="base/test/assets/example.webp 200px,base/test/assets/example.png 100px" />
`);

imgTags = loadAllSrcsetLinks();

expect(imgTags.map(s => s.src)).toEqual(['http://localhost:9876/base/test/assets/example.webp', 'http://localhost:9876/base/test/assets/example.png']);
});

it(`${platform}: capture url from source of picture`, async () => {
withExample(`
<picture>
<source srcset='//locahost:9876/base/test/assets/example.webp, //localhost:9876/base/test/assets/example.png 2x' />
<source srcset='//locahost:9876/base/test/assets/example.jpeg 100px, //locahost:9876/base/test/assets/example1.jpeg 200px' />
</picture>
`);

imgTags = loadAllSrcsetLinks();

expect(imgTags.map(s => s.src)).toEqual([
'http://locahost:9876/base/test/assets/example.webp',
'http://localhost:9876/base/test/assets/example.png',
'http://locahost:9876/base/test/assets/example.jpeg',
'http://locahost:9876/base/test/assets/example1.jpeg'
]);
});

it(`${platform}: srcset inside shadowroot`, () => {
withExample(`
<img srcset="/base/test/assets/example.webp, /base/test/assets/example.png 100px, /base/test/assets/example1.png 2x" />
`, { withShadow: true });

imgTags = loadAllSrcsetLinks();

expect(imgTags.map(s => s.src)).toEqual([
'http://localhost:9876/base/test/assets/example.webp',
'http://localhost:9876/base/test/assets/example.png',
'http://localhost:9876/base/test/assets/example1.png'
]);
});
});
});