From 5f86910347e74cb9125ec8a5fb7d90155af44b04 Mon Sep 17 00:00:00 2001 From: Wil Wilsman Date: Thu, 6 Oct 2022 12:32:24 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20comparisons=20API=20for=20fut?= =?UTF-8?q?ure=20SDK=20usage=20(#1087)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Add client methods for new comparisons API endpoints * ✨ Add core endpoint to upload snapshot comparisons * ✨ Add comparison sdk-utils helper * ✨ Return generated comparison redirect link from CLI endpoint * 🐛 Remove comparison max height constraint --- packages/client/README.md | 35 ++- packages/client/src/client.js | 122 +++++++- packages/client/test/client.test.js | 359 +++++++++++++++++++++- packages/client/test/helpers.js | 8 + packages/core/src/api.js | 27 +- packages/core/src/config.js | 74 ++++- packages/core/src/percy.js | 17 + packages/core/src/snapshot.js | 3 +- packages/core/test/api.test.js | 100 +++++- packages/core/test/percy.test.js | 90 +++++- packages/sdk-utils/src/index.js | 2 + packages/sdk-utils/src/post-comparison.js | 18 ++ packages/sdk-utils/src/post-snapshot.js | 4 +- packages/sdk-utils/test/index.test.js | 51 +++ 14 files changed, 857 insertions(+), 53 deletions(-) create mode 100644 packages/sdk-utils/src/post-comparison.js diff --git a/packages/client/README.md b/packages/client/README.md index 8ba38433e..7ef7ddc5f 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -7,6 +7,7 @@ builds. Can also be used to query for a project's builds using a read access tok - [Usage](#usage) - [Create a build](#create-a-build) - [Create, upload, and finalize snapshots](#create-upload-and-finalize-snapshots) +- [Create, upload, and finalize comparisons](#create-upload-and-finalize-comparisons) - [Finalize a build](#finalize-a-build) - [Query for a build*](#query-for-a-build) - [Query for a project's builds*](#query-for-a-projects-builds) @@ -59,7 +60,39 @@ await client.sendSnapshot(buildId, snapshotOptions) - `mimetype` — Resource mimetype (**required**) - `content` — Resource content (**required**) - `sha` — Resource content sha - - `root` — Boolean indicating a root resource + - `root` — Boolean indicating a root resource## Create, upload, and finalize snapshots + +## Create, upload, and finalize comparisons + +This method combines the work of creating a snapshot, creating an associated comparison, uploading +associated comparison tiles, and finally finalizing the comparison. + +``` js +await client.sendComparison(buildId, comparisonOptions) +``` + +#### Options + +- `name` — Snapshot name (**required**) +- `clientInfo` — Additional client info +- `environmentInfo` — Additional environment info +- `externalDebugUrl` — External debug URL +- `tag` — Tagged information about this comparison + - `name` — The tag name for this comparison, e.g. "iPhone 14 Pro" (**required**) + - `osName` - OS name for the comparison tag; e.g. "iOS" + - `osVersion` - OS version for the comparison tag; e.g. "16" + - `width` - The width for this type of comparison + - `height` - The height for this type of comparison + - `orientation` - Either "portrait" or "landscape" +- `tiles` — Array of comparison tiles + - `sha` — Tile file contents SHA-256 hash + - `filepath` — Tile filepath in the filesystem (required when missing `content`) + - `content` — Tile contents as a string or buffer (required when missing `filepath`) + - `statusBarHeight` — Height of any status bar in this tile + - `navBarHeight` — Height of any nav bar in this tile + - `headerHeight` — Height of any header area in this tile + - `footerHeight` — Height of any footer area in this tile + - `fullscreen` — Boolean indicating this is a fullscreen tile ## Finalize a build diff --git a/packages/client/src/client.js b/packages/client/src/client.js index 0ad4a9831..82893df74 100644 --- a/packages/client/src/client.js +++ b/packages/client/src/client.js @@ -15,11 +15,11 @@ import { const { PERCY_CLIENT_API_URL = 'https://percy.io/api/v1' } = process.env; const pkg = getPackageJSON(import.meta.url); -// Validate build ID arguments -function validateBuildId(id) { - if (!id) throw new Error('Missing build ID'); +// Validate ID arguments +function validateId(type, id) { + if (!id) throw new Error(`Missing ${type} ID`); if (!(typeof id === 'string' || typeof id === 'number')) { - throw new Error('Invalid build ID'); + throw new Error(`Invalid ${type} ID`); } } @@ -159,7 +159,7 @@ export class PercyClient { // Finalizes the active build. When `all` is true, `all-shards=true` is // added as a query param so the API finalizes all other build shards. async finalizeBuild(buildId, { all = false } = {}) { - validateBuildId(buildId); + validateId('build', buildId); let qs = all ? 'all-shards=true' : ''; this.log.debug(`Finalizing build ${buildId}...`); return this.post(`builds/${buildId}/finalize?${qs}`); @@ -167,7 +167,7 @@ export class PercyClient { // Retrieves build data by id. Requires a read access token. async getBuild(buildId) { - validateBuildId(buildId); + validateId('build', buildId); this.log.debug(`Get build ${buildId}`); return this.get(`builds/${buildId}`); } @@ -255,10 +255,9 @@ export class PercyClient { // `content` is read from the filesystem. The sha is optional and will be // created from `content` if one is not provided. async uploadResource(buildId, { url, sha, filepath, content } = {}) { - validateBuildId(buildId); - + validateId('build', buildId); this.log.debug(`Uploading resource: ${url}...`); - if (filepath) content = fs.readFileSync(filepath); + if (filepath) content = await fs.promises.readFile(filepath); return this.post(`builds/${buildId}/resources`, { data: { @@ -273,8 +272,7 @@ export class PercyClient { // Uploads resources to the active build concurrently, two at a time. async uploadResources(buildId, resources) { - validateBuildId(buildId); - + validateId('build', buildId); this.log.debug(`Uploading resources for ${buildId}...`); return pool(function*() { @@ -295,7 +293,7 @@ export class PercyClient { environmentInfo, resources = [] } = {}) { - validateBuildId(buildId); + validateId('build', buildId); this.addClientInfo(clientInfo); this.addEnvironmentInfo(environmentInfo); @@ -305,6 +303,11 @@ export class PercyClient { this.log.debug(`Creating snapshot: ${name}...`); + for (let resource of resources) { + if (resource.sha || resource.content || !resource.filepath) continue; + resource.content = await fs.promises.readFile(resource.filepath); + } + return this.post(`builds/${buildId}/snapshots`, { data: { type: 'snapshots', @@ -319,7 +322,7 @@ export class PercyClient { resources: { data: resources.map(r => ({ type: 'resources', - id: r.sha || sha256hash(r.content), + id: r.sha ?? (r.content && sha256hash(r.content)), attributes: { 'resource-url': r.url || null, 'is-root': r.root || null, @@ -335,7 +338,7 @@ export class PercyClient { // Finalizes a snapshot. async finalizeSnapshot(snapshotId) { - if (!snapshotId) throw new Error('Missing snapshot ID'); + validateId('snapshot', snapshotId); this.log.debug(`Finalizing snapshot ${snapshotId}...`); return this.post(`snapshots/${snapshotId}/finalize`); } @@ -354,6 +357,97 @@ export class PercyClient { await this.finalizeSnapshot(snapshot.data.id); return snapshot; } + + async createComparison(snapshotId, { tag, tiles = [], externalDebugUrl } = {}) { + validateId('snapshot', snapshotId); + + this.log.debug(`Creating comparision: ${tag.name}...`); + + for (let tile of tiles) { + if (tile.sha || tile.content || !tile.filepath) continue; + tile.content = await fs.promises.readFile(tile.filepath); + } + + return this.post(`snapshots/${snapshotId}/comparisons`, { + data: { + type: 'comparisons', + attributes: { + 'external-debug-url': externalDebugUrl || null + }, + relationships: { + tag: { + data: { + type: 'tag', + attributes: { + name: tag.name || null, + width: tag.width || null, + height: tag.height || null, + 'os-name': tag.osName || null, + 'os-version': tag.osVersion || null, + orientation: tag.orientation || null + } + } + }, + tiles: { + data: tiles.map(t => ({ + type: 'tiles', + attributes: { + sha: t.sha || (t.content && sha256hash(t.content)), + 'status-bar-height': t.statusBarHeight || null, + 'nav-bar-height': t.navBarHeight || null, + 'header-height': t.headerHeight || null, + 'footer-height': t.footerHeight || null, + fullscreen: t.fullscreen || null + } + })) + } + } + } + }); + } + + async uploadComparisonTile(comparisonId, { index = 0, total = 1, filepath, content } = {}) { + validateId('comparison', comparisonId); + this.log.debug(`Uploading comparison tile: ${index + 1}/${total} (${comparisonId})...`); + if (filepath) content = await fs.promises.readFile(filepath); + + return this.post(`comparisons/${comparisonId}/tiles`, { + data: { + type: 'tiles', + attributes: { + 'base64-content': base64encode(content), + index + } + } + }); + } + + async uploadComparisonTiles(comparisonId, tiles) { + validateId('comparison', comparisonId); + this.log.debug(`Uploading comparison tiles for ${comparisonId}...`); + + return pool(function*() { + for (let index = 0; index < tiles.length; index++) { + yield this.uploadComparisonTile(comparisonId, { + index, total: tiles.length, ...tiles[index] + }); + } + }, this, 2); + } + + async finalizeComparison(comparisonId) { + validateId('comparison', comparisonId); + this.log.debug(`Finalizing comparison ${comparisonId}...`); + return this.post(`comparisons/${comparisonId}/finalize`); + } + + async sendComparison(buildId, options) { + let snapshot = await this.createSnapshot(buildId, options); + let comparison = await this.createComparison(snapshot.data.id, options); + await this.uploadComparisonTiles(comparison.data.id, options.tiles); + await this.finalizeComparison(comparison.data.id); + return comparison; + } } export default PercyClient; diff --git a/packages/client/test/client.test.js b/packages/client/test/client.test.js index a5cf0bda3..db0c99fff 100644 --- a/packages/client/test/client.test.js +++ b/packages/client/test/client.test.js @@ -423,7 +423,7 @@ describe('PercyClient', () => { }); it('can upload a resource from a local path', async () => { - spyOn(fs, 'readFileSync').and.callFake(p => `contents of ${p}`); + spyOn(fs.promises, 'readFile').and.callFake(async p => `contents of ${p}`); await expectAsync(client.uploadResource(123, { sha: 'foo-sha', @@ -457,32 +457,34 @@ describe('PercyClient', () => { it('uploads multiple resources two at a time', async () => { let content = 'foo'; - // to test this, the API is set to delay responses by 15ms... api.reply('/builds/123/resources', async () => { - await new Promise(r => setTimeout(r, 12)); - return [201, { success: content }]; + let result = { content }; + setTimeout(() => (content = 'bar'), 10); + await new Promise(r => setTimeout(r, 20)); + return [201, result]; }); - // ...after 20ms (enough time for a single request) the contents change... - setTimeout(() => (content = 'bar'), 20); - spyOn(fs, 'readFileSync').and.returnValue(content); + spyOn(fs.promises, 'readFile').and.resolveTo(content); - // ... which should result in every 2 uploads being identical await expectAsync(client.uploadResources(123, [ { filepath: 'foo/bar' }, { filepath: 'foo/bar' }, { filepath: 'foo/bar' }, { filepath: 'foo/bar' } ])).toBeResolvedTo([ - { success: 'foo' }, - { success: 'foo' }, - { success: 'bar' }, - { success: 'bar' } + { content: 'foo' }, + { content: 'foo' }, + { content: 'bar' }, + { content: 'bar' } ]); }); it('throws any errors from uploading', async () => { - await expectAsync(client.uploadResources(123, [{}])).toBeRejectedWithError(); + spyOn(fs.promises, 'readFile').and.rejectWith(new Error()); + + await expectAsync(client.uploadResources(123, [ + { filepath: 'foo/bar' } + ])).toBeRejectedWithError(); }); }); @@ -495,6 +497,9 @@ describe('PercyClient', () => { }); it('creates a snapshot', async () => { + spyOn(fs.promises, 'readFile') + .withArgs('foo/bar').and.resolveTo('bar'); + await expectAsync(client.createSnapshot(123, { name: 'snapfoo', widths: [1000], @@ -504,11 +509,15 @@ describe('PercyClient', () => { clientInfo: 'sdk/info', environmentInfo: 'sdk/env', resources: [{ - url: '/foobar', + url: '/foo', content: 'foo', mimetype: 'text/html', widths: [1000], root: true + }, { + url: '/bar', + filepath: 'foo/bar', + mimetype: 'image/png' }] })).toBeResolved(); @@ -536,11 +545,20 @@ describe('PercyClient', () => { type: 'resources', id: sha256hash('foo'), attributes: { - 'resource-url': '/foobar', + 'resource-url': '/foo', mimetype: 'text/html', 'for-widths': [1000], 'is-root': true } + }, { + type: 'resources', + id: sha256hash('bar'), + attributes: { + 'resource-url': '/bar', + mimetype: 'image/png', + 'for-widths': null, + 'is-root': null + } }] } } @@ -674,4 +692,315 @@ describe('PercyClient', () => { expect(api.requests['/snapshots/4567/finalize']).toBeDefined(); }); }); + + describe('#createComparison()', () => { + it('throws when missing a snapshot id', async () => { + await expectAsync(client.createComparison()) + .toBeRejectedWithError('Missing snapshot ID'); + }); + + it('creates a comparison', async () => { + spyOn(fs.promises, 'readFile') + .withArgs('foo/bar').and.resolveTo('bar'); + + await expectAsync(client.createComparison(4567, { + tag: { + name: 'tagfoo', + width: 748, + height: 1024, + osName: 'fooOS', + osVersion: '0.1.0', + orientation: 'portrait' + }, + tiles: [{ + statusBarHeight: 40, + navBarHeight: 30, + headerHeight: 20, + footerHeight: 50, + fullscreen: false, + content: 'foo' + }, { + statusBarHeight: 40, + navBarHeight: 30, + headerHeight: 20, + footerHeight: 50, + fullscreen: true, + filepath: 'foo/bar' + }], + externalDebugUrl: 'http://debug.localhost' + })).toBeResolved(); + + expect(api.requests['/snapshots/4567/comparisons'][0].body).toEqual({ + data: { + type: 'comparisons', + attributes: { + 'external-debug-url': 'http://debug.localhost' + }, + relationships: { + tag: { + data: { + type: 'tag', + attributes: { + name: 'tagfoo', + width: 748, + height: 1024, + 'os-name': 'fooOS', + 'os-version': '0.1.0', + orientation: 'portrait' + } + } + }, + tiles: { + data: [{ + type: 'tiles', + attributes: { + sha: sha256hash('foo'), + 'status-bar-height': 40, + 'nav-bar-height': 30, + 'header-height': 20, + 'footer-height': 50, + fullscreen: null + } + }, { + type: 'tiles', + attributes: { + sha: sha256hash('bar'), + 'status-bar-height': 40, + 'nav-bar-height': 30, + 'header-height': 20, + 'footer-height': 50, + fullscreen: true + } + }] + } + } + } + }); + }); + + it('falls back to null attributes for various properties', async () => { + await expectAsync( + client.createComparison(4567, { tag: {}, tiles: [{}] }) + ).toBeResolved(); + + expect(api.requests['/snapshots/4567/comparisons'][0].body).toEqual({ + data: { + type: 'comparisons', + attributes: { + 'external-debug-url': null + }, + relationships: { + tag: { + data: { + type: 'tag', + attributes: { + name: null, + width: null, + height: null, + 'os-name': null, + 'os-version': null, + orientation: null + } + } + }, + tiles: { + data: [{ + type: 'tiles', + attributes: { + 'status-bar-height': null, + 'nav-bar-height': null, + 'header-height': null, + 'footer-height': null, + fullscreen: null + } + }] + } + } + } + }); + }); + }); + + describe('#uploadComparisonTile()', () => { + it('throws when missing a comparison id', async () => { + await expectAsync(client.uploadComparisonTile()) + .toBeRejectedWithError('Missing comparison ID'); + }); + + it('uploads a tile for a comparison', async () => { + await expectAsync( + client.uploadComparisonTile(891011, { content: 'foo', index: 3 }) + ).toBeResolved(); + + expect(api.requests['/comparisons/891011/tiles'][0].body).toEqual({ + data: { + type: 'tiles', + attributes: { + 'base64-content': base64encode('foo'), + index: 3 + } + } + }); + }); + + it('can upload a tile from a local path', async () => { + spyOn(fs.promises, 'readFile').and.callFake(async p => `contents of ${p}`); + + await expectAsync( + client.uploadComparisonTile(891011, { filepath: 'foo/bar' }) + ).toBeResolved(); + + expect(api.requests['/comparisons/891011/tiles'][0].body).toEqual({ + data: { + type: 'tiles', + attributes: { + 'base64-content': base64encode('contents of foo/bar'), + index: 0 + } + } + }); + }); + }); + + describe('#uploadComparisonTiles()', () => { + it('throws when missing a build id', async () => { + await expectAsync(client.uploadComparisonTiles()) + .toBeRejectedWithError('Missing comparison ID'); + }); + + it('does nothing when no tiles are provided', async () => { + await expectAsync(client.uploadComparisonTiles(891011, [])).toBeResolvedTo([]); + }); + + it('uploads multiple tiles two at a time', async () => { + let content = 'foo'; + + api.reply('/comparisons/891011/tiles', async () => { + let result = { content }; + setTimeout(() => (content = 'bar'), 10); + await new Promise(r => setTimeout(r, 20)); + return [201, result]; + }); + + spyOn(fs.promises, 'readFile').and.resolveTo(content); + + await expectAsync(client.uploadComparisonTiles(891011, [ + { filepath: 'foo/bar' }, + { filepath: 'foo/bar' }, + { filepath: 'foo/bar' }, + { filepath: 'foo/bar' } + ])).toBeResolvedTo([ + { content: 'foo' }, + { content: 'foo' }, + { content: 'bar' }, + { content: 'bar' } + ]); + }); + + it('throws any errors from uploading', async () => { + spyOn(fs.promises, 'readFile').and.rejectWith(new Error()); + + await expectAsync(client.uploadComparisonTiles(123, [ + { filepath: 'foo/bar' } + ])).toBeRejectedWithError(); + }); + }); + + describe('#finalizeComparison()', () => { + it('throws when missing a comparison id', async () => { + await expectAsync(client.finalizeComparison()) + .toBeRejectedWithError('Missing comparison ID'); + }); + + it('finalizes a comparison', async () => { + await expectAsync(client.finalizeComparison(123)).toBeResolved(); + expect(api.requests['/comparisons/123/finalize']).toBeDefined(); + }); + }); + + describe('#sendComparison()', () => { + beforeEach(async () => { + await client.sendComparison(123, { + name: 'test snapshot name', + tag: { name: 'test tag' }, + tiles: [{ content: 'tile' }] + }); + }); + + it('creates a snapshot', async () => { + expect(api.requests['/builds/123/snapshots'][0].body).toEqual({ + data: { + type: 'snapshots', + attributes: { + name: 'test snapshot name', + scope: null, + 'enable-javascript': null, + 'minimum-height': null, + widths: null + }, + relationships: { + resources: { + data: [] + } + } + } + }); + }); + + it('creates a comparison', async () => { + expect(api.requests['/snapshots/4567/comparisons'][0].body).toEqual({ + data: { + type: 'comparisons', + attributes: { + 'external-debug-url': null + }, + relationships: { + tag: { + data: { + type: 'tag', + attributes: { + name: 'test tag', + width: null, + height: null, + 'os-name': null, + 'os-version': null, + orientation: null + } + } + }, + tiles: { + data: [{ + type: 'tiles', + attributes: { + sha: jasmine.any(String), + 'status-bar-height': null, + 'nav-bar-height': null, + 'header-height': null, + 'footer-height': null, + fullscreen: null + } + }] + } + } + } + }); + }); + + it('uploads comparison tiles', async () => { + expect(api.requests['/comparisons/891011/tiles'][0].body).toEqual({ + data: { + type: 'tiles', + attributes: { + 'base64-content': base64encode('tile'), + index: 0 + } + } + }); + }); + + it('finalizes a comparison', async () => { + expect(api.requests['/snapshots/4567/finalize']).not.toBeDefined(); + expect(api.requests['/comparisons/891011/finalize']).toBeDefined(); + }); + }); }); diff --git a/packages/client/test/helpers.js b/packages/client/test/helpers.js index bcb41b060..1ba8bd6ec 100644 --- a/packages/client/test/helpers.js +++ b/packages/client/test/helpers.js @@ -120,6 +120,14 @@ export const api = { } } } + }], + + '/snapshots/4567/comparisons': ({ body }) => [201, { + data: { + id: '891011', + attributes: body.attributes, + relationships: body.relationships + } }] }, diff --git a/packages/core/src/api.js b/packages/core/src/api.js index fa532b9be..ae7afa16f 100644 --- a/packages/core/src/api.js +++ b/packages/core/src/api.js @@ -2,11 +2,19 @@ import fs from 'fs'; import path from 'path'; import { createRequire } from 'module'; import logger from '@percy/logger'; +import { normalize } from '@percy/config/utils'; import { getPackageJSON, Server } from './utils.js'; // need require.resolve until import.meta.resolve can be transpiled export const PERCY_DOM = createRequire(import.meta.url).resolve('@percy/dom'); +// Returns a URL encoded string of nested query params +function encodeURLSearchParams(subj, prefix) { + return typeof subj === 'object' ? Object.entries(subj).map(([key, value]) => ( + encodeURLSearchParams(value, prefix ? `${prefix}[${key}]` : key) + )).join('&') : `${prefix}=${encodeURIComponent(subj)}`; +} + // Create a Percy CLI API server instance export function createPercyServer(percy, port) { let pkg = getPackageJSON(import.meta.url); @@ -80,12 +88,29 @@ export function createPercyServer(percy, port) { let wrapper = '(window.PercyAgent = class { snapshot(n, o) { return PercyDOM.serialize(o); } });'; return res.send(200, 'applicaton/javascript', content.concat(wrapper)); }) - // post one or more snapshots + // post one or more snapshots, optionally async .route('post', '/percy/snapshot', async (req, res) => { let snapshot = percy.snapshot(req.body); if (!req.url.searchParams.has('async')) await snapshot; return res.json(200, { success: true }); }) + // post one or more comparisons, optionally waiting + .route('post', '/percy/comparison', async (req, res) => { + let upload = percy.upload(req.body); + if (req.url.searchParams.has('await')) await upload; + + // generate and include one or more redirect links to comparisons + let link = ({ name, tag }) => [ + percy.client.apiUrl, '/comparisons/redirect?', + encodeURLSearchParams(normalize({ + buildId: percy.build?.id, snapshot: { name }, tag + }, { snake: true })) + ].join(''); + + return res.json(200, Object.assign({ success: true }, req.body ? ( + Array.isArray(req.body) ? { links: req.body.map(link) } : { link: link(req.body) } + ) : {})); + }) // flushes one or more snapshots from the internal queue .route('post', '/percy/flush', async (req, res) => res.json(200, { success: await percy.flush(req.body).then(() => true) diff --git a/packages/core/src/config.js b/packages/core/src/config.js index 880de0731..94ec3c6d3 100644 --- a/packages/core/src/config.js +++ b/packages/core/src/config.js @@ -19,7 +19,7 @@ export const configSchema = { items: { type: 'integer', maximum: 2000, - minimum: 10 + minimum: 120 } }, minHeight: { @@ -353,10 +353,80 @@ export const snapshotSchema = { } }; +// Comparison upload options +export const comparisonSchema = { + type: 'object', + $id: '/comparison', + required: ['name', 'tag'], + additionalProperties: false, + properties: { + name: { type: 'string' }, + externalDebugUrl: { type: 'string' }, + tag: { + type: 'object', + additionalProperties: false, + required: ['name'], + properties: { + name: { type: 'string' }, + osName: { type: 'string' }, + osVersion: { type: 'string' }, + width: { + type: 'integer', + maximum: 2000, + minimum: 120 + }, + height: { + type: 'integer', + minimum: 10 + }, + orientation: { + type: 'string', + enum: ['portrait', 'landscape'] + } + } + }, + tiles: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + filepath: { + type: 'string' + }, + content: { + type: 'string' + }, + statusBarHeight: { + type: 'integer', + minimum: 0 + }, + navBarHeight: { + type: 'integer', + minimum: 0 + }, + headerHeight: { + type: 'integer', + minimum: 0 + }, + footerHeight: { + type: 'integer', + minimum: 0 + }, + fullscreen: { + type: 'boolean' + } + } + } + } + } +}; + // Grouped schemas for easier registration export const schemas = [ configSchema, - snapshotSchema + snapshotSchema, + comparisonSchema ]; // Config migrate function diff --git a/packages/core/src/percy.js b/packages/core/src/percy.js index 4b707e6f3..4848904d8 100644 --- a/packages/core/src/percy.js +++ b/packages/core/src/percy.js @@ -327,6 +327,23 @@ export class Percy { return yieldAll(options.map(o => this.yield.upload(o))); } + // validate comparison uploads and warn about any errors + if ('tag' in options || 'tiles' in options) { + // throw when missing required snapshot or tag name + if (!options.name) throw new Error('Missing required snapshot name'); + if (!options.tag?.name) throw new Error('Missing required tag name for comparison'); + + // normalize, migrate, and remove certain properties from validating + options = PercyConfig.migrate(options, '/comparison'); + let { clientInfo, environmentInfo, ...comparison } = options; + let errors = PercyConfig.validate(comparison, '/comparison'); + + if (errors) { + this.log.warn('Invalid upload options:'); + for (let e of errors) this.log.warn(`- ${e.path}: ${e.message}`); + } + } + // add client & environment info this.client.addClientInfo(options.clientInfo); this.client.addEnvironmentInfo(options.environmentInfo); diff --git a/packages/core/src/snapshot.js b/packages/core/src/snapshot.js index ce8f19649..b1aa03928 100644 --- a/packages/core/src/snapshot.js +++ b/packages/core/src/snapshot.js @@ -333,7 +333,8 @@ export function createSnapshotsQueue(percy) { : resources; // upload the snapshot and log when deferred - let response = yield percy.client.sendSnapshot(build.id, snapshot); + let send = 'tag' in snapshot ? 'sendComparison' : 'sendSnapshot'; + let response = yield percy.client[send](build.id, snapshot); if (percy.deferUploads) percy.log.info(`Snapshot uploaded: ${name}`, meta); return { ...snapshot, response }; diff --git a/packages/core/test/api.test.js b/packages/core/test/api.test.js index 042298984..d6ea28a6c 100644 --- a/packages/core/test/api.test.js +++ b/packages/core/test/api.test.js @@ -154,7 +154,7 @@ describe('API Server', () => { }); it('can handle snapshots async with a parameter', async () => { - let test = new Promise(r => setTimeout(r, 500)); + let resolve, test = new Promise(r => (resolve = r)); spyOn(percy, 'snapshot').and.returnValue(test); await percy.start(); @@ -165,7 +165,103 @@ describe('API Server', () => { }); await expectAsync(test).toBePending(); - await test; // no hanging promises + resolve(); // no hanging promises + }); + + it('has a /comparison endpoint that calls #upload() async with provided options', async () => { + let resolve, test = new Promise(r => (resolve = r)); + spyOn(percy, 'upload').and.returnValue(test); + await percy.start(); + + await expectAsync(request('/percy/comparison', { + method: 'POST', + body: { 'test-me': true, me_too: true } + })).toBeResolvedTo(jasmine.objectContaining({ + success: true + })); + + expect(percy.upload).toHaveBeenCalledOnceWith( + { 'test-me': true, me_too: true } + ); + + await expectAsync(test).toBePending(); + resolve(); // no hanging promises + }); + + it('includes links in the /comparison endpoint response', async () => { + spyOn(percy, 'upload').and.resolveTo(); + await percy.start(); + + await expectAsync(request('/percy/comparison', { + method: 'POST', + body: { + name: 'Snapshot name', + tag: { + name: 'Tag name', + osName: 'OS name', + osVersion: 'OS version', + width: 800, + height: 1280, + orientation: 'landscape' + } + } + })).toBeResolvedTo(jasmine.objectContaining({ + link: `${percy.client.apiUrl}/comparisons/redirect?${[ + 'build_id=123', + 'snapshot[name]=Snapshot%20name', + 'tag[name]=Tag%20name', + 'tag[os_name]=OS%20name', + 'tag[os_version]=OS%20version', + 'tag[width]=800', + 'tag[height]=1280', + 'tag[orientation]=landscape' + ].join('&')}` + })); + + await expectAsync(request('/percy/comparison', { + method: 'POST', + body: [ + { name: 'Snapshot 1', tag: { name: 'Tag 1' } }, + { name: 'Snapshot 2', tag: { name: 'Tag 2' } } + ] + })).toBeResolvedTo(jasmine.objectContaining({ + links: [ + `${percy.client.apiUrl}/comparisons/redirect?${[ + 'build_id=123', + 'snapshot[name]=Snapshot%201', + 'tag[name]=Tag%201' + ].join('&')}`, + `${percy.client.apiUrl}/comparisons/redirect?${[ + 'build_id=123', + 'snapshot[name]=Snapshot%202', + 'tag[name]=Tag%202' + ].join('&')}` + ] + })); + }); + + it('can wait on comparisons to finish uploading with a parameter', async () => { + let resolve, test = new Promise(r => (resolve = r)); + + spyOn(percy, 'upload').and.returnValue(test); + await percy.start(); + + let pending = expectAsync( + request('/percy/comparison?await', 'POST') + ).toBeResolvedTo({ + success: true + }); + + await new Promise(r => setTimeout(r, 50)); + expect(percy.upload).toHaveBeenCalled(); + + await expectAsync(test).toBePending(); + await expectAsync(pending).toBePending(); + + resolve(); + + await expectAsync(test).toBeResolved(); + await expectAsync(pending).toBeResolved(); }); it('returns a 500 error when an endpoint throws', async () => { diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js index ef50049bc..83d1079f6 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -709,7 +709,7 @@ describe('Percy', () => { expect(() => percy.upload({})).toThrowError('Not running'); }); - it('pushes items to the snapshots queue', async () => { + it('pushes snapshots to the internal queue', async () => { await percy.start(); expect(api.requests['/builds/123/snapshots']).toBeUndefined(); await percy.upload({ name: 'Snapshot 1' }); @@ -720,29 +720,89 @@ describe('Percy', () => { it('can provide a resources function to evaluate within the queue', async () => { let resources = jasmine.createSpy('resources').and.returnValue([ - { sha: 'eval', url: '/evaluated' } + { sha: 'eval1', url: '/eval-1', content: 'foo' }, + { sha: 'eval2', url: '/eval-2', content: 'bar' } ]); await percy.start(); await percy.upload({ name: 'Snapshot', resources }); expect(resources).toHaveBeenCalled(); - expect(api.requests['/builds/123/snapshots'][0]).toHaveProperty('body', { - data: jasmine.objectContaining({ - relationships: { - resources: { - data: [jasmine.objectContaining({ - attributes: jasmine.objectContaining({ - 'resource-url': '/evaluated' - }) - })] - } - } - }) + expect(api.requests['/builds/123/snapshots']).toHaveSize(1); + expect(api.requests['/builds/123/resources']).toHaveSize(2); + expect(api.requests['/snapshots/4567/finalize']).toHaveSize(1); + + let partial = jasmine.objectContaining; + expect(api.requests['/builds/123/snapshots'][0]) + .toHaveProperty('body.data.relationships.resources.data', [ + partial({ attributes: partial({ 'resource-url': '/eval-1' }) }), + partial({ attributes: partial({ 'resource-url': '/eval-2' }) }) + ]); + }); + + it('can push snapshot comparisons to the internal queue', async () => { + await percy.start(); + + await percy.upload({ + name: 'Snapshot', + tag: { name: 'device' }, + tiles: [{ content: 'foo' }, { content: 'bar' }] }); + + expect(api.requests['/builds/123/snapshots']).toHaveSize(1); + expect(api.requests['/snapshots/4567/comparisons']).toHaveSize(1); + expect(api.requests['/comparisons/891011/tiles']).toHaveSize(2); + expect(api.requests['/comparisons/891011/finalize']).toHaveSize(1); + expect(api.requests['/snapshots/4567/finalize']).toBeUndefined(); + + expect(logger.stderr).toEqual([]); + expect(logger.stdout).toEqual([ + '[percy] Percy has started!', + '[percy] Snapshot taken: Snapshot' + ]); + }); + + it('errors when missing any required properties', async () => { + await percy.start(); + + expect(() => percy.upload({ + tag: { name: 'device' }, + tiles: [{ content: 'missing' }] + })).toThrowError('Missing required snapshot name'); + + expect(() => percy.upload({ + name: 'Missing tag name', + tiles: [{ content: 'missing' }] + })).toThrowError('Missing required tag name for comparison'); + }); + + it('warns about invalid snapshot comparison options', async () => { + await percy.start(); + + await percy.upload({ + name: 'Snapshot', + external_debug_url: 'localhost', + some_other_rand_prop: 'random value', + tag: { name: 'device', foobar: 'baz' }, + tiles: [{ content: 'foo' }, { content: [123] }] + }); + + expect(api.requests['/snapshots/4567/comparisons']).toHaveSize(1); + expect(api.requests['/comparisons/891011/tiles']).toHaveSize(1); + + expect(logger.stderr).toEqual([ + '[percy] Invalid upload options:', + '[percy] - someOtherRandProp: unknown property', + '[percy] - tag.foobar: unknown property', + '[percy] - tiles[1].content: must be a string, received an array' + ]); + expect(logger.stdout).toEqual([ + '[percy] Percy has started!', + '[percy] Snapshot taken: Snapshot' + ]); }); - it('can cancel pending pushed items', async () => { + it('can cancel pending pushed snapshots', async () => { percy = await Percy.start({ token: 'PERCY_TOKEN', deferUploads: true diff --git a/packages/sdk-utils/src/index.js b/packages/sdk-utils/src/index.js index 1fac4a887..6c3809c2d 100644 --- a/packages/sdk-utils/src/index.js +++ b/packages/sdk-utils/src/index.js @@ -5,6 +5,7 @@ import isPercyEnabled from './percy-enabled.js'; import waitForPercyIdle from './percy-idle.js'; import fetchPercyDOM from './percy-dom.js'; import postSnapshot from './post-snapshot.js'; +import postComparison from './post-comparison.js'; import flushSnapshots from './flush-snapshots.js'; export { @@ -15,6 +16,7 @@ export { waitForPercyIdle, fetchPercyDOM, postSnapshot, + postComparison, flushSnapshots }; diff --git a/packages/sdk-utils/src/post-comparison.js b/packages/sdk-utils/src/post-comparison.js new file mode 100644 index 000000000..b578d040b --- /dev/null +++ b/packages/sdk-utils/src/post-comparison.js @@ -0,0 +1,18 @@ +import percy from './percy-info.js'; +import request from './request.js'; + +// Post snapshot data to the CLI snapshot endpoint. If the endpoint responds with a build error, +// indicate that Percy has been disabled. +export async function postComparison(options, params) { + let query = params ? `?${new URLSearchParams(params)}` : ''; + + await request.post(`/percy/comparison${query}`, options).catch(err => { + if (err.response?.body?.build?.error) { + percy.enabled = false; + } else { + throw err; + } + }); +} + +export default postComparison; diff --git a/packages/sdk-utils/src/post-snapshot.js b/packages/sdk-utils/src/post-snapshot.js index b7709e717..5e728561d 100644 --- a/packages/sdk-utils/src/post-snapshot.js +++ b/packages/sdk-utils/src/post-snapshot.js @@ -1,8 +1,8 @@ import percy from './percy-info.js'; import request from './request.js'; -// Post snapshot data to the snapshot endpoint. If the snapshot endpoint responds with a closed -// error message, signal that Percy has been disabled. +// Post snapshot data to the CLI snapshot endpoint. If the endpoint responds with a build error, +// indicate that Percy has been disabled. export async function postSnapshot(options, params) { let query = params ? `?${new URLSearchParams(params)}` : ''; diff --git a/packages/sdk-utils/test/index.test.js b/packages/sdk-utils/test/index.test.js index 8bd807f45..6916a4903 100644 --- a/packages/sdk-utils/test/index.test.js +++ b/packages/sdk-utils/test/index.test.js @@ -191,6 +191,57 @@ describe('SDK Utils', () => { }); }); + describe('postComparison(options[, params])', () => { + let { postComparison } = utils; + let options; + + beforeEach(() => { + options = { + name: 'Snapshot Name', + tag: { name: 'Tag Name' }, + tiles: [{ filename: '/foo/bar' }], + externalDebugUrl: 'http://external-debug-url' + }; + }); + + it('posts comparison options to the CLI API comparison endpoint', async () => { + await expectAsync(postComparison(options)).toBeResolved(); + await expectAsync(helpers.get('requests')).toBeResolvedTo([{ + url: '/percy/comparison', + method: 'POST', + body: options + }]); + }); + + it('throws when the comparison API fails', async () => { + await helpers.test('error', '/percy/comparison'); + + await expectAsync(postComparison({})) + .toBeRejectedWithError('testing'); + }); + + it('disables snapshots when a build fails', async () => { + await helpers.test('error', '/percy/comparison'); + await helpers.test('build-failure'); + utils.percy.enabled = true; + + expect(utils.percy.enabled).toEqual(true); + await expectAsync(postComparison({})).toBeResolved(); + expect(utils.percy.enabled).toEqual(false); + }); + + it('accepts URL parameters as the second argument', async () => { + let params = { test: 'foobar' }; + + await expectAsync(postComparison(options, params)).toBeResolved(); + await expectAsync(helpers.get('requests')).toBeResolvedTo([{ + url: `/percy/comparison?${new URLSearchParams(params)}`, + method: 'POST', + body: options + }]); + }); + }); + describe('flushSnapshots([options])', () => { let { flushSnapshots } = utils;