Skip to content

Commit

Permalink
✨ Support multi-dom snapshots (#1096)
Browse files Browse the repository at this point in the history
* ✨ Add for-widths attribute to snapshot resources

* ✨ Merge deferred snapshot resources by widths

* ✨ Capture snapshots at multiple widths when uploads are deferred

* ✨ Allow specifying a singular width for dom snapshots

* ✅ Test multi-dom snapshots

* ✅ Fix cli-upload tests
  • Loading branch information
wwilsman authored Oct 6, 2022
1 parent 9082762 commit 54fa2c1
Show file tree
Hide file tree
Showing 9 changed files with 205 additions and 26 deletions.
2 changes: 2 additions & 0 deletions packages/cli-upload/test/upload.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ describe('percy upload', () => {
attributes: {
'resource-url': 'http://localhost/test-1',
mimetype: 'text/html',
'for-widths': null,
'is-root': true
}
}, {
Expand All @@ -104,6 +105,7 @@ describe('percy upload', () => {
attributes: {
'resource-url': 'http://localhost/test-1.png',
mimetype: 'image/png',
'for-widths': null,
'is-root': null
}
}])
Expand Down
1 change: 1 addition & 0 deletions packages/client/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ export class PercyClient {
attributes: {
'resource-url': r.url || null,
'is-root': r.root || null,
'for-widths': r.widths || null,
mimetype: r.mimetype || null
}
}))
Expand Down
9 changes: 7 additions & 2 deletions packages/client/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,7 @@ describe('PercyClient', () => {
url: '/foobar',
content: 'foo',
mimetype: 'text/html',
widths: [1000],
root: true
}]
})).toBeResolved();
Expand Down Expand Up @@ -536,8 +537,9 @@ describe('PercyClient', () => {
id: sha256hash('foo'),
attributes: {
'resource-url': '/foobar',
'is-root': true,
mimetype: 'text/html'
mimetype: 'text/html',
'for-widths': [1000],
'is-root': true
}
}]
}
Expand Down Expand Up @@ -568,6 +570,7 @@ describe('PercyClient', () => {
id: 'sha',
attributes: {
'resource-url': null,
'for-widths': null,
'is-root': null,
mimetype: null
}
Expand Down Expand Up @@ -608,6 +611,7 @@ describe('PercyClient', () => {
sha: sha256hash(testDOM),
mimetype: 'text/html',
content: testDOM,
widths: [1000],
root: true
}]
})
Expand All @@ -631,6 +635,7 @@ describe('PercyClient', () => {
attributes: {
mimetype: 'text/html',
'resource-url': null,
'for-widths': [1000],
'is-root': true
}
}]
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,8 @@ export const snapshotSchema = {
properties: {
url: { type: 'string' },
name: { type: 'string' },
domSnapshot: { type: 'string' }
domSnapshot: { type: 'string' },
width: { $ref: '/config/snapshot#/properties/widths/items' }
},
errors: {
unevaluatedProperties: e => (
Expand Down
52 changes: 34 additions & 18 deletions packages/core/src/discovery.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,19 +102,27 @@ function processSnapshotResources({ domSnapshot, resources, ...snapshot }) {
// the page and calling any provided execute options.
async function* captureSnapshotResources(page, snapshot, options) {
let { discovery, additionalSnapshots = [], ...baseSnapshot } = snapshot;
if (typeof options === 'function') options = { capture: options };
let { capture, deviceScaleFactor, mobile } = options;
let { capture, captureWidths, deviceScaleFactor, mobile } = options;

// used to take snapshots and remove any discovered root resource
let takeSnapshot = async (options, width) => {
if (captureWidths) options = { ...options, width };
let captured = await page.snapshot(options);
captured.resources.delete(normalizeURL(captured.url));
capture(processSnapshotResources(captured));
return captured;
};

// used to resize the using capture options
let resize = width => page.resize({
let resizePage = width => page.resize({
height: snapshot.minHeight,
deviceScaleFactor,
mobile,
width
});

// navigate to the url
yield resize(snapshot.widths[0]);
yield resizePage(snapshot.widths[0]);
yield page.goto(snapshot.url);

if (snapshot.execute) {
Expand All @@ -128,25 +136,29 @@ async function* captureSnapshotResources(page, snapshot, options) {
for (let additionalSnapshot of [baseSnapshot, ...additionalSnapshots]) {
let isBaseSnapshot = additionalSnapshot === baseSnapshot;
let snap = { ...baseSnapshot, ...additionalSnapshot };
let width, { widths, execute } = snap;

// iterate over widths to trigger reqeusts for the base snapshot
if (isBaseSnapshot) {
for (let i = 0; i < snap.widths.length - 1; i++) {
yield page.evaluate(snap.execute?.beforeResize);
// iterate over widths to trigger reqeusts and capture other widths
if (isBaseSnapshot || captureWidths) {
for (let i = 0; i < widths.length - 1; i++) {
if (captureWidths) yield takeSnapshot(snap, width);
yield page.evaluate(execute?.beforeResize);
yield waitForDiscoveryNetworkIdle(page, discovery);
yield resize(snap.widths[i + 1]);
yield page.evaluate(snap.execute?.afterResize);
yield resizePage(width = widths[i + 1]);
yield page.evaluate(execute?.afterResize);
}
}

if (capture && !snapshot.domSnapshot) {
// capture this snapshot and update the base snapshot after capture
let captured = yield page.snapshot(snap);
let captured = yield takeSnapshot(snap, width);
if (isBaseSnapshot) baseSnapshot = captured;

// remove any discovered root resource request
captured.resources.delete(normalizeURL(captured.url));
capture(processSnapshotResources(captured));
// resize back to the initial width when capturing additional snapshot widths
if (captureWidths && additionalSnapshots.length) {
let l = additionalSnapshots.indexOf(additionalSnapshot) + 1;
if (l < additionalSnapshots.length) yield resizePage(snapshot.widths[0]);
}
}
}

Expand Down Expand Up @@ -213,9 +225,10 @@ export function createDiscoveryQueue(percy) {
.handle('end', async () => {
await percy.browser.close();
})
// snapshots are unique by name
.handle('find', ({ name }, snapshot) => (
snapshot.name === name
// snapshots are unique by name; when deferred also by widths
.handle('find', ({ name, widths }, snapshot) => (
snapshot.name === name && (!percy.deferUploads || (
!widths || widths.join() === snapshot.widths.join()))
))
// initialize the root resource for DOM snapshots
.handle('push', snapshot => {
Expand Down Expand Up @@ -249,7 +262,10 @@ export function createDiscoveryQueue(percy) {
});

try {
yield* captureSnapshotResources(page, snapshot, callback);
yield* captureSnapshotResources(page, snapshot, {
captureWidths: !snapshot.domSnapshot && percy.deferUploads,
capture: callback
});
} finally {
// always close the page when done
await page.close();
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,8 @@ export class Page {
execute,
...snapshot
}) {
let { name, enableJavaScript } = snapshot;
this.log.debug(`Taking snapshot: ${name}`, this.meta);
let { name, width, enableJavaScript } = snapshot;
this.log.debug(`Taking snapshot: ${name}${width ? ` @${width}px` : ''}`, this.meta);

// wait for any specified timeout
if (waitForTimeout) {
Expand Down
41 changes: 39 additions & 2 deletions packages/core/src/snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,40 @@ export async function* gatherSnapshots(options, context) {
return snapshots;
}

// Merges snapshots and deduplicates resource arrays. Duplicate log resources are replaced, root
// resources are deduplicated by widths, and all other resources are deduplicated by their URL.
function mergeSnapshotOptions(prev = {}, next) {
let { resources: oldResources = [], ...existing } = prev;
let { resources: newResources = [], widths = [], width, ...incoming } = next;

// prioritize singular widths over mutilple widths
widths = width ? [width] : widths;

// deduplicate resources by associated widths and url
let resources = oldResources.reduce((all, resource) => {
if (resource.log || resource.widths.every(w => widths.includes(w))) return all;
if (!resource.root && all.some(r => r.url === resource.url)) return all;
resource.widths = resource.widths.filter(w => !widths.includes(w));
return all.concat(resource);
}, newResources.map(r => ({ ...r, widths })));

// sort resources after merging; roots first by min-width & logs last
resources.sort((a, b) => {
if (a.root && b.root) return Math.min(...b.widths) - Math.min(...a.widths);
return (a.root || b.log) ? -1 : (a.log || b.root) ? 1 : 0;
});

// overwrite resources and ensure unique widths
return PercyConfig.merge([
existing, incoming, { widths, resources }
], (path, prev, next) => {
if (path[0] === 'resources') return [path, next];
if (path[0] === 'widths' && prev && next) {
return [path, [...new Set([...prev, ...next])]];
}
});
}

// Creates a snapshots queue that manages a Percy build and uploads snapshots.
export function createSnapshotsQueue(percy) {
let { concurrency } = percy.config.discovery;
Expand Down Expand Up @@ -274,7 +308,7 @@ export function createSnapshotsQueue(percy) {
.handle('find', ({ name }, snapshot) => (
snapshot.name === name
))
// when pushed, maybe flush old snapshots
// when pushed, maybe flush old snapshots or possibly merge with existing snapshots
.handle('push', (snapshot, existing) => {
let { name, meta } = snapshot;

Expand All @@ -284,7 +318,10 @@ export function createSnapshotsQueue(percy) {

// immediately flush when uploads are delayed but not skipped
if (percy.delayUploads && !percy.skipUploads) queue.flush();
return snapshot;
// overwrite any existing snapshot when not deferred or when resources is a function
if (!percy.deferUploads || typeof snapshot.resources === 'function') return snapshot;
// merge snapshot options when uploads are deferred
return mergeSnapshotOptions(existing, snapshot);
})
// send snapshots to be uploaded to the build
.handle('task', async function*({ resources, ...snapshot }) {
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ export function createPercyCSSResource(url, css) {

// Creates a log resource object.
export function createLogResource(logs) {
return createResource(`/percy.${Date.now()}.log`, JSON.stringify(logs), 'text/plain');
let [url, content] = [`/percy.${Date.now()}.log`, JSON.stringify(logs)];
return createResource(url, content, 'text/plain', { log: true });
}

// Returns true or false if the provided object is a generator or not
Expand Down
116 changes: 116 additions & 0 deletions packages/core/test/snapshot.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,122 @@ describe('Snapshot', () => {
expect(roots[1]).toHaveProperty('attributes.resource-url', 'http://localhost:8000/two');
});

it('uploads named snapshots with differing root widths when deferred', async () => {
// stop and recreate a percy instance with the desired option
await percy.stop(true);
await api.mock({ delay: 50 });

percy = await Percy.start({
token: 'PERCY_TOKEN',
discovery: { concurrency: 1 },
deferUploads: true
});

let snap = (domSnapshot, widths) => percy.snapshot({
[Array.isArray(widths) ? 'widths' : 'width']: widths,
url: 'http://localhost:8000/',
domSnapshot
});

snap('xs width', [400, 600]);
snap('sm widths', [400, 600, 800]);
snap('med widths', [800, 1000, 1200]);
snap('lg widths', 1200);
await percy.idle();

// deferred still works as expected
expect(api.requests['/builds']).toBeUndefined();
expect(api.requests['/builds/123/snapshots']).toBeUndefined();

await percy.stop();

// single snapshot uploaded after stopping
expect(api.requests['/builds/123/snapshots']).toHaveSize(1);

// snapshot should contain 3 roots of differing widths
let roots = api.requests['/builds/123/snapshots'][0].body.data
.relationships.resources.data.filter(r => r.attributes['is-root']);

expect(roots).toHaveSize(3);
expect(roots[0]).toHaveProperty('attributes.for-widths', [1200]);
expect(roots[1]).toHaveProperty('attributes.for-widths', [800, 1000]);
expect(roots[2]).toHaveProperty('attributes.for-widths', [400, 600]);

// roots have the same URL, but different SHA IDs
expect(roots[0].attributes['resource-url'])
.toEqual(roots[1].attributes['resource-url']);
expect(roots[1].attributes['resource-url'])
.toEqual(roots[2].attributes['resource-url']);
expect(roots[0].id).not.toEqual(roots[1].id);
expect(roots[1].id).not.toEqual(roots[2].id);
});

it('can capture snapshots with multiple root widths when deferred', async () => {
server.reply('/styles.css', () => [200, 'text/css', '@import "/coverage.css"']);
server.reply('/coverage.css', () => [200, 'text/css', 'p { color: purple; }']);
// stop and recreate a percy instance with the desired option
await percy.stop(true);
await api.mock();

testDOM = `
<p id="test"></p>
<link rel="stylesheet" href="/styles.css"/>
<script>(window.onresize = () => {
let width = window.innerWidth;
if (width <= 600) test.innerText = 'small';
if (width > 600) test.innerText = 'medium';
if (width > 1000) test.innerText = 'large';
})()</script>
`;

percy = await Percy.start({
token: 'PERCY_TOKEN',
deferUploads: true
});

percy.snapshot({
name: 'Snapshot 0',
url: 'http://localhost:8000/',
additionalSnapshots: [{ name: 'Snapshot 1' }],
widths: [600, 1000, 1600]
});

await percy.idle();

// deferred still works as expected
expect(api.requests['/builds']).toBeUndefined();
expect(api.requests['/builds/123/snapshots']).toBeUndefined();

await percy.stop();

// snapshots uploaded after stopping
expect(api.requests['/builds/123/snapshots']).toHaveSize(2);

for (let i in api.requests['/builds/123/snapshots']) {
let req = api.requests['/builds/123/snapshots'][i];
expect(req).toHaveProperty('body.data.attributes.name', `Snapshot ${i}`);

// snapshots should contain 3 roots of differing widths
let roots = req.body.data.relationships
.resources.data.filter(r => r.attributes['is-root']);

expect(roots).toHaveSize(3);
expect(roots[0]).toHaveProperty('attributes.for-widths', [1600]);
expect(roots[1]).toHaveProperty('attributes.for-widths', [1000]);
expect(roots[2]).toHaveProperty('attributes.for-widths', [600]);

let captured = roots.map(({ id }) => Buffer.from((
api.requests['/builds/123/resources']
.find(r => r.body.data.id === id)?.body
.data.attributes['base64-content']
), 'base64').toString());

expect(captured[0]).toMatch('<p id="test">large</p>');
expect(captured[1]).toMatch('<p id="test">medium</p>');
expect(captured[2]).toMatch('<p id="test">small</p>');
}
});

it('logs after taking the snapshot', async () => {
await percy.snapshot({
name: 'test snapshot',
Expand Down

0 comments on commit 54fa2c1

Please sign in to comment.