Skip to content

Commit

Permalink
✨ Add @percy/cli update notice (#678)
Browse files Browse the repository at this point in the history
  • Loading branch information
Wil Wilsman authored Dec 16, 2021
1 parent 5464cc6 commit 7dff315
Show file tree
Hide file tree
Showing 7 changed files with 267 additions and 8 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ coverage
dist
oclif.manifest.json
.DS_Store
.releases
.local-chromium
packages/logger/test/client.js
packages/sdk-utils/test/client.js
8 changes: 5 additions & 3 deletions packages/cli/bin/run
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ if (parseInt(process.version.split('.')[0].substring(1), 10) < 12) {
process.exit(1);
}

import('@percy/cli').then(async ({ percy }) => {
await percy(process.argv.slice(2));
});
import('@percy/cli').then(
async ({ checkForUpdate, percy }) => {
await checkForUpdate();
await percy(process.argv.slice(2));
});
1 change: 1 addition & 0 deletions packages/cli/src/index.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { default, percy } from './percy';
export { checkForUpdate } from './update';
97 changes: 97 additions & 0 deletions packages/cli/src/update.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import fs from 'fs';
import path from 'path';
import logger from '@percy/logger';
import { colors } from '@percy/logger/dist/util';
import pkg from '../package.json';

// filepath where the cache will be read and written to
const CACHE_FILE = path.join(__dirname, '..', '.releases');
// max age the cache should be used for (3 days)
const CACHE_MAX_AGE = 3 * 24 * 60 * 60 * 1000;

// Safely read from CACHE_FILE and return an object containing `data` mirroring what was previously
// written using `writeToCache(data)`. An empty object is returned when older than CACHE_MAX_AGE,
// and an `error` will be present if one was encountered.
function readFromCache() {
let cached = {};

try {
if (fs.existsSync(CACHE_FILE)) {
let { createdAt, data } = JSON.parse(fs.readFileSync(CACHE_FILE));
if ((Date.now() - createdAt) < CACHE_MAX_AGE) cached.data = data;
}
} catch (error) {
let log = logger('cli:update:cache');
log.debug('Unable to read from cache');
log.debug(cached.error = error);
}

return cached;
}

// Safely write data to CACHE_FILE with the current timestamp.
function writeToCache(data) {
try {
fs.writeFileSync(CACHE_FILE, JSON.stringify({
createdAt: Date.now(),
data
}));
} catch (error) {
let log = logger('cli:update:cache');
log.debug('Unable to write to cache');
log.debug(error);
}
}

// Fetch and return release information for @percy/cli.
async function fetchReleases() {
let { request } = await import('@percy/client/dist/request');

// fetch releases from the github api without retries
let api = 'https://api.github.com/repos/percy/cli/releases';
let data = await request(api, {
headers: { 'User-Agent': pkg.name },
retries: 0
});

// return relevant information
return data.map(r => ({
tag: r.tag_name,
prerelease: r.prerelease
}));
}

// Check for updates by comparing latest releases with the current version. The result of the check
// is cached to speed up subsequent CLI usage.
export async function checkForUpdate() {
let { data: releases, error: cacheError } = readFromCache();
let log = logger('cli:update');

try {
// request new release information if needed
if (!releases) {
releases = await fetchReleases();
if (!cacheError) writeToCache(releases, log);
}

// check the current package version against released versions
let versions = releases.map(r => r.tag.substr(1));
let age = versions.indexOf(pkg.version);

// a new version is available
if (age !== 0) {
let range = `${colors.red(pkg.version)} -> ${colors.green(versions[0])}`;

log.stderr.write('\n');
log.warn(`${age > 0 && age < 10 ? 'A new version of @percy/cli is available!' : (
'Heads up! The current version of @percy/cli is more than 10 releases behind!'
)} ${range}`);
log.stderr.write('\n');
}
} catch (err) {
log.debug('Unable to check for updates');
log.debug(err);
}
}

export default checkForUpdate;
2 changes: 1 addition & 1 deletion packages/cli/test/commands.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ describe('CLI commands', () => {

it('handles errors and logs debug info', async () => {
mockfs.mkdirSync('node_modules', { recursive: true });
spyOn(require('fs'), 'readdirSync').and.throwError(new Error('EACCES'));
mockfs.spyOn('readdirSync').and.throwError(new Error('EACCES'));
await expectAsync(importCommands()).toBeResolvedTo([]);
expect(logger.stdout).toEqual([]);
expect(logger.stderr).toEqual([
Expand Down
17 changes: 13 additions & 4 deletions packages/cli/test/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,19 @@ import mockRequire from 'mock-require';
export { mockRequire };

// Helper function to mock fs with memfs and proxy methods to memfs.vol
export const mockfs = new Proxy(
() => mockRequire('fs', memfs.fs),
{ get: (_, k) => (...a) => memfs.vol[k](...a) }
);
export const mockfs = new Proxy(() => {
mockfs.mkdirSync(process.cwd(), { recursive: true });
return mockRequire('fs', memfs.fs);
}, {
get: (_, prop) => prop === 'spyOn'
? spyOn.bind(null, memfs.fs)
: memfs.vol[prop].bind(memfs.vol)
});

// Mocks the update cache file with the provided data and timestamp
export function mockUpdateCache(data, createdAt = Date.now()) {
mockfs.writeFileSync('.releases', JSON.stringify({ data, createdAt }));
}

// Mocks the filesystem and require cache to simulate installed commands
export function mockModuleCommands(atPath, cmdMocks) {
Expand Down
149 changes: 149 additions & 0 deletions packages/cli/test/update.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import nock from 'nock';
import logger from '@percy/logger/test/helpers';

import {
mockfs,
mockRequire,
mockUpdateCache
} from './helpers';

describe('CLI update check', () => {
let checkForUpdate, request;

beforeEach(async () => {
mockfs();
logger.mock();

request = nock('https://api.github.com/repos/percy/cli', {
reqheaders: { 'User-Agent': ua => !!ua }
});

mockRequire('../package.json', { name: '@percy/cli', version: '1.0.0' });
({ checkForUpdate } = mockRequire.reRequire('../src/update'));
});

afterEach(() => {
mockfs.reset();
nock.cleanAll();
});

it('fetches and caches the latest release information', async () => {
request.get('/releases').reply(200, [{ tag_name: 'v1.0.0' }]);

expect(mockfs.existsSync('.releases')).toBe(false);

await checkForUpdate();
expect(logger.stdout).toEqual([]);
expect(logger.stderr).toEqual([]);
expect(request.isDone()).toBe(true);

expect(mockfs.existsSync('.releases')).toBe(true);
expect(JSON.parse(mockfs.readFileSync('.releases')))
.toHaveProperty('data', [{ tag: 'v1.0.0' }]);
});

it('does not fetch the latest release information if cached', async () => {
request.get('/releases').reply(200, [{ tag_name: 'v1.0.0' }]);
mockUpdateCache([{ tag: 'v1.0.0' }]);

await checkForUpdate();
expect(logger.stdout).toEqual([]);
expect(logger.stderr).toEqual([]);
expect(request.isDone()).toBe(false);
});

it('fetchs the latest release information if the cache is outdated', async () => {
request.get('/releases').reply(200, [{ tag_name: 'v1.0.0' }]);

let cacheCreatedAt = Date.now() - (30 * 24 * 60 * 60 * 1000);
mockUpdateCache([{ tag: 'v0.2.0' }, { tag: 'v0.1.0' }], cacheCreatedAt);

await checkForUpdate();
expect(logger.stdout).toEqual([]);
expect(logger.stderr).toEqual([]);
expect(request.isDone()).toBe(true);

expect(JSON.parse(mockfs.readFileSync('.releases')))
.toHaveProperty('data', [{ tag: 'v1.0.0' }]);
});

it('warns when a new version is available', async () => {
mockUpdateCache([{ tag: 'v1.1.0' }, { tag: 'v1.0.0' }]);

await checkForUpdate();
expect(logger.stdout).toEqual([]);
expect(logger.stderr).toEqual([
'', '[percy] A new version of @percy/cli is available! 1.0.0 -> 1.1.0', ''
]);
});

it('warns when the current version is outdated', async () => {
mockUpdateCache([{ tag: 'v2.0.0' }]);

await checkForUpdate();
expect(logger.stdout).toEqual([]);
expect(logger.stderr).toEqual([
'', '[percy] Heads up! The current version of @percy/cli ' +
'is more than 10 releases behind! 1.0.0 -> 2.0.0', ''
]);
});

it('handles errors reading from cache and logs debug info', async () => {
mockUpdateCache([{ tag: 'v1.0.0' }]);
mockfs.spyOn('readFileSync').and.throwError(new Error('EACCES'));
request.get('/releases').reply(200, [{ tag_name: 'v1.0.0' }]).persist();

await checkForUpdate();
expect(logger.stdout).toEqual([]);
expect(logger.stderr).toEqual([]);

logger.loglevel('debug');

await checkForUpdate();
expect(logger.stdout).toEqual([]);
expect(logger.stderr).toEqual([
'[percy:cli:update:cache] Unable to read from cache',
jasmine.stringContaining('[percy:cli:update:cache] Error: EACCES')
]);

expect(request.isDone()).toEqual(true);
});

it('handles errors writing to cache and logs debug info', async () => {
mockfs.spyOn('writeFileSync').and.throwError(new Error('EACCES'));
request.get('/releases').reply(200, [{ tag_name: 'v1.0.0' }]).persist();

await checkForUpdate();
expect(logger.stdout).toEqual([]);
expect(logger.stderr).toEqual([]);

logger.loglevel('debug');

await checkForUpdate();
expect(logger.stdout).toEqual([]);
expect(logger.stderr).toEqual([
'[percy:cli:update:cache] Unable to write to cache',
jasmine.stringContaining('[percy:cli:update:cache] Error: EACCES')
]);

expect(request.isDone()).toEqual(true);
expect(mockfs.existsSync('.releases')).toBe(false);
});

it('handles request errors and logs debug info', async () => {
request.get('/releases').reply(503).persist();

await checkForUpdate();
expect(logger.stdout).toEqual([]);
expect(logger.stderr).toEqual([]);

logger.loglevel('debug');

await checkForUpdate();
expect(logger.stdout).toEqual([]);
expect(logger.stderr).toEqual([
'[percy:cli:update] Unable to check for updates',
jasmine.stringContaining('[percy:cli:update] Error: 503')
]);
});
});

0 comments on commit 7dff315

Please sign in to comment.