Skip to content

Commit

Permalink
feat: ✨ Add ability to snapshot a directory of static images (#353)
Browse files Browse the repository at this point in the history
* feat: ✨ Add ability to snapshot a directory of static images

* ✅ Add integration test for upload command

* feat: ✨ Use image dimensions for snapshot dimensions

* ✅ Add CI steps for snapshot and upload commands

* ✅ Update snapshot command's test script

* fix: 🐛 Use absolute image pathnames and exit with non-zero on error

* feat: ✨🔊 Better logging when uploading static images

* ✅ Appropriately size static test images

* fix: 🐛 Use correct min-height option

* ✅🔧 Skip static-snapshot test

It currently uploads 0 snapshots and needs to be investigated

* feat: ✨ Error and exit when no matching files can be found

* 📝 Add upload command to readme

* feat: ✨ Add ignore option to image upload command
  • Loading branch information
Wil Wilsman authored Sep 25, 2019
1 parent a6034d2 commit 96f1ed5
Show file tree
Hide file tree
Showing 11 changed files with 280 additions and 2 deletions.
9 changes: 8 additions & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
command: ./bin/run --help
- run:
name: Build package
command: npm run build
command: yarn build
- run:
name: Unit tests
command: |
Expand All @@ -36,6 +36,13 @@ jobs:
- run:
name: Client tests
command: $NYC yarn test-client --singleRun
# [skipped] this uploads 0 snapshots and needs to be fixed
# - run:
# name: Snapshot command test
# command: yarn test-snapshot-command
- run:
name: Upload command test
command: yarn test-upload-command
- run:
name: Setup integration test .percy.yml
command: mv .ci.percy.yml .percy.yml
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ USAGE
* [`percy finalize`](#percy-finalize)
* [`percy help [COMMAND]`](#percy-help-command)
* [`percy snapshot SNAPSHOTDIRECTORY`](#percy-snapshot-snapshotdirectory)
* [`percy upload UPLOADDIRECTORY`](#percy-upload-uploaddirectory)

## `percy exec`

Expand Down Expand Up @@ -119,4 +120,26 @@ EXAMPLES
$ percy snapshot _site/ --base-url "/blog/"
$ percy snapshot _site/ --ignore-files "/blog/drafts/**"
```

## `percy upload UPLOADDIRECTORY`

Upload a directory containing static snapshot images.

```
USAGE
$ percy upload UPLOADDIRECTORY
ARGUMENTS
UPLOADDIRECTORY A path to the directory containing static snapshot images
OPTIONS
-f, --files=files [default: **/*.png,**/*.jpg,**/*.jpeg] Glob or comma-seperated string of globs for matching the
files and directories to snapshot.
-i, --ignore=ignore Glob or comma-seperated string of globs for matching the files and directories to ignore.
EXAMPLES
$ percy upload _images/
$ percy upload _images/ --files **/*.png
```
<!-- commandsstop -->
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"follow-redirects": "^1.9.0",
"generic-pool": "^3.7.1",
"globby": "^10.0.1",
"image-size": "^0.8.2",
"js-yaml": "^3.13.1",
"percy-client": "^3.0.3",
"puppeteer": "^1.13.0",
Expand Down Expand Up @@ -144,7 +145,8 @@
"test": "yarn build-client && PERCY_TOKEN=abc mocha --forbid-only \"test/**/*.test.ts\" --exclude \"test/percy-agent-client/**/*.test.ts\" --exclude \"test/integration/**/*\"",
"test-client": "karma start ./test/percy-agent-client/karma.conf.js",
"test-integration": "yarn build-client && node ./bin/run exec -h *.localtest.me -- mocha test/integration/**/*.test.ts",
"test-snapshot-command": "./bin/run snapshot test/integration/test-static-site -b /dummy-base-url -i '(red-keep)' -c '\\.(html)$'",
"test-snapshot-command": "./bin/run snapshot test/integration/test-static-site -b /dummy-base-url -i '(red-keep)' -s '\\.(html)$'",
"test-upload-command": "./bin/run upload test/integration/test-static-images",
"version": "oclif-dev readme && git add README.md",
"watch": "npm-watch"
},
Expand Down
58 changes: 58 additions & 0 deletions src/commands/upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Command, flags } from '@oclif/command'
import { DEFAULT_CONFIGURATION } from '../configuration/configuration'
import ConfigurationService from '../services/configuration-service'
import ImageSnapshotService from '../services/image-snapshot-service'

export default class Upload extends Command {
static description = 'Upload a directory containing static snapshot images.'
static hidden = false

static args = [{
name: 'uploadDirectory',
description: 'A path to the directory containing static snapshot images',
required: true,
}]

static examples = [
'$ percy upload _images/',
'$ percy upload _images/ --files **/*.png',
]

static flags = {
files: flags.string({
char: 'f',
description: 'Glob or comma-seperated string of globs for matching the files and directories to snapshot.',
default: DEFAULT_CONFIGURATION['image-snapshots'].files,
}),
ignore: flags.string({
char: 'i',
description: 'Glob or comma-seperated string of globs for matching the files and directories to ignore.',
default: DEFAULT_CONFIGURATION['image-snapshots'].ignore,
}),
}

percyToken: string = process.env.PERCY_TOKEN || ''

percyTokenPresent(): boolean {
return this.percyToken.trim() !== ''
}

async run() {
// exit gracefully if percy token was not provided
if (!this.percyTokenPresent()) {
this.warn('PERCY_TOKEN was not provided.')
this.exit(0)
}

const { args, flags } = this.parse(Upload)

const configurationService = new ConfigurationService()
configurationService.applyFlags(flags)
configurationService.applyArgs(args)
const configuration = configurationService.configuration

// upload snapshot images
const imageSnapshotService = new ImageSnapshotService(configuration['image-snapshots'])
await imageSnapshotService.snapshotAll()
}
}
7 changes: 7 additions & 0 deletions src/configuration/configuration.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { DEFAULT_PORT } from '../services/agent-service-constants'
import { AgentConfiguration } from './agent-configuration'
import { ImageSnapshotsConfiguration } from './image-snapshots-configuration'
import { SnapshotConfiguration } from './snapshot-configuration'
import { StaticSnapshotsConfiguration } from './static-snapshots-configuration'

export interface Configuration {
version: number,
snapshot: SnapshotConfiguration
'static-snapshots': StaticSnapshotsConfiguration
'image-snapshots': ImageSnapshotsConfiguration
agent: AgentConfiguration
}

Expand All @@ -33,4 +35,9 @@ export const DEFAULT_CONFIGURATION: Configuration = {
'ignore-files': '',
'port': DEFAULT_PORT + 1,
},
'image-snapshots': {
path: '.',
files: '**/*.png,**/*.jpg,**/*.jpeg',
ignore: '',
},
}
5 changes: 5 additions & 0 deletions src/configuration/image-snapshots-configuration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface ImageSnapshotsConfiguration {
path: string,
files: string,
ignore: string,
}
12 changes: 12 additions & 0 deletions src/services/configuration-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ export default class ConfigurationService {
this.configuration['static-snapshots']['ignore-files'] = flags['ignore-files']
}

if (flags.files) {
this.configuration['image-snapshots'].files = flags.files
}

if (flags.ignore) {
this.configuration['image-snapshots'].ignore = flags.ignore
}

return this.configuration
}

Expand All @@ -68,6 +76,10 @@ export default class ConfigurationService {
this.configuration['static-snapshots'].path = args.snapshotDirectory
}

if (args.uploadDirectory) {
this.configuration['image-snapshots'].path = args.uploadDirectory
}

return this.configuration
}
}
150 changes: 150 additions & 0 deletions src/services/image-snapshot-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import * as crypto from 'crypto'
import * as fs from 'fs'
import * as globby from 'globby'
import { imageSize } from 'image-size'
import * as os from 'os'
import * as path from 'path'

import { DEFAULT_CONFIGURATION } from '../configuration/configuration'
import { ImageSnapshotsConfiguration } from '../configuration/image-snapshots-configuration'
import logger, { logError, profile } from '../utils/logger'
import BuildService from './build-service'
import PercyClientService from './percy-client-service'

const ALLOWED_IMAGE_TYPES = /\.(png|jpg|jpeg)$/i

export default class ImageSnapshotService extends PercyClientService {
private readonly buildService: BuildService
private readonly configuration: ImageSnapshotsConfiguration

constructor(configuration?: ImageSnapshotsConfiguration) {
super()

this.buildService = new BuildService()
this.configuration = configuration || DEFAULT_CONFIGURATION['image-snapshots']
}

get buildId() {
return this.buildService.buildId
}

makeLocalCopy(imagePath: string) {
logger.debug(`Making local copy of image: ${imagePath}`)

const buffer = fs.readFileSync(path.resolve(this.configuration.path, imagePath))
const sha = crypto.createHash('sha256').update(buffer).digest('hex')
const filename = path.join(os.tmpdir(), sha)

if (!fs.existsSync(filename)) {
fs.writeFileSync(filename, buffer)
} else {
logger.debug(`Skipping file copy [already_copied]: ${imagePath}`)
}

return filename
}

buildResources(imagePath: string): any[] {
const { name, ext } = path.parse(imagePath)
const localCopy = this.makeLocalCopy(imagePath)
const mimetype = ext === '.png' ? 'image/png' : 'image/jpeg'
const sha = path.basename(localCopy)

const rootResource = this.percyClient.makeResource({
isRoot: true,
resourceUrl: `/${name}`,
mimetype: 'text/html',
content: `
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>${imagePath}</title>
<style>
html, body, img { width: 100%; margin: 0; padding: 0; font-size: 0; }
</style>
</head>
<body>
<img src="/${imagePath}"/>
</body>
</html>
`,
})

const imgResource = this.percyClient.makeResource({
resourceUrl: `/${imagePath}`,
localPath: localCopy,
mimetype,
sha,
})

return [rootResource, imgResource]
}

async createSnapshot(
name: string,
resources: any[],
width: number,
height: number,
): Promise<any> {
return this.percyClient.createSnapshot(this.buildId, resources, {
name,
widths: [width],
minimumHeight: height,
}).then(async (response: any) => {
await this.percyClient.uploadMissingResources(this.buildId, response, resources)
return response
}).then(async (response: any) => {
const snapshotId = response.body.data.id
profile('-> imageSnapshotService.finalizeSnapshot')
await this.percyClient.finalizeSnapshot(snapshotId)
profile('-> imageSnapshotService.finalizeSnapshot', { snapshotId })
return response
}).catch(logError)
}

async snapshotAll() {
try {
// intentially remove '' values from because that matches every file
const globs = this.configuration.files.split(',').filter(Boolean)
const ignore = this.configuration.ignore.split(',').filter(Boolean)
const paths = await globby(globs, { cwd: this.configuration.path, ignore })

if (!paths.length) {
logger.error(`no matching files found in '${this.configuration.path}''`)
logger.info('exiting')
return process.exit(1)
}

await this.buildService.create()
logger.debug('uploading snapshots of static images')

// wait for snapshots in parallel
await Promise.all(paths.reduce((promises, pathname) => {
logger.debug(`handling snapshot: '${pathname}'`)

// only snapshot supported images
if (!pathname.match(ALLOWED_IMAGE_TYPES)) {
logger.info(`skipping unsupported image type: '${pathname}'`)
return promises
}

// @ts-ignore - if dimensions are undefined, the library throws an error
const { width, height } = imageSize(path.resolve(this.configuration.path, pathname))

const resources = this.buildResources(pathname)
const snapshotPromise = this.createSnapshot(pathname, resources, width, height)
logger.info(`snapshot uploaded: '${pathname}'`)
promises.push(snapshotPromise)

return promises
}, [] as any[]))

// finalize build
await this.buildService.finalize()
} catch (error) {
logError(error)
process.exit(1)
}
}
}
Binary file added test/integration/test-static-images/percy.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5083,6 +5083,13 @@ ignore@^5.1.1:
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.4.tgz#84b7b3dbe64552b6ef0eca99f6743dbec6d97adf"
integrity sha512-MzbUSahkTW1u7JpKKjY7LCARd1fU5W2rLdxlM4kdkayuCwZImjkpluF9CM1aLewYJguPDqewLam18Y6AU69A8A==

image-size@^0.8.2:
version "0.8.2"
resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.8.2.tgz#9b5cef7a18a0991beba861b731fb4cfe7a45822d"
integrity sha512-0AO8bEDtAcC+dScZmCDUvmxIYWlJ+0DQOl1BkTQYrrM3/oQORS03P0gDT7ZoElRozHlfoUxT+L2ErLFmbT5tdA==
dependencies:
queue "6.0.1"

import-fresh@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546"
Expand Down Expand Up @@ -8136,6 +8143,13 @@ querystring@0.2.0:
resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620"
integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=

queue@6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/queue/-/queue-6.0.1.tgz#abd5a5b0376912f070a25729e0b6a7d565683791"
integrity sha512-AJBQabRCCNr9ANq8v77RJEv73DPbn55cdTb+Giq4X0AVnNVZvMHlYp7XlQiN+1npCZj1DuSmaA2hYVUUDgxFDg==
dependencies:
inherits "~2.0.3"

quick-lru@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-1.1.0.tgz#4360b17c61136ad38078397ff11416e186dcfbb8"
Expand Down

0 comments on commit 96f1ed5

Please sign in to comment.