diff --git a/packages/core/src/api.js b/packages/core/src/api.js index 49abc1f29..47544f445 100644 --- a/packages/core/src/api.js +++ b/packages/core/src/api.js @@ -62,6 +62,12 @@ export function createPercyServer(percy, port) { build: percy.testing?.build ?? percy.build, loglevel: percy.loglevel(), config: percy.config, + widths: { + // This is always needed even if width is passed + mobile: percy.deviceDetails ? percy.deviceDetails.map((d) => d.width) : [], + // This will only be used if width is not passed in options + config: percy.config.snapshot.widths + }, success: true, type: percy.client.tokenType() })) diff --git a/packages/core/src/config.js b/packages/core/src/config.js index 017e042cd..68d090244 100644 --- a/packages/core/src/config.js +++ b/packages/core/src/config.js @@ -78,6 +78,10 @@ export const configSchema = { sync: { type: 'boolean' }, + responsiveSnapshotCapture: { + type: 'boolean', + default: false + }, testCase: { type: 'string' }, @@ -291,6 +295,7 @@ export const snapshotSchema = { domTransformation: { $ref: '/config/snapshot#/properties/domTransformation' }, enableLayout: { $ref: '/config/snapshot#/properties/enableLayout' }, sync: { $ref: '/config/snapshot#/properties/sync' }, + responsiveSnapshotCapture: { $ref: '/config/snapshot#/properties/responsiveSnapshotCapture' }, testCase: { $ref: '/config/snapshot#/properties/testCase' }, labels: { $ref: '/config/snapshot#/properties/labels' }, thTestCaseExecutionId: { $ref: '/config/snapshot#/properties/thTestCaseExecutionId' }, @@ -455,6 +460,7 @@ export const snapshotSchema = { items: { type: 'string' } }, cookies: { type: 'string' }, + width: { $ref: '/config/snapshot#/properties/widths/items' }, resources: { type: 'array', items: { @@ -473,7 +479,9 @@ export const snapshotSchema = { items: { type: 'string' } } } - }] + }, + { type: 'array', items: { $ref: '/snapshot#/$defs/dom/properties/domSnapshot/oneOf/1' } } + ] } }, errors: { diff --git a/packages/core/src/discovery.js b/packages/core/src/discovery.js index 743d90e44..d03d89200 100644 --- a/packages/core/src/discovery.js +++ b/packages/core/src/discovery.js @@ -88,13 +88,29 @@ function waitForDiscoveryNetworkIdle(page, options) { // Creates an initial resource map for a snapshot containing serialized DOM function parseDomResources({ url, domSnapshot }) { - if (!domSnapshot) return new Map(); - let isHTML = typeof domSnapshot === 'string'; - let { html, resources = [] } = isHTML ? { html: domSnapshot } : domSnapshot; - let rootResource = createRootResource(url, html); + const map = new Map(); + if (!domSnapshot) return map; + let allRootResources = new Set(); + let allResources = new Set(); + + if (!Array.isArray(domSnapshot)) { + domSnapshot = [domSnapshot]; + } + + for (let dom of domSnapshot) { + let isHTML = typeof dom === 'string'; + let { html, resources = [] } = isHTML ? { html: dom } : dom; + resources.forEach(r => allResources.add(r)); + const attrs = dom.width ? { widths: [dom.width] } : {}; + let rootResource = createRootResource(url, html, attrs); + allRootResources.add(rootResource); + } + allRootResources = Array.from(allRootResources); + map.set(allRootResources[0].url, allRootResources); + allResources = Array.from(allResources); // reduce the array of resources into a keyed map - return resources.reduce((map, { url, content, mimetype }) => { + return allResources.reduce((map, { url, content, mimetype }) => { // serialized resource contents are base64 encoded content = Buffer.from(content, mimetype.includes('text') ? 'utf8' : 'base64'); // specify the resource as provided to prevent overwriting during asset discovery @@ -102,7 +118,21 @@ function parseDomResources({ url, domSnapshot }) { // key the resource by its url and return the map return map.set(resource.url, resource); // the initial map is created with at least a root resource - }, new Map([[rootResource.url, rootResource]])); + }, map); +} + +function createAndApplyPercyCSS({ percyCSS, roots }) { + let css = createPercyCSSResource(roots[0].url, percyCSS); + + // replace root contents and associated properties + roots.forEach(root => { + Object.assign(root, createRootResource(root.url, ( + root.content.replace(/(<\/body>)(?!.*\1)/is, ( + `` + ) + '$&')))); + }); + + return css; } // Calls the provided callback with additional resources @@ -111,14 +141,14 @@ function processSnapshotResources({ domSnapshot, resources, ...snapshot }) { resources = [...(resources?.values() ?? [])]; // find any root resource matching the provided dom snapshot - let rootContent = domSnapshot?.html ?? domSnapshot; - let root = resources.find(r => r.content === rootContent); + // since root resources are stored as array + let roots = resources.find(r => Array.isArray(r)); // initialize root resources if needed - if (!root) { + if (!roots) { let domResources = parseDomResources({ ...snapshot, domSnapshot }); resources = [...domResources.values(), ...resources]; - root = resources[0]; + roots = resources.find(r => Array.isArray(r)); } // inject Percy CSS @@ -129,16 +159,13 @@ function processSnapshotResources({ domSnapshot, resources, ...snapshot }) { log.warn('DOM elements found outside , percyCSS might not work'); } - let css = createPercyCSSResource(root.url, snapshot.percyCSS); - resources.push(css); - - // replace root contents and associated properties - Object.assign(root, createRootResource(root.url, ( - root.content.replace(/(<\/body>)(?!.*\1)/is, ( - `` - ) + '$&')))); + const percyCSSReource = createAndApplyPercyCSS({ percyCSS: snapshot.percyCSS, roots }); + resources.push(percyCSSReource); } + // For multi dom root resources are stored as array + resources = resources.flat(); + // include associated snapshot logs matched by meta information resources.push(createLogResource(logger.query(log => ( log.meta.snapshot?.testCase === snapshot.meta.snapshot.testCase && log.meta.snapshot?.name === snapshot.meta.snapshot.name @@ -195,16 +222,19 @@ async function* captureSnapshotResources(page, snapshot, options) { }; // used to resize the using capture options - let resizePage = width => page.resize({ - height: snapshot.minHeight, - deviceScaleFactor, - mobile, - width - }); + let resizePage = width => { + page.network.intercept.currentWidth = width; + return page.resize({ + height: snapshot.minHeight, + deviceScaleFactor, + mobile, + width + }); + }; // navigate to the url yield resizePage(snapshot.widths[0]); - yield page.goto(snapshot.url, { cookies }); + yield page.goto(snapshot.url, { cookies, forceReload: discovery.captureResponsiveAssetsEnabled }); // wait for any specified timeout if (snapshot.discovery.waitForTimeout && page.enableJavaScript) { @@ -228,7 +258,8 @@ async function* captureSnapshotResources(page, snapshot, options) { // 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) { + // Don't run incase of responsiveSnapshotCapture since we are running discovery for all widths so images will get captured in all required widths + if (!snapshot.responsiveSnapshotCapture && discovery.captureSrcset) { await page.insertPercyDom(); yield page.eval('window.PercyDOM.loadAllSrcsetLinks()'); } @@ -247,6 +278,7 @@ async function* captureSnapshotResources(page, snapshot, options) { yield page.evaluate(execute?.beforeResize); yield waitForDiscoveryNetworkIdle(page, discovery); yield resizePage(width = widths[i + 1]); + if (snapshot.responsiveSnapshotCapture) { yield page.goto(snapshot.url, { cookies, forceReload: true }); } yield page.evaluate(execute?.afterResize); } } @@ -365,8 +397,15 @@ export function createDiscoveryQueue(percy) { disableCache: snapshot.discovery.disableCache, allowedHostnames: snapshot.discovery.allowedHostnames, disallowedHostnames: snapshot.discovery.disallowedHostnames, - getResource: u => snapshot.resources.get(u) || cache.get(u), - saveResource: r => { snapshot.resources.set(r.url, r); if (!r.root) { cache.set(r.url, r); } } + getResource: (u, width = null) => { + let resource = snapshot.resources.get(u) || cache.get(u); + if (resource && Array.isArray(resource) && resource[0].root) { + const rootResource = resource.find(r => r.widths?.includes(width)); + resource = rootResource || resource[0]; + } + return resource; + }, + saveResource: r => { snapshot.resources.set(r.url, r); cache.set(r.url, r); } } }); diff --git a/packages/core/src/network.js b/packages/core/src/network.js index 711518fd7..0c81fa4b1 100644 --- a/packages/core/src/network.js +++ b/packages/core/src/network.js @@ -354,7 +354,7 @@ async function sendResponseResource(network, request, session) { let send = (method, params) => network.send(session, method, params); try { - let resource = network.intercept.getResource(url); + let resource = network.intercept.getResource(url, network.intercept.currentWidth); network.log.debug(`Handling request: ${url}`, meta); if (!resource?.root && hostnameMatches(disallowedHostnames, url)) { @@ -495,7 +495,7 @@ async function saveResponseResource(network, request) { } } - if (resource) { + if (resource && !resource.root) { network.intercept.saveResource(resource); } } diff --git a/packages/core/src/page.js b/packages/core/src/page.js index 80a936e31..8f778d7be 100644 --- a/packages/core/src/page.js +++ b/packages/core/src/page.js @@ -68,9 +68,14 @@ export class Page { } // Go to a URL and wait for navigation to occur - async goto(url, { waitUntil = 'load', cookies } = {}) { + async goto(url, { waitUntil = 'load', cookies, forceReload } = {}) { this.log.debug(`Navigate to: ${url}`, this.meta); + if (forceReload) { + this.log.debug('Navigating to blank page', this.meta); + await this.session.send('Page.navigate', { url: 'about:blank' }); + } + let navigate = async () => { const userPassedCookie = this.session.browser.cookies; // set cookies before navigation so we can default the domain to this hostname diff --git a/packages/core/src/utils.js b/packages/core/src/utils.js index 163fe7702..cac47e84f 100644 --- a/packages/core/src/utils.js +++ b/packages/core/src/utils.js @@ -113,8 +113,8 @@ export function createResource(url, content, mimetype, attrs) { // Creates a root resource object with an additional `root: true` property. The URL is normalized // here as a convenience since root resources are usually created outside of asset discovery. -export function createRootResource(url, content) { - return createResource(normalizeURL(url), content, 'text/html', { root: true }); +export function createRootResource(url, content, attrs = {}) { + return createResource(normalizeURL(url), content, 'text/html', { ...attrs, root: true }); } // Creates a Percy CSS resource object. diff --git a/packages/core/test/api.test.js b/packages/core/test/api.test.js index fa6c1c789..26c476625 100644 --- a/packages/core/test/api.test.js +++ b/packages/core/test/api.test.js @@ -51,6 +51,7 @@ describe('API Server', () => { success: true, loglevel: 'info', config: PercyConfig.getDefaults(), + widths: { mobile: [], config: PercyConfig.getDefaults().snapshot.widths }, build: { id: '123', number: 1, @@ -69,6 +70,19 @@ describe('API Server', () => { }); }); + it('should return widths present in config and fetch widths for devices', async () => { + await percy.start(); + percy.deviceDetails = [{ width: 390, devicePixelRatio: 2 }]; + percy.config = PercyConfig.getDefaults({ snapshot: { widths: [1000] } }); + + await expectAsync(request('/percy/healthcheck')).toBeResolvedTo(jasmine.objectContaining({ + widths: { + mobile: [390], + config: [1000] + } + })); + }); + it('can set config options via the /config endpoint', async () => { let expected = PercyConfig.getDefaults({ snapshot: { widths: [1000] } }); await percy.start(); diff --git a/packages/core/test/discovery.test.js b/packages/core/test/discovery.test.js index d4e2ca9d8..dfcd3b460 100644 --- a/packages/core/test/discovery.test.js +++ b/packages/core/test/discovery.test.js @@ -2432,7 +2432,7 @@ describe('Discovery', () => { describe('waitForSelector/waitForTimeout at the time of discovery when Js is enabled =>', () => { it('calls waitForTimeout, waitForSelector and page.eval when their respective arguments are given', async () => { - const page = await percy.browser.page(); + const page = await percy.browser.page({ intercept: { getResource: () => {} } }); spyOn(percy.browser, 'page').and.returnValue(page); spyOn(page, 'eval').and.callThrough(); percy.loglevel('debug'); @@ -2885,4 +2885,124 @@ describe('Discovery', () => { })); }); }); + + describe('Handles multiple root resources', () => { + it('gathers resources for a snapshot', async () => { + let DOM1 = testDOM.replace('Percy!', 'Percy! at 370'); + let DOM2 = testDOM.replace('Percy!', 'Percy! at 765'); + const capturedResource = { + url: 'http://localhost:8000/img-already-captured.png', + content: 'R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', + mimetype: 'image/png' + }; + + await percy.snapshot({ + name: 'test snapshot', + url: 'http://localhost:8000', + responsiveSnapshotCapture: true, + widths: [365, 1280], + domSnapshot: [{ + html: testDOM, + width: 1280 + }, { + html: DOM1, + resources: [capturedResource], + width: 370 + }, { + html: DOM2, + width: 765 + }] + }); + + await percy.idle(); + + let paths = server.requests.map(r => r[0]); + // does not request the root url (serves domSnapshot instead) + expect(paths).not.toContain('/'); + expect(paths).toContain('/style.css'); + expect(paths).toContain('/img.gif'); + + expect(captured[0]).toEqual(jasmine.arrayContaining([ + jasmine.objectContaining({ + id: sha256hash(testDOM), + attributes: jasmine.objectContaining({ + 'resource-url': 'http://localhost:8000/', + 'is-root': true, + 'for-widths': [1280] + }) + }), + jasmine.objectContaining({ + id: sha256hash(DOM1), + attributes: jasmine.objectContaining({ + 'resource-url': 'http://localhost:8000/', + 'is-root': true, + 'for-widths': [370] + }) + }), + jasmine.objectContaining({ + id: sha256hash(DOM2), + attributes: jasmine.objectContaining({ + 'resource-url': 'http://localhost:8000/', + 'is-root': true, + 'for-widths': [765] + }) + }) + ])); + }); + + it('injects the percy-css resource into all dom snapshots', async () => { + const simpleDOM = dedent` + + + +

Hello Percy!

+ + + `; + let DOM1 = simpleDOM.replace('Percy!', 'Percy! at 370'); + + await percy.snapshot({ + name: 'test snapshot', + url: 'http://localhost:8000', + responsiveSnapshotCapture: true, + percyCSS: 'body { color: purple; }', + domSnapshot: [{ + html: simpleDOM, + width: 1280 + }, { + html: DOM1, + width: 370 + }] + }); + + await percy.idle(); + + let cssURL = new URL((api.requests['/builds/123/snapshots'][0].body.data.relationships.resources.data).find(r => r.attributes['resource-url'].endsWith('.css')).attributes['resource-url']); + let injectedDOM = simpleDOM.replace('', ( + `` + ) + ''); + let injectedDOM1 = DOM1.replace('', ( + `` + ) + ''); + + expect(captured[0]).toEqual(jasmine.arrayContaining([ + jasmine.objectContaining({ + id: sha256hash(injectedDOM), + attributes: jasmine.objectContaining({ + 'resource-url': 'http://localhost:8000/', + 'is-root': true, + 'for-widths': [1280] + }) + }), + jasmine.objectContaining({ + id: sha256hash(injectedDOM1), + attributes: jasmine.objectContaining({ + 'resource-url': 'http://localhost:8000/', + 'is-root': true, + 'for-widths': [370] + }) + }) + ])); + }); + }); }); diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js index 4228e73c1..07767c198 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -74,7 +74,8 @@ describe('Percy', () => { percyCSS: '', enableJavaScript: false, disableShadowDOM: false, - cliEnableJavaScript: true + cliEnableJavaScript: true, + responsiveSnapshotCapture: false }); });