diff --git a/packages/core/src/api.js b/packages/core/src/api.js index 49abc1f29..dd9a3e3ed 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() })) @@ -187,6 +193,11 @@ export function createPercyServer(percy, port) { } else if (cmd === 'version') { // the version command will update the api version header for testing percy.testing.version = body; + } else if (cmd === 'config') { + percy.config.snapshot.widths = body.config; + percy.deviceDetails = body.mobile?.map((w) => { return { width: w }; }); + percy.config.snapshot.responsiveSnapshotCapture = !!body.responsive; + percy.config.percy.deferUploads = !!body.deferUploads; } else if (cmd === 'error' || cmd === 'disconnect') { // the error or disconnect commands will cause specific endpoints to fail (percy.testing.api ||= {})[body] = cmd; diff --git a/packages/core/src/config.js b/packages/core/src/config.js index 017e042cd..2162c690d 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' }, @@ -454,7 +459,9 @@ export const snapshotSchema = { type: 'array', items: { type: 'string' } }, - cookies: { type: 'string' }, + cookies: { oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] }, + userAgent: { type: 'string' }, + width: { $ref: '/config/snapshot#/properties/widths/items' }, resources: { type: 'array', items: { @@ -473,7 +480,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 b5a048341..70c3d1ada 100644 --- a/packages/core/src/discovery.js +++ b/packages/core/src/discovery.js @@ -69,6 +69,11 @@ function debugSnapshotOptions(snapshot) { debugProp(snapshot, 'clientInfo'); debugProp(snapshot, 'environmentInfo'); debugProp(snapshot, 'domSnapshot', Boolean); + if (Array.isArray(snapshot.domSnapshot)) { + debugProp(snapshot, 'domSnapshot.0.userAgent'); + } else { + debugProp(snapshot, 'domSnapshot.userAgent'); + } for (let added of (snapshot.additionalSnapshots || [])) { log.debug(`Additional snapshot: ${added.name}`, snapshot.meta); @@ -79,12 +84,18 @@ function debugSnapshotOptions(snapshot) { } // parse browser cookies in correct format if flag is enabled -// it assumes that cookiesStr is string returned by document.cookie -function parseCookies(cookiesStr) { - if ( - process.env.PERCY_DO_NOT_USE_CAPTURED_COOKIES === 'true' || - !(typeof cookiesStr === 'string' && cookiesStr !== '') - ) return null; +function parseCookies(cookies) { + if (process.env.PERCY_DO_NOT_USE_CAPTURED_COOKIES === 'true') return null; + + // If cookies is collected via SDK + if (Array.isArray(cookies) && cookies.every(item => typeof item === 'object' && 'name' in item && 'value' in item)) { + // omit other fields reason sometimes expiry comes as actual date where we expect it to be double + return cookies.map(c => ({ name: c.name, value: c.value, secure: c.secure })); + } + + if (!(typeof cookies === 'string' && cookies !== '')) return null; + // it assumes that cookiesStr is string returned by document.cookie + const cookiesStr = cookies; return cookiesStr.split('; ').map(c => { const eqIdx = c.indexOf('='); @@ -109,13 +120,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 @@ -123,7 +150,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 @@ -132,14 +173,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 @@ -150,16 +191,13 @@ function processSnapshotResources({ domSnapshot, resources, ...snapshot }) { log.warn('DOM elements found outside
+Hello Percy!
+
, 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 @@ -181,7 +219,8 @@ async function* captureSnapshotResources(page, snapshot, options) { const log = logger('core:discovery'); let { discovery, additionalSnapshots = [], ...baseSnapshot } = snapshot; let { capture, captureWidths, deviceScaleFactor, mobile, captureForDevices } = options; - let cookies = parseCookies(snapshot?.domSnapshot?.cookies); + let cookies = snapshot?.domSnapshot?.cookies || snapshot?.domSnapshot?.[0]?.cookies; + cookies = parseCookies(cookies); // iterate over device to trigger reqeusts and capture other dpr width async function* captureResponsiveAssets() { @@ -209,16 +248,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) { @@ -242,7 +284,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()'); } @@ -261,6 +304,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); } } @@ -379,8 +423,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 e7a4e859a..80de7a919 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)) { @@ -499,7 +499,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..7d4f53ac9 100644 --- a/packages/core/src/page.js +++ b/packages/core/src/page.js @@ -68,13 +68,18 @@ 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, skipCookies = false } = {}) { this.log.debug(`Navigate to: ${url}`, this.meta); + if (forceReload) { + this.log.debug('Navigating to blank page', this.meta); + await this.goto('about:blank', { skipCookies: true }); + } + let navigate = async () => { const userPassedCookie = this.session.browser.cookies; // set cookies before navigation so we can default the domain to this hostname - if (userPassedCookie.length || cookies) { + if (!skipCookies && (userPassedCookie.length || cookies)) { let defaultDomain = hostname(url); cookies = this.mergeCookies(userPassedCookie, cookies); 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..7cc581a3e 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(); @@ -717,6 +731,24 @@ describe('API Server', () => { expect(headers['x-percy-core-version']).toEqual('0.0.1'); }); + it('can manipulate the config widths via /test/api/config', async () => { + let { widths, config } = await get('/percy/healthcheck'); + expect(widths.config).toEqual([375, 1280]); + expect(widths.mobile).toEqual([]); + + await post('/test/api/config', { config: [390], deferUploads: true }); + ({ widths, config } = await get('/percy/healthcheck')); + expect(widths.config).toEqual([390]); + expect(config.snapshot.responsiveSnapshotCapture).toEqual(false); + expect(config.percy.deferUploads).toEqual(true); + + await post('/test/api/config', { config: [375, 1280], mobile: [456], responsive: true }); + ({ widths, config } = await get('/percy/healthcheck')); + expect(widths.mobile).toEqual([456]); + expect(config.snapshot.responsiveSnapshotCapture).toEqual(true); + expect(config.percy.deferUploads).toEqual(false); + }); + it('can make endpoints return server errors via /test/api/error', async () => { let { statusCode } = await req('/percy/healthcheck'); expect(statusCode).toEqual(200); diff --git a/packages/core/test/discovery.test.js b/packages/core/test/discovery.test.js index 7872552fa..273a1f359 100644 --- a/packages/core/test/discovery.test.js +++ b/packages/core/test/discovery.test.js @@ -1378,6 +1378,56 @@ describe('Discovery', () => { expect(cookie).toEqual('__Secure-test-cookie=value'); }); + it('can send default collected cookies from sdk', async () => { + await percy.stop(); + + percy = await Percy.start({ + token: 'PERCY_TOKEN', + snapshot: { widths: [1000] }, + discovery: { concurrency: 1 } + }); + + await percy.snapshot({ + name: 'mmm cookies', + url: 'http://localhost:8000', + domSnapshot: { + html: testDOM, + cookies: [{ name: 'cookie-via-sdk', value: 'cookie-value' }] + } + }); + + expect(logger.stdout).toEqual(jasmine.arrayContaining([ + '[percy] Snapshot taken: mmm cookies' + ])); + + expect(cookie).toEqual('cookie-via-sdk=cookie-value'); + }); + + it('does not use cookies if wrong object is passed from sdk', async () => { + await percy.stop(); + + percy = await Percy.start({ + token: 'PERCY_TOKEN', + snapshot: { widths: [1000] }, + discovery: { concurrency: 1 } + }); + + await percy.snapshot({ + name: 'mmm cookies', + url: 'http://localhost:8000', + domSnapshot: { + html: testDOM, + cookies: [{ wrong_object_key: 'cookie-via-sdk', wrong_object_value: 'cookie-value' }] + } + }); + + expect(logger.stdout).toEqual(jasmine.arrayContaining([ + '[percy] Snapshot taken: mmm cookies' + ])); + + expect(cookie).toEqual(undefined); + }); + it('does not use cookie if empty cookies is passed (in case of httponly)', async () => { await percy.stop(); @@ -2432,7 +2482,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 +2935,142 @@ describe('Discovery', () => { })); }); }); + + describe('Handles multiple root resources', () => { + it('gathers multiple 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' + }; + + const capturedResource1 = { + url: 'http://localhost:8000/img-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, + cookies: [{ name: 'value' }] + }, { + html: DOM1, + resources: [capturedResource], + width: 370 + }, { + html: DOM2, + resources: [capturedResource1], + 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] + }) + }), + jasmine.objectContaining({ + attributes: jasmine.objectContaining({ + 'resource-url': 'http://localhost:8000/img-already-captured.png' + }) + }), + jasmine.objectContaining({ + attributes: jasmine.objectContaining({ + 'resource-url': 'http://localhost:8000/img-captured.png' + }) + }) + ])); + }); + + it('injects the percy-css resource into all dom snapshots', async () => { + const simpleDOM = dedent` + +
+
+