diff --git a/packages/core/src/config.js b/packages/core/src/config.js index 51af87a16..ce1d5bef0 100644 --- a/packages/core/src/config.js +++ b/packages/core/src/config.js @@ -53,6 +53,9 @@ export const configSchema = { domTransformation: { type: 'string' }, + reshuffleInvalidTags: { + type: 'boolean' + }, scope: { type: 'string' }, @@ -236,6 +239,7 @@ export const snapshotSchema = { disableShadowDOM: { $ref: '/config/snapshot#/properties/disableShadowDOM' }, domTransformation: { $ref: '/config/snapshot#/properties/domTransformation' }, enableLayout: { $ref: '/config/snapshot#/properties/enableLayout' }, + reshuffleInvalidTags: { $ref: '/config/snapshot#/properties/reshuffleInvalidTags' }, discovery: { type: 'object', additionalProperties: false, @@ -398,6 +402,10 @@ export const snapshotSchema = { mimetype: { type: 'string' } } } + }, + hints: { + type: 'array', + items: { type: 'string' } } } }] diff --git a/packages/core/src/discovery.js b/packages/core/src/discovery.js index 0ca3cecda..b0e4ca885 100644 --- a/packages/core/src/discovery.js +++ b/packages/core/src/discovery.js @@ -37,6 +37,8 @@ function debugSnapshotOptions(snapshot) { debugProp(snapshot, 'cliEnableJavaScript'); debugProp(snapshot, 'disableShadowDOM'); debugProp(snapshot, 'enableLayout'); + debugProp(snapshot, 'domTransformation'); + debugProp(snapshot, 'reshuffleInvalidTags'); debugProp(snapshot, 'deviceScaleFactor'); debugProp(snapshot, 'waitForTimeout'); debugProp(snapshot, 'waitForSelector'); @@ -92,6 +94,7 @@ function parseDomResources({ url, domSnapshot }) { // Calls the provided callback with additional resources function processSnapshotResources({ domSnapshot, resources, ...snapshot }) { + let log = logger('core:snapshot'); resources = [...(resources?.values() ?? [])]; // find any root resource matching the provided dom snapshot @@ -107,6 +110,12 @@ function processSnapshotResources({ domSnapshot, resources, ...snapshot }) { // inject Percy CSS if (snapshot.percyCSS) { + // check @percy/dom/serialize-dom.js + let domSnapshotHints = domSnapshot?.hints ?? []; + if (domSnapshotHints.includes('DOM elements found outside ')) { + log.warn('DOM elements found outside , percyCSS might not work'); + } + let css = createPercyCSSResource(root.url, snapshot.percyCSS); resources.push(css); diff --git a/packages/core/src/page.js b/packages/core/src/page.js index 0dc759251..fbed6ea83 100644 --- a/packages/core/src/page.js +++ b/packages/core/src/page.js @@ -141,7 +141,7 @@ export class Page { execute, ...snapshot }) { - let { name, width, enableJavaScript, disableShadowDOM, domTransformation } = snapshot; + let { name, width, enableJavaScript, disableShadowDOM, domTransformation, reshuffleInvalidTags } = snapshot; this.log.debug(`Taking snapshot: ${name}${width ? ` @${width}px` : ''}`, this.meta); // wait for any specified timeout @@ -182,7 +182,7 @@ export class Page { /* eslint-disable-next-line no-undef */ domSnapshot: PercyDOM.serialize(options), url: document.URL - }), { enableJavaScript, disableShadowDOM, domTransformation }); + }), { enableJavaScript, disableShadowDOM, domTransformation, reshuffleInvalidTags }); return { ...snapshot, ...capture }; } diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js index 8f930a3d0..96000e838 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -90,7 +90,7 @@ describe('Percy', () => { }); // expect required arguments are passed to PercyDOM.serialize - expect(evalSpy.calls.allArgs()[3]).toEqual(jasmine.arrayContaining([jasmine.anything(), { enableJavaScript: undefined, disableShadowDOM: true, domTransformation: undefined }])); + expect(evalSpy.calls.allArgs()[3]).toEqual(jasmine.arrayContaining([jasmine.anything(), { enableJavaScript: undefined, disableShadowDOM: true, domTransformation: undefined, reshuffleInvalidTags: undefined }])); expect(snapshot.url).toEqual('http://localhost:8000/'); expect(snapshot.domSnapshot).toEqual(jasmine.objectContaining({ diff --git a/packages/core/test/snapshot.test.js b/packages/core/test/snapshot.test.js index 283b9bcca..772fb4564 100644 --- a/packages/core/test/snapshot.test.js +++ b/packages/core/test/snapshot.test.js @@ -659,7 +659,8 @@ describe('Snapshot', () => { domSnapshot: { html: ``, warnings: ['Test serialize warning'], - resources: [resource, textResource] + resources: [resource, textResource], + hints: ['DOM elements found outside '] } }); @@ -1301,5 +1302,26 @@ describe('Snapshot', () => { expect(root.id).toEqual(sha256hash(injectedDOM)); expect(root.attributes).toHaveProperty('base64-content', base64encode(injectedDOM)); }); + + it('warns when domSnapshot hints of invalid tags', async () => { + await percy.snapshot({ + name: 'Serialized Snapshot', + url: 'http://localhost:8000', + domSnapshot: { + html: '', + warnings: [], + resources: [], + hints: ['DOM elements found outside '] + } + + }); + + expect(logger.stderr).toEqual([ + '[percy] DOM elements found outside , percyCSS might not work' + ]); + expect(logger.stdout).toEqual([ + '[percy] Snapshot taken: Serialized Snapshot' + ]); + }); }); }); diff --git a/packages/core/types/index.d.ts b/packages/core/types/index.d.ts index 4055b1d7b..79b170316 100644 --- a/packages/core/types/index.d.ts +++ b/packages/core/types/index.d.ts @@ -40,6 +40,8 @@ interface CommonSnapshotOptions { enableJavaScript?: boolean; disableShadowDOM?: boolean; enableLayout?: boolean; + domTransformation?: string; + reshuffleInvalidTags?: boolean; devicePixelRatio?: number; scope?: string; } diff --git a/packages/dom/README.md b/packages/dom/README.md index fcbd2788f..04154bf58 100644 --- a/packages/dom/README.md +++ b/packages/dom/README.md @@ -37,7 +37,8 @@ const domSnapshot = await page.evaluate(() => PercyDOM.serialize(options)) - `enableJavaScript` — When true, does not serialize some DOM elements - `domTransformation` — Function to transform the DOM after serialization -- `disableShadowDOM` — disable shadow DOM capturing, this option can be passed to `percySnapshot` its part of per-snapshot config. +- `disableShadowDOM` — disable shadow DOM capturing, usually to be used when `enableJavascript: true` +- `reshuffleInvalidTags` — moves DOM tags which are outside `` to its inside to make the DOM compliant. ## Serialized Content diff --git a/packages/dom/src/serialize-dom.js b/packages/dom/src/serialize-dom.js index a1b902c84..6840f9903 100644 --- a/packages/dom/src/serialize-dom.js +++ b/packages/dom/src/serialize-dom.js @@ -64,13 +64,15 @@ export function serializeDOM(options) { enableJavaScript = options?.enable_javascript, domTransformation = options?.dom_transformation, stringifyResponse = options?.stringify_response, - disableShadowDOM = options?.disable_shadow_dom + disableShadowDOM = options?.disable_shadow_dom, + reshuffleInvalidTags = options?.reshuffle_invalid_tags } = options || {}; // keep certain records throughout serialization let ctx = { resources: new Set(), warnings: new Set(), + hints: new Set(), cache: new Map(), enableJavaScript, disableShadowDOM @@ -94,11 +96,21 @@ export function serializeDOM(options) { } if (!disableShadowDOM) { injectDeclarativeShadowDOMPolyfill(ctx); } + if (reshuffleInvalidTags) { + let clonedBody = ctx.clone.body; + while (clonedBody.nextSibling) { + let sibling = clonedBody.nextSibling; + clonedBody.append(sibling); + } + } else if (ctx.clone.body.nextSibling) { + ctx.hints.add('DOM elements found outside '); + } let result = { html: serializeHTML(ctx), warnings: Array.from(ctx.warnings), - resources: Array.from(ctx.resources) + resources: Array.from(ctx.resources), + hints: Array.from(ctx.hints) }; return stringifyResponse diff --git a/packages/dom/test/helpers.js b/packages/dom/test/helpers.js index 2877d3404..84938e170 100644 --- a/packages/dom/test/helpers.js +++ b/packages/dom/test/helpers.js @@ -3,7 +3,7 @@ export const chromeBrowser = 'CHROME'; export const firefoxBrowser = 'FIREFOX'; // create and cleanup testing DOM -export function withExample(html, options = { withShadow: true }) { +export function withExample(html, options = { withShadow: true, invalidTagsOutsideBody: false }) { let $test = document.getElementById('test'); if ($test) $test.remove(); @@ -16,7 +16,7 @@ export function withExample(html, options = { withShadow: true }) { document.body.appendChild($test); - if (options?.withShadow) { + if (options.withShadow) { $testShadow = document.createElement('div'); $testShadow.id = 'test-shadow'; let $shadow = $testShadow.attachShadow({ mode: 'open' }); @@ -24,6 +24,15 @@ export function withExample(html, options = { withShadow: true }) { document.body.appendChild($testShadow); } + + if (options.invalidTagsOutsideBody) { + let p = document.getElementById('invalid-p'); + p?.remove(); + p = document.createElement('p'); + p.id = 'invalid-p'; + p.innerText = 'P tag outside body'; + document.documentElement.append(p); + } return document; } diff --git a/packages/dom/test/serialize-dom.test.js b/packages/dom/test/serialize-dom.test.js index 1da2d53ee..5fcecfe6f 100644 --- a/packages/dom/test/serialize-dom.test.js +++ b/packages/dom/test/serialize-dom.test.js @@ -6,7 +6,8 @@ describe('serializeDOM', () => { expect(serializeDOM()).toEqual({ html: jasmine.any(String), warnings: jasmine.any(Array), - resources: jasmine.any(Array) + resources: jasmine.any(Array), + hints: jasmine.any(Array) }); }); @@ -27,7 +28,7 @@ describe('serializeDOM', () => { it('optionally returns a stringified response', () => { expect(serializeDOM({ stringifyResponse: true })) - .toMatch('{"html":".*","warnings":\\[\\],"resources":\\[\\]}'); + .toMatch('{"html":".*","warnings":\\[\\],"resources":\\[\\],"hints":\\[\\]}'); }); it('always has a doctype', () => { @@ -68,7 +69,7 @@ describe('serializeDOM', () => { expect($('h2.callback').length).toEqual(1); }); - it('applies dom transformations', () => { + it('applies default dom transformations', () => { withExample('