diff --git a/.changeset/eighty-bikes-peel.md b/.changeset/eighty-bikes-peel.md new file mode 100644 index 00000000..021a1fc4 --- /dev/null +++ b/.changeset/eighty-bikes-peel.md @@ -0,0 +1,7 @@ +--- +'@chromatic-com/playwright': minor +'@chromatic-com/cypress': minor +'@chromatic-com/shared-e2e': minor +--- + +Picture tags are archived diff --git a/packages/cypress/tests/cypress/e2e/archiving-assets.cy.ts b/packages/cypress/tests/cypress/e2e/archiving-assets.cy.ts index caba4447..aed9d0c0 100644 --- a/packages/cypress/tests/cypress/e2e/archiving-assets.cy.ts +++ b/packages/cypress/tests/cypress/e2e/archiving-assets.cy.ts @@ -39,6 +39,42 @@ it('srcset is used to determine image asset URL', () => { cy.visit('/asset-paths/srcset'); }); +it('picture source is captured, multiple source elements', () => { + cy.visit('/asset-paths/picture'); +}); + +// TODO: Unskip when we use one-archive-per-test in Cypress (like we do in Playwright) +// currently, we use one archive for all the tests, but in this case it means we capture +// the wrong image (since the unmatching images are already in the global archive, we +// mistakenly assume they were "captured" by this test and use them instead of the fallback image) +context.skip('mobile', { viewportWidth: 500, viewportHeight: 500 }, () => { + it('picture source is captured, multiple source elements', () => { + cy.visit('/asset-paths/picture'); + }); +}); + +// TODO: Unskip when we use one-archive-per-test in Cypress (like we do in Playwright) +// currently, we use one archive for all the tests, but in this case it means we capture +// the wrong image (since the unmatching images are already in the global archive, we +// mistakenly assume they were "captured" by this test and use them instead of the fallback image) +it.skip('picture captures fallback image', () => { + cy.visit('/asset-paths/picture-no-matching-source'); +}); + +it('picture source is captured, single source with srcset', () => { + cy.visit('/asset-paths/picture-multiple-srcset'); +}); + +// TODO: Unskip when we use one-archive-per-test in Cypress (like we do in Playwright) +// currently, we use one archive for all the tests, but in this case it means we capture +// the wrong image (since the unmatching images are already in the global archive, we +// mistakenly assume they were "captured" by this test and use them instead of the fallback image) +context.skip('mobile', { viewportWidth: 500, viewportHeight: 500 }, () => { + it('picture source is captured, single source with srcset', () => { + cy.visit('/asset-paths/picture-multiple-srcset'); + }); +}); + it('external CSS files are inlined', () => { cy.visit('/asset-paths/external-css-files'); }); diff --git a/packages/playwright/tests/archiving-assets.spec.ts b/packages/playwright/tests/archiving-assets.spec.ts index aac4fe20..caddf03b 100644 --- a/packages/playwright/tests/archiving-assets.spec.ts +++ b/packages/playwright/tests/archiving-assets.spec.ts @@ -49,6 +49,18 @@ test('srcset is used to determine image asset URL', async ({ page }) => { await page.goto('/asset-paths/srcset'); }); +test('picture source is captured, multiple source elements', async ({ page }) => { + await page.goto('/asset-paths/picture'); +}); + +test('picture source is captured, single source with srcset', async ({ page }) => { + await page.goto('/asset-paths/picture-multiple-srcset'); +}); + +test('picture captures fallback image', async ({ page }) => { + await page.goto('/asset-paths/picture-no-matching-source'); +}); + test('external CSS files are inlined', async ({ page }) => { await page.goto('/asset-paths/external-css-files'); }); diff --git a/packages/shared/src/write-archive/dom-snapshot.test.ts b/packages/shared/src/write-archive/dom-snapshot.test.ts index 968531e9..0f45bf90 100644 --- a/packages/shared/src/write-archive/dom-snapshot.test.ts +++ b/packages/shared/src/write-archive/dom-snapshot.test.ts @@ -1,3 +1,4 @@ +import { serializedNodeWithId } from '@chromaui/rrweb-snapshot'; import { DOMSnapshot } from './dom-snapshot'; const relativeUrl = '/images/image.png'; @@ -9,10 +10,236 @@ function createSnapshot(url1: string, url2: string, url3: string) { return `{"type":0,"childNodes":[{"type":1,"name":"html","publicId":"","systemId":"","id":2},{"type":2,"tagName":"html","attributes":{},"childNodes":[{"type":2,"tagName":"head","attributes":{},"childNodes":[{"type":3,"textContent":" ","id":5},{"type":2,"tagName":"link","attributes":{"rel":"stylesheet","href":"/styles-test.css"},"childNodes":[],"id":6},{"type":3,"textContent":" ","id":7},{"type":2,"tagName":"style","attributes":{},"childNodes":[{"type":3,"textContent":".test1 { background-image: url(\\"${url1}\\"); }.test2 { background-image: url(\\"${url2}\\"); }.test2 { background-image: url(\\"${url3}\\"); }","isStyle":true,"id":9}],"id":8},{"type":3,"textContent":" ","id":10}],"id":4},{"type":3,"textContent":" ","id":11},{"type":2,"tagName":"body","attributes":{},"childNodes":[{"type":3,"textContent":" ","id":13},{"type":2,"tagName":"div","attributes":{"class":"image-container flex flex-wrap"},"childNodes":[{"type":3,"textContent":" ","id":15},{"type":2,"tagName":"img","attributes":{"src":"${url1}"},"childNodes":[],"id":16},{"type":3,"textContent":" ","id":17},{"type":2,"tagName":"img","attributes":{"src":"${url2}"},"childNodes":[],"id":18},{"type":3,"textContent":" ","id":19},{"type":2,"tagName":"img","attributes":{"src":"${url3}"},"childNodes":[],"id":20},{"type":3,"textContent":" ","id":21},{"type":2,"tagName":"div","attributes":{"style":"background: url('${url1}'); no-repeat center;"},"childNodes":[],"id":22},{"type":3,"textContent":" ","id":23},{"type":2,"tagName":"div","attributes":{"style":"background: url('${url2}'); no-repeat center;"},"childNodes":[],"id":24},{"type":3,"textContent":" ","id":25},{"type":2,"tagName":"div","attributes":{"style":"background: url('${url3}'); no-repeat center;"},"childNodes":[],"id":26},{"type":3,"textContent":" ","id":27}],"id":14},{"type":3,"textContent":" ","id":28}],"id":12}],"id":3}],"id":1}`; } -function createSrcsetSnapshot(url1: string, url2: string, url3: string) { - return `{"type":2,"tagName":"img","attributes":{"srcset":"${url2} 384w, ${url3} 1920w","sizes":"(min-width: 768px) 768px, 192px","src":"${url1}"},"childNodes":[],"id":61}`; +function createImgSrcsetSnapshot({ + backupUrl, + smallUrl, + largeUrl, +}: { + backupUrl: string; + smallUrl: string; + largeUrl: string; +}) { + return JSON.stringify({ + type: 2, + tagName: 'img', + attributes: { + srcset: `${smallUrl} 384w, ${largeUrl} 1920w`, + sizes: '(min-width: 768px) 768px, 192px', + src: backupUrl, + }, + childNodes: [], + id: 61, + }); +} + +function createPictureSrcsetSnapshotSingleSource({ + backupUrl, + matchingUrl, +}: { + backupUrl: string; + matchingUrl: string; +}) { + return JSON.stringify({ + type: 2, + tagName: 'picture', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'source', + attributes: { + srcset: matchingUrl, + }, + childNodes: [], + id: 63, + }, + { + type: 2, + tagName: 'img', + attributes: { + src: backupUrl, + }, + childNodes: [], + id: 61, + }, + ], + id: 62, + }); +} + +function createPictureSrcsetSnapshotSingleSourceImageHasAttributes({ + backupUrl, + matchingUrl, +}: { + backupUrl: string; + matchingUrl: string; +}) { + return JSON.stringify({ + type: 2, + tagName: 'picture', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'source', + attributes: { + srcset: matchingUrl, + }, + childNodes: [], + id: 63, + }, + { + type: 2, + tagName: 'img', + attributes: { + class: 'do-not-remove-me', + src: backupUrl, + }, + childNodes: [], + id: 61, + }, + ], + id: 62, + }); +} + +function createPictureSrcsetSnapshotSingleSourceMultipleSrcsets({ + backupUrl, + wrongUrl, + matchingUrl, +}: { + backupUrl: string; + wrongUrl: string; + matchingUrl: string; +}) { + return JSON.stringify({ + type: 2, + tagName: 'picture', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'source', + attributes: { + expectedMappedSnapshot, + srcset: `${wrongUrl} 2000w, ${matchingUrl} 900w`, + }, + childNodes: [], + id: 63, + }, + { + type: 2, + tagName: 'img', + attributes: { + src: backupUrl, + }, + childNodes: [], + id: 61, + }, + ], + id: 62, + }); +} + +function createPictureSrcsetSnapshotMultipleSources({ + backupUrl, + wrongUrl, + matchingUrl, +}: { + backupUrl: string; + wrongUrl: string; + matchingUrl: string; +}) { + return JSON.stringify({ + type: 2, + tagName: 'picture', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'source', + attributes: { + srcset: wrongUrl, + }, + childNodes: [], + id: 64, + }, + { + type: 2, + tagName: 'source', + attributes: { + srcset: matchingUrl, + }, + childNodes: [], + id: 63, + }, + { + type: 2, + tagName: 'img', + attributes: { + src: backupUrl, + }, + childNodes: [], + id: 61, + }, + ], + id: 62, + }); } +function createPictureSrcsetNoUrlMatches({ + wrongUrl, + alsoWrongUrl, +}: { + wrongUrl: string; + alsoWrongUrl: string; +}) { + return JSON.stringify({ + type: 2, + tagName: 'picture', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'source', + attributes: { + srcset: wrongUrl, + }, + childNodes: [], + id: 63, + }, + { + type: 2, + tagName: 'img', + attributes: { + src: alsoWrongUrl, + }, + childNodes: [], + id: 61, + }, + ], + id: 62, + }); +} + +const imageTag = { + type: 2, + tagName: 'img', + attributes: { + src: queryUrlTransformed, + }, + childNodes: [] as serializedNodeWithId[], + id: 61, +}; + +const pictureWithJustImageTag = { + type: 2, + tagName: 'picture', + attributes: {}, + childNodes: [imageTag], + id: 62, +}; + const snapshot = createSnapshot(relativeUrl, externalUrl, queryUrl); const expectedMappedSnapshot = createSnapshot(relativeUrl, externalUrl, queryUrlTransformed); @@ -38,22 +265,114 @@ describe('DOMSnapshot', () => { }); it('maps img srcsets', async () => { - const domSnapshot = new DOMSnapshot(createSrcsetSnapshot(relativeUrl, externalUrl, queryUrl)); + const domSnapshot = new DOMSnapshot( + createImgSrcsetSnapshot({ + backupUrl: relativeUrl, + smallUrl: externalUrl, + largeUrl: queryUrl, + }) + ); const mappedSnapshot = await domSnapshot.mapAssetPaths(sourceMap); - expect(mappedSnapshot).toEqual( - `{"type":2,"tagName":"img","attributes":{"src":"${queryUrlTransformed}"},"childNodes":[],"id":61}` - ); + expect(JSON.parse(mappedSnapshot)).toEqual({ + type: 2, + tagName: 'img', + attributes: { + src: `${queryUrlTransformed}`, + }, + childNodes: [], + id: 61, + }); }); it('does not change img srcsets when no mapped asset found in source map', async () => { - const origSnapshot = createSrcsetSnapshot(relativeUrl, externalUrl, queryUrl); - const domSnapshot = new DOMSnapshot(origSnapshot); + const originalSnapshot = createImgSrcsetSnapshot({ + backupUrl: relativeUrl, + smallUrl: externalUrl, + largeUrl: queryUrl, + }); + const domSnapshot = new DOMSnapshot(originalSnapshot); const mappedSnapshot = await domSnapshot.mapAssetPaths(new Map()); - expect(mappedSnapshot).toEqual(origSnapshot); + expect(mappedSnapshot).toEqual(originalSnapshot); + }); + + it('maps picture srcsets, single ', async () => { + const domSnapshot = new DOMSnapshot( + createPictureSrcsetSnapshotSingleSource({ backupUrl: relativeUrl, matchingUrl: queryUrl }) + ); + + const mappedSnapshot = await domSnapshot.mapAssetPaths(sourceMap); + + expect(JSON.parse(mappedSnapshot)).toEqual(pictureWithJustImageTag); + }); + + it('maps picture srcsets, multiple s', async () => { + const domSnapshot = new DOMSnapshot( + createPictureSrcsetSnapshotMultipleSources({ + backupUrl: relativeUrl, + wrongUrl: externalUrl, + matchingUrl: queryUrl, + }) + ); + + const mappedSnapshot = await domSnapshot.mapAssetPaths(sourceMap); + + expect(JSON.parse(mappedSnapshot)).toEqual(pictureWithJustImageTag); + }); + + it('maps picture srcsets, single with multiple srcset values', async () => { + const domSnapshot = new DOMSnapshot( + createPictureSrcsetSnapshotSingleSourceMultipleSrcsets({ + backupUrl: relativeUrl, + wrongUrl: externalUrl, + matchingUrl: queryUrl, + }) + ); + + const mappedSnapshot = await domSnapshot.mapAssetPaths(sourceMap); + + expect(JSON.parse(mappedSnapshot)).toEqual(pictureWithJustImageTag); + }); + + it('maps picture srcsets, and children left untouched if there is no URL match', async () => { + const originalSnapshot = createPictureSrcsetNoUrlMatches({ + wrongUrl: '/totally-bogus-url.png', + alsoWrongUrl: 'https://another-totally-bogus.com/url.png', + }); + const domSnapshot = new DOMSnapshot(originalSnapshot); + + const mappedSnapshot = await domSnapshot.mapAssetPaths(sourceMap); + + expect(JSON.parse(mappedSnapshot)).toEqual(JSON.parse(originalSnapshot)); + }); + + // important that we only blow away what we need to; since contents are styled by their tag, + // we don't want to get rid of any existing attributes (like class for example) + it('maps picture srcsets, preserves existing attributes', async () => { + const domSnapshot = new DOMSnapshot( + createPictureSrcsetSnapshotSingleSourceImageHasAttributes({ + backupUrl: relativeUrl, + matchingUrl: queryUrl, + }) + ); + + const mappedSnapshot = await domSnapshot.mapAssetPaths(sourceMap); + + expect(JSON.parse(mappedSnapshot)).toEqual({ + ...pictureWithJustImageTag, + childNodes: [ + { + ...imageTag, + attributes: { + ...imageTag.attributes, + class: 'do-not-remove-me', + }, + }, + ], + }); }); }); }); diff --git a/packages/shared/src/write-archive/dom-snapshot.ts b/packages/shared/src/write-archive/dom-snapshot.ts index 89b786de..6da13b9d 100644 --- a/packages/shared/src/write-archive/dom-snapshot.ts +++ b/packages/shared/src/write-archive/dom-snapshot.ts @@ -1,6 +1,6 @@ /* eslint-disable no-underscore-dangle */ /* eslint-disable no-param-reassign */ -import type { serializedNodeWithId } from '@chromaui/rrweb-snapshot'; +import type { serializedElementNodeWithId, serializedNodeWithId } from '@chromaui/rrweb-snapshot'; import { NodeType } from '@chromaui/rrweb-snapshot'; import srcset from 'srcset'; @@ -92,11 +92,56 @@ export class DOMSnapshot { delete node.attributes.sizes; } } + + if (node.tagName === 'picture') { + this.mapPictureElement(node, sourceMap); + } } return node; } + private mapPictureElement(node: serializedElementNodeWithId, sourceMap: Map) { + const allSourceUrls: string[] = node.childNodes + .filter(this.isSourceElement) + .map((childNode: serializedElementNodeWithId) => { + // there can be multiple values in a single srcset, extract all of them + const sourceSetValues = srcset.parse( + (childNode.attributes?.srcset as string | undefined) ?? '' + ); + return sourceSetValues.map((srcSetValue) => srcSetValue.url); + }) + // since srcsets can have multiple values, we will have a nested array here + .flat(); + + // we have all of the raw URLs. + const matchingUrl = allSourceUrls.find((sourceUrl) => { + // find a url in the asset map... by which we mean in the sourceMap + return sourceMap.has(sourceUrl); + }); + + // do any of my children match what is in there? + if (matchingUrl) { + // if so, blow away all tags + node.childNodes = node.childNodes.filter((childNode) => !this.isSourceElement(childNode)); + + // replace the tag's `src` with this asset-mapped URL + const imageElement = node.childNodes.find( + (childNode) => childNode.type === NodeType.Element && childNode.tagName === 'img' + ) as serializedElementNodeWithId; + if (imageElement && imageElement.attributes) { + // we're assuming that whatever was archived is an image URL + // this should be the case (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/source#srcset), + // but noting it here as it'a an assumption + imageElement.attributes.src = sourceMap.get(matchingUrl); + } + } + } + + private isSourceElement(childNode: serializedNodeWithId) { + return childNode.type === NodeType.Element && childNode.tagName === 'source'; + } + private mapTextElement(node: serializedNodeWithId, sourceMap: Map) { if (node.type === NodeType.Text && node.isStyle) { if (node.textContent) { diff --git a/test-server/fixtures/asset-paths/picture-multiple-srcset.html b/test-server/fixtures/asset-paths/picture-multiple-srcset.html new file mode 100644 index 00000000..ec6316e0 --- /dev/null +++ b/test-server/fixtures/asset-paths/picture-multiple-srcset.html @@ -0,0 +1,15 @@ + + + + + + + + + + + + +

blue < 768px <= pink

+ + diff --git a/test-server/fixtures/asset-paths/picture-no-matching-source.html b/test-server/fixtures/asset-paths/picture-no-matching-source.html new file mode 100644 index 00000000..50bbe308 --- /dev/null +++ b/test-server/fixtures/asset-paths/picture-no-matching-source.html @@ -0,0 +1,13 @@ + + + + + + + + + + +

Should show purple as it's the only compatible image

+ + diff --git a/test-server/fixtures/asset-paths/picture.html b/test-server/fixtures/asset-paths/picture.html new file mode 100644 index 00000000..09bfd1c9 --- /dev/null +++ b/test-server/fixtures/asset-paths/picture.html @@ -0,0 +1,14 @@ + + + + + + + + + + + +

blue < 768px <= pink

+ +