From 8a9e41bcfb7f457ccb32d7223becc0623e41d809 Mon Sep 17 00:00:00 2001
From: chinmay-browserstack
<92926953+chinmay-browserstack@users.noreply.github.com>
Date: Mon, 30 Sep 2024 19:48:40 +0530
Subject: [PATCH] [Defer Uploads v2] Added support for handling multiple root
resource in single asset discovery phase (#1728)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* [Defer Uploads v2] Added support for handling multiple root resource in single asset discovery phase
* update domSnapshot structure in config + reload page in case on multidom
* Fix test
* Added tests for widths endpoint + multiple root resources
* add multi dom case
* Added case for percyCSS with multiple root resource
* Remove extra code and improve coverage
* Fix coverage
* Coverage resolved
* Addressed comments
* Minor improvement
* Addressed comments
* wait for page navigation while force reloading
* Updated test to have different resource in different doms
* Add logging for test browser userAgent
* Fix tests
* Change userAgent condition for responsiveSnapshotCapture
* Added test endpoint for changing widths
* Added waitForResize script
* Add flag for changing config in test api
* Added tests for waitForResize
* Added defer uploads option in test config endpoint
* Responsive DOM SDK Utils (#1746)
* Fixed cookies parsing bug (#1727)
* v1.29.4-beta.3 (#1729)
* adding secure flag for cookies that starts with __Secure (#1730)
* releasing v1.29.4-beta.4 (#1731)
* updating path-to-regexp to 6.3.0 where the vulnerabilities are fixed (#1733)
* releasing v1.29.4-beta.5 (#1735)
* :arrow_up: Bump micromatch from 4.0.7 to 4.0.8 (#1706)
Bumps [micromatch](https://github.com/micromatch/micromatch) from 4.0.7 to 4.0.8.
- [Release notes](https://github.com/micromatch/micromatch/releases)
- [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/micromatch/compare/4.0.7...4.0.8)
---
updated-dependencies:
- dependency-name: micromatch
dependency-type: direct:production
...
Signed-off-by: dependabot[bot]
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` + +
+
+