diff --git a/packages/cli-upload/README.md b/packages/cli-upload/README.md index 13dc9038e..62f2e2782 100644 --- a/packages/cli-upload/README.md +++ b/packages/cli-upload/README.md @@ -4,30 +4,36 @@ Percy CLI command to uploade a directory of static images to Percy for diffing. ## Commands -* [`percy upload DIRNAME`](#percy-upload-dirname) +* [`percy upload`](#percy-upload) -## `percy upload DIRNAME` +### `percy upload` Upload a directory of images to Percy ``` -USAGE - $ percy upload DIRNAME - -ARGUMENTS - DIRNAME directory of images to upload - -OPTIONS - -c, --config=config configuration file path - -d, --dry-run prints a list of matching images to upload without uploading - -e, --strip-extensions strips file extensions from snapshot names - -f, --files=files [default: **/*.{png,jpg,jpeg}] one or more globs matching image file paths to upload - -i, --ignore=ignore one or more globs matching image file paths to ignore - -q, --quiet log errors only - -v, --verbose log everything - --silent log nothing - -EXAMPLE +Usage: + $ percy upload [options] + +Arguments: + dirname Directory of images to upload + +Options: + -f, --files [pattern] One or more globs matching image file paths to upload (default: + "**/*.{png,jpg,jpeg}") + -i, --ignore One or more globs matching image file paths to ignore + -e, --strip-extensions Strips file extensions from snapshot names + +Percy options: + -c, --config Config file path + -d, --dry-run Print snapshot names only + +Global options: + -v, --verbose Log everything + -q, --quiet Log errors only + -s, --silent Log nothing + -h, --help Display command help + +Examples: $ percy upload ./images ``` diff --git a/packages/cli-upload/package.json b/packages/cli-upload/package.json index 4343c0685..0d2e6fa4a 100644 --- a/packages/cli-upload/package.json +++ b/packages/cli-upload/package.json @@ -2,10 +2,17 @@ "name": "@percy/cli-upload", "version": "1.0.0-beta.71", "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/percy/cli", + "directory": "packages/cli-upload" + }, + "publishConfig": { + "access": "public" + }, "main": "dist/index.js", "files": [ - "dist", - "oclif.manifest.json" + "dist" ], "engines": { "node": ">=12" @@ -13,32 +20,20 @@ "scripts": { "build": "node ../../scripts/build", "lint": "eslint --ignore-path ../../.gitignore .", - "postbuild": "oclif-dev manifest", - "readme": "oclif-dev readme", + "readme": "percy-cli-readme", "test": "node ../../scripts/test", "test:coverage": "yarn test --coverage" }, - "publishConfig": { - "access": "public" - }, - "oclif": { - "bin": "percy", - "commands": "./dist/commands", - "hooks": { - "init": "./dist/hooks/init" - } + "@percy/cli": { + "commands": [ + "./dist/upload.js" + ] }, "dependencies": { "@percy/cli-command": "1.0.0-beta.71", "@percy/client": "1.0.0-beta.71", - "@percy/config": "1.0.0-beta.71", "@percy/logger": "1.0.0-beta.71", "globby": "^11.0.4", "image-size": "^1.0.0" - }, - "repository": { - "type": "git", - "url": "https://github.com/percy/cli", - "directory": "packages/cli-upload" } } diff --git a/packages/cli-upload/src/commands/upload.js b/packages/cli-upload/src/commands/upload.js deleted file mode 100644 index 893fb3ba6..000000000 --- a/packages/cli-upload/src/commands/upload.js +++ /dev/null @@ -1,151 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import Command, { flags } from '@percy/cli-command'; -import logger from '@percy/logger'; -import globby from 'globby'; -import imageSize from 'image-size'; -import PercyClient from '@percy/client'; - -// eslint-disable-next-line import/no-extraneous-dependencies -import Queue from '@percy/core/dist/queue'; - -import createImageResources from '../resources'; -import { schema } from '../config'; -import pkg from '../../package.json'; - -const ALLOWED_FILE_TYPES = /^\.(png|jpg|jpeg)$/i; - -export class Upload extends Command { - static description = 'Upload a directory of images to Percy'; - - static args = [{ - name: 'dirname', - description: 'directory of images to upload', - required: true - }]; - - static flags = { - ...flags.logging, - ...flags.config, - - files: flags.string({ - char: 'f', - multiple: true, - description: 'one or more globs matching image file paths to upload', - default: schema.upload.properties.files.default, - percyrc: 'upload.files' - }), - ignore: flags.string({ - char: 'i', - multiple: true, - description: 'one or more globs matching image file paths to ignore', - percyrc: 'upload.ignore' - }), - 'strip-extensions': flags.boolean({ - char: 'e', - description: 'strips file extensions from snapshot names', - percyrc: 'upload.stripExtensions' - }), - 'dry-run': flags.boolean({ - char: 'd', - description: 'prints a list of matching images to upload without uploading' - }) - }; - - static examples = [ - '$ percy upload ./images' - ]; - - log = logger('cli:upload'); - - async run() { - if (!this.isPercyEnabled()) { - return this.log.info('Percy is disabled. Skipping upload'); - } - - let { dirname } = this.args; - let { 'dry-run': dry } = this.flags; - - if (!fs.existsSync(dirname)) { - return this.error(`Not found: ${dirname}`); - } else if (!fs.lstatSync(dirname).isDirectory()) { - return this.error(`Not a directory: ${dirname}`); - } - - let { upload: conf } = this.percyrc(); - this.queue = new Queue(conf.concurrency); - - let paths = await globby(conf.files, { - ignore: [].concat(conf.ignore || []), - cwd: dirname - }); - - let l = paths.length; - if (!l) this.error(`No matching files found in '${dirname}'`); - paths.sort(); - - this.client = new PercyClient({ - clientInfo: `${pkg.name}/${pkg.version}`, - environmentInfo: `node/${process.version}` - }); - - if (dry) { - this.log.info(`Found ${l} snapshot${l !== 1 ? 's' : ''}`); - } else { - let { data: build } = await this.client.createBuild(); - let { 'build-number': number, 'web-url': url } = build.attributes; - this.build = { id: build.id, number, url }; - this.log.info('Percy has started!'); - } - - for (let filename of paths) { - let file = path.parse(filename); - - if (!ALLOWED_FILE_TYPES.test(file.ext)) { - this.log.info(`Skipping unsupported file type: ${filename}`); - continue; - } - - let name = conf.stripExtensions ? ( - path.join(file.dir, file.name) - ) : filename; - - if (dry) this.log.info(`Snapshot found: ${name}`); - else this.snapshot(name, filename, dirname); - } - } - - // Push a snapshot upload to the queue - snapshot(name, filename, dirname) { - this.queue.push(`upload/${name}`, async () => { - let filepath = path.resolve(dirname, filename); - let { width, height } = imageSize(filepath); - let buffer = fs.readFileSync(filepath); - - await this.client.sendSnapshot(this.build.id, { - // width and height is clamped to API min and max - widths: [Math.max(10, Math.min(width, 2000))], - minHeight: Math.max(10, Math.min(height, 2000)), - resources: createImageResources(filename, buffer, width, height), - name - }); - - this.log.info(`Snapshot uploaded: ${name}`); - }); - } - - // Finalize the build when finished - async finally(error) { - if (!this.build?.id) return; - if (error) this.queue.close(true); - if (this.closing) return; - this.closing = true; - - await this.queue.empty(s => { - this.log.progress(`Uploading ${s} snapshot${s !== 1 ? 's' : ''}...`, !!s); - }); - - await this.client.finalizeBuild(this.build.id); - this.log.info(`Finalized build #${this.build.number}: ${this.build.url}`); - } -} diff --git a/packages/cli-upload/src/hooks/init.js b/packages/cli-upload/src/hooks/init.js deleted file mode 100644 index 5673d87c8..000000000 --- a/packages/cli-upload/src/hooks/init.js +++ /dev/null @@ -1,7 +0,0 @@ -import PercyConfig from '@percy/config'; -import * as UploadConfig from '../config'; - -export default function() { - PercyConfig.addSchema(UploadConfig.schema); - PercyConfig.addMigration(UploadConfig.migration); -} diff --git a/packages/cli-upload/src/resources.js b/packages/cli-upload/src/resources.js index 97bdfbf58..84a75dbfb 100644 --- a/packages/cli-upload/src/resources.js +++ b/packages/cli-upload/src/resources.js @@ -25,7 +25,7 @@ function createImageResource(url, content, mimetype) { // Returns root resource and image resource objects based on an image's // filename, contents, and dimensions. The root resource is a generated DOM // designed to display an image at it's native size without margins or padding. -export function createImageResources(filename, content, width, height) { +export function createImageResources(filename, content, size) { let { dir, name, ext } = path.parse(filename); let rootUrl = `/${encodeURIComponent(path.join(dir, name))}`; let imageUrl = `/${encodeURIComponent(filename)}`; @@ -45,7 +45,7 @@ export function createImageResources(filename, content, width, height) { - + `), diff --git a/packages/cli-upload/src/upload.js b/packages/cli-upload/src/upload.js new file mode 100644 index 000000000..0d363c524 --- /dev/null +++ b/packages/cli-upload/src/upload.js @@ -0,0 +1,120 @@ +import fs from 'fs'; +import path from 'path'; +import command from '@percy/cli-command'; +import * as UploadConfig from './config'; +import pkg from '../package.json'; + +const ALLOWED_FILE_TYPES = /\.(png|jpg|jpeg)$/i; + +export const upload = command('upload', { + description: 'Upload a directory of images to Percy', + + args: [{ + name: 'dirname', + description: 'Directory of images to upload', + required: true, + validate: dir => { + if (!fs.existsSync(dir)) { + throw new Error(`Not found: ${dir}`); + } else if (!fs.lstatSync(dir).isDirectory()) { + throw new Error(`Not a directory: ${dir}`); + } + } + }], + + flags: [{ + name: 'files', + description: 'One or more globs matching image file paths to upload', + default: UploadConfig.schema.upload.properties.files.default, + percyrc: 'upload.files', + type: 'pattern', + multiple: true, + short: 'f' + }, { + name: 'ignore', + description: 'One or more globs matching image file paths to ignore', + percyrc: 'upload.ignore', + type: 'pattern', + multiple: true, + short: 'i' + }, { + name: 'strip-extensions', + description: 'Strips file extensions from snapshot names', + percyrc: 'upload.stripExtensions', + short: 'e' + }], + + examples: [ + '$0 ./images' + ], + + percy: { + clientInfo: `${pkg.name}/${pkg.version}`, + environmentInfo: `node/${process.version}`, + discoveryFlags: false, + deferUploads: true + }, + + config: { + schemas: [UploadConfig.schema], + migrations: [UploadConfig.migration] + } +}, async function*({ flags, args, percy, log, exit }) { + if (!percy) exit(0, 'Percy is disabled'); + let config = percy.config.upload; + + let { default: globby } = await import('globby'); + let pathnames = yield globby(config.files, { + ignore: [].concat(config.ignore || []), + cwd: args.dirname + }); + + if (!pathnames.length) { + exit(1, `No matching files found in '${args.dirname}'`); + } + + let { default: imageSize } = await import('image-size'); + let { createImageResources } = await import('./resources'); + + // the internal upload queue shares a concurrency with the snapshot queue + percy.setConfig({ discovery: { concurrency: config.concurrency } }); + + // do not launch a browser when starting + yield percy.start({ browser: false }); + + for (let filename of pathnames) { + let file = path.parse(filename); + let name = config.stripExtensions + ? path.join(file.dir, file.name) + : filename; + + if (!ALLOWED_FILE_TYPES.test(filename)) { + log.info(`Skipping unsupported file type: ${filename}`); + } else { + if (percy.dryRun) log.info(`Snapshot found: ${name}`); + + percy._scheduleUpload(filename, async () => { + let filepath = path.resolve(args.dirname, filename); + let buffer = fs.readFileSync(filepath); + + // width and height is clamped to API min and max + let size = imageSize(filepath); + let widths = [Math.max(10, Math.min(size.width, 2000))]; + let minHeight = Math.max(10, Math.min(size.height, 2000)); + let resources = createImageResources(filename, buffer, size); + return { name, widths, minHeight, resources }; + }).then(() => { + log.info(`Snapshot uploaded: ${name}`); + }); + } + } + + try { + yield* percy.stop(); + } catch (error) { + await percy.stop(true); + throw error; + } +}); + +export default upload; diff --git a/packages/cli-upload/test/upload.test.js b/packages/cli-upload/test/upload.test.js index 21fa38e10..66047b4c1 100644 --- a/packages/cli-upload/test/upload.test.js +++ b/packages/cli-upload/test/upload.test.js @@ -3,18 +3,17 @@ import path from 'path'; import rimraf from 'rimraf'; import mockAPI from '@percy/client/test/helpers'; import logger from '@percy/logger/test/helpers'; -import { Upload } from '../src/commands/upload'; +import upload from '../src/upload'; // http://png-pixel.com/ const pixel = Buffer.from('R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', 'base64'); +const tmp = path.join(__dirname, 'tmp'); const cwd = process.cwd(); describe('percy upload', () => { beforeAll(() => { - require('../src/hooks/init').default(); - - fs.mkdirSync(path.join(__dirname, 'tmp')); - process.chdir(path.join(__dirname, 'tmp')); + fs.mkdirSync(tmp); + process.chdir(tmp); fs.mkdirSync('images'); fs.writeFileSync(path.join('images', 'test-1.png'), pixel); @@ -26,7 +25,7 @@ describe('percy upload', () => { afterAll(() => { process.chdir(cwd); - rimraf.sync(path.join(__dirname, 'tmp')); + rimraf.sync(tmp); }); beforeEach(() => { @@ -38,7 +37,6 @@ describe('percy upload', () => { afterEach(() => { delete process.env.PERCY_TOKEN; delete process.env.PERCY_ENABLE; - process.removeAllListeners(); if (fs.existsSync('.percy.yml')) { fs.unlinkSync('.percy.yml'); @@ -47,14 +45,14 @@ describe('percy upload', () => { it('skips uploading when percy is disabled', async () => { process.env.PERCY_ENABLE = '0'; - await Upload.run(['./images']); + await upload(['./images']); - expect(logger.stderr).toEqual([]); - expect(logger.stdout).toEqual(['[percy] Percy is disabled. Skipping upload']); + expect(logger.stdout).toEqual([]); + expect(logger.stderr).toEqual(['[percy] Percy is disabled']); }); it('errors when the directory is not found', async () => { - await expectAsync(Upload.run(['./404'])).toBeRejectedWithError('EEXIT: 1'); + await expectAsync(upload(['./404'])).toBeRejected(); expect(logger.stdout).toEqual([]); expect(logger.stderr).toEqual([ @@ -63,7 +61,7 @@ describe('percy upload', () => { }); it('errors when the path is not a directory', async () => { - await expectAsync(Upload.run(['./nope'])).toBeRejectedWithError('EEXIT: 1'); + await expectAsync(upload(['./nope'])).toBeRejected(); expect(logger.stdout).toEqual([]); expect(logger.stderr).toEqual([ @@ -72,7 +70,9 @@ describe('percy upload', () => { }); it('errors when there are no matching files', async () => { - await expectAsync(Upload.run(['./images', '--files=no-match.png'])).toBeRejectedWithError('EEXIT: 1'); + await expectAsync( + upload(['./images', '--files=no-match.png']) + ).toBeRejected(); expect(logger.stdout).toEqual([]); expect(logger.stderr).toEqual([ @@ -81,7 +81,7 @@ describe('percy upload', () => { }); it('creates a new build and uploads snapshots', async () => { - await Upload.run(['./images']); + await upload(['./images']); expect(logger.stderr).toEqual([]); expect(logger.stdout).toEqual(jasmine.arrayContaining([ @@ -128,7 +128,7 @@ describe('percy upload', () => { }); it('strips file extensions with `--strip-extensions`', async () => { - await Upload.run(['./images', '--strip-extensions']); + await upload(['./images', '--strip-extensions']); expect(logger.stderr).toEqual([]); expect(logger.stdout).toEqual(jasmine.arrayContaining([ @@ -142,7 +142,7 @@ describe('percy upload', () => { }); it('skips unsupported image types', async () => { - await Upload.run(['./images', '--files=*']); + await upload(['./images', '--files=*']); expect(logger.stderr).toEqual([]); expect(logger.stdout).toEqual(jasmine.arrayContaining([ @@ -157,9 +157,11 @@ describe('percy upload', () => { }); it('does not upload snapshots and prints matching files with --dry-run', async () => { - await Upload.run(['./images', '--dry-run']); + await upload(['./images', '--dry-run']); - expect(logger.stderr).toEqual([]); + expect(logger.stderr).toEqual([ + '[percy] Build not created' + ]); expect(logger.stdout).toEqual(jasmine.arrayContaining([ '[percy] Found 3 snapshots', '[percy] Snapshot found: test-1.png', @@ -168,9 +170,11 @@ describe('percy upload', () => { ])); logger.reset(); - await Upload.run(['./images', '--dry-run', '--files=test-1.png']); + await upload(['./images', '--dry-run', '--files=test-1.png']); - expect(logger.stderr).toEqual([]); + expect(logger.stderr).toEqual([ + '[percy] Build not created' + ]); expect(logger.stdout).toEqual(jasmine.arrayContaining([ '[percy] Found 1 snapshot', '[percy] Snapshot found: test-1.png' @@ -187,21 +191,22 @@ describe('percy upload', () => { ' concurrency: 1' ].join('\n')); - let upload = Upload.run(['./images']); + let up = upload(['./images']); // wait for the first upload before terminating - await new Promise(r => (function check() { - if (mockAPI.requests['/builds/123/snapshots']) r(); - else setTimeout(check, 10); + await new Promise(resolve => (function check() { + let done = !!mockAPI.requests['/builds/123/snapshots']; + setTimeout(done ? resolve : check, 10); }())); process.emit('SIGTERM'); - await upload; + await up; expect(logger.stderr).toEqual([]); expect(logger.stdout).toEqual([ '[percy] Percy has started!', '[percy] Uploading 3 snapshots...', + '[percy] Stopping percy...', '[percy] Snapshot uploaded: test-1.png', '[percy] Finalized build #1: https://percy.io/test/test/123' ]);