Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ oclif.manifest.json
packages/logger/test/client.js
packages/sdk-utils/test/client.js
packs
docs/
docs/
.claude/
8 changes: 8 additions & 0 deletions .semgrepignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,11 @@ test/regression/server.js
# upstream of the join — suppress at the file level with this
# rationale.
packages/core/src/lock.js

# archive.js owns its own path-traversal guard: validateArchiveDir
# resolves and normalizes the input then rejects any `..` segments
# before the join/resolve calls in archiveSnapshot/readArchivedSnapshots
# run. The path-join-resolve-traversal rule does not follow the guard
# (the resolve happens *inside* validateArchiveDir itself) so it flags
# every join. Suppress at the file level with this rationale.
packages/core/src/archive.js
3 changes: 2 additions & 1 deletion packages/cli-exec/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
},
"@percy/cli": {
"commands": [
"./dist/exec.js"
"./dist/exec.js",
"./dist/replay.js"
]
},
"dependencies": {
Expand Down
9 changes: 8 additions & 1 deletion packages/cli-exec/src/exec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import logger from '@percy/logger';
import start from './start.js';
import stop from './stop.js';
import ping from './ping.js';
import replay from './replay.js';
import { waitForTimeout } from '@percy/client/utils';

export const exec = command('exec', {
description: 'Start and stop Percy around a supplied command',
usage: '[options] -- <command>',
commands: [start, stop, ping],
commands: [start, stop, ping, replay],

flags: [{
name: 'parallel',
Expand All @@ -18,6 +19,12 @@ export const exec = command('exec', {
name: 'partial',
description: 'Marks the build as a partial build',
parse: () => !!(process.env.PERCY_PARTIAL_BUILD ||= '1')
}, {
name: 'archive-dir',
description: 'Save snapshot data to an archive directory for deferred upload',
percyrc: 'percy.archiveDir',
type: 'string',
group: 'Percy'
}, {
name: 'testing',
percyrc: 'testing',
Expand Down
1 change: 1 addition & 0 deletions packages/cli-exec/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { default, exec } from './exec.js';
export { start } from './start.js';
export { stop } from './stop.js';
export { ping } from './ping.js';
export { replay } from './replay.js';
50 changes: 50 additions & 0 deletions packages/cli-exec/src/replay.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import fs from 'fs';
import command from '@percy/cli-command';

export const replay = command('replay', {
description: 'Upload archived snapshots to Percy',
args: [{
name: 'archive-dir',
description: 'Directory containing archived snapshots',
required: true,
attribute: val => {
if (!fs.existsSync(val)) throw new Error(`Not found: ${val}`);
if (!fs.lstatSync(val).isDirectory()) throw new Error(`Not a directory: ${val}`);
return 'archiveDir';
}
}],

examples: [
'$0 ./percy-archive'
],

percy: {
deferUploads: true,
skipDiscovery: true
}
}, async function*({ percy, args, log, exit }) {
if (!percy) exit(0, 'Percy is disabled');

let { readArchivedSnapshots } = await import('@percy/core/archive');
let snapshots = readArchivedSnapshots(args.archiveDir, log);

if (!snapshots.length) {
throw new Error('No valid snapshots found in archive');
}

try {
yield* percy.yield.start();

for (let snapshot of snapshots) {
yield* percy.yield.replaySnapshot(snapshot);
}

yield* percy.yield.stop();
} catch (error) {
log.error(error);
await percy.stop(true);
throw error;
}
});

export default replay;
164 changes: 164 additions & 0 deletions packages/cli-exec/test/replay.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { fs, logger, setupTest } from '@percy/cli-command/test/helpers';
import { serializeSnapshot } from '@percy/core/archive';
import { replay } from '../src/replay.js';

describe('percy replay', () => {
beforeEach(async () => {
replay.packageInformation = { name: '@percy/cli-exec' };
process.env.PERCY_TOKEN = '<<PERCY_TOKEN>>';
process.env.PERCY_FORCE_PKG_VALUE = JSON.stringify({ name: '@percy/client', version: '1.0.0' });
process.env.PERCY_CLIENT_ERROR_LOGS = false;
});

afterEach(() => {
delete process.env.PERCY_TOKEN;
delete process.env.PERCY_FORCE_PKG_VALUE;
delete process.env.PERCY_CLIENT_ERROR_LOGS;
delete process.env.PERCY_ENABLE;
delete replay.packageInformation;
});

it('skips when Percy is disabled', async () => {
process.env.PERCY_ENABLE = '0';
await setupTest({
filesystem: { 'archive/.keep': '' }
});
await replay(['./archive']);

expect(logger.stdout).toEqual([]);
expect(logger.stderr).toEqual(jasmine.arrayContaining([
'[percy] Percy is disabled'
]));
});

it('errors when the provided path does not exist', async () => {
await setupTest();
await expectAsync(replay(['./nonexistent'])).toBeRejected();

expect(logger.stderr).toEqual(jasmine.arrayContaining([
'[percy] Error: Not found: ./nonexistent'
]));
});

it('errors when the provided path is not a directory', async () => {
await setupTest({
filesystem: { 'not-a-dir.txt': 'hello' }
});

await expectAsync(replay(['./not-a-dir.txt'])).toBeRejected();

expect(logger.stderr).toEqual(jasmine.arrayContaining([
'[percy] Error: Not a directory: ./not-a-dir.txt'
]));
});

it('errors when the archive directory is empty', async () => {
await setupTest({
filesystem: { 'archive/.keep': '' }
});

// remove the .keep file so only the directory exists
fs.unlinkSync('archive/.keep');

await expectAsync(replay(['./archive'])).toBeRejected();

expect(logger.stderr).toEqual(jasmine.arrayContaining([
'[percy] Error: No valid snapshots found in archive'
]));
});

it('uploads archived snapshots to Percy', async () => {
let archived = serializeSnapshot({
name: 'Test Snapshot',
url: 'http://localhost:8000',
widths: [1280],
minHeight: 1024,
resources: [{
url: 'http://localhost:8000/',
sha: 'abc123',
mimetype: 'text/html',
root: true,
content: Buffer.from('<p>Test</p>')
}]
});

await setupTest({
filesystem: {
'archive/Test_Snapshot-snapshot.json': JSON.stringify(archived)
}
});

await replay(['./archive']);

expect(logger.stdout).toEqual(jasmine.arrayContaining([
'[percy] Percy has started!',
jasmine.stringMatching('\\[percy\\] Replaying snapshot: Test Snapshot')
]));
});

it('logs and stops percy when an upstream error is thrown', async () => {
let archived = serializeSnapshot({
name: 'Boom Snapshot',
url: 'http://localhost:8000',
widths: [1280],
minHeight: 1024,
resources: [{
url: 'http://localhost:8000/',
sha: 'abc123',
mimetype: 'text/html',
root: true,
content: Buffer.from('<p>Test</p>')
}]
});

await setupTest({
filesystem: {
'archive/Boom_Snapshot.json': JSON.stringify(archived)
}
});

let { Percy } = await import('@percy/core');
spyOn(Percy.prototype, 'replaySnapshot').and.callFake(() => ({
[Symbol.asyncIterator]() { return this; },
next() { return Promise.reject(new Error('boom')); }
}));

await expectAsync(replay(['./archive'])).toBeRejectedWithError('boom');

expect(logger.stderr).toEqual(jasmine.arrayContaining([
'[percy] Error: boom'
]));
});

it('skips invalid archive files with warnings', async () => {
let valid = serializeSnapshot({
name: 'Valid Snapshot',
url: 'http://localhost:8000',
widths: [1280],
minHeight: 1024,
resources: [{
url: 'http://localhost:8000/',
sha: 'abc123',
mimetype: 'text/html',
root: true,
content: Buffer.from('<p>Test</p>')
}]
});

await setupTest({
filesystem: {
'archive/valid.json': JSON.stringify(valid),
'archive/invalid.json': '{ "not": "a valid archive" }'
}
});

await replay(['./archive']);

expect(logger.stderr).toEqual(jasmine.arrayContaining([
jasmine.stringMatching('\\[percy\\] Skipping invalid archive file')
]));
expect(logger.stdout).toEqual(jasmine.arrayContaining([
jasmine.stringMatching('\\[percy\\] Replaying snapshot: Valid Snapshot')
]));
});
});
1 change: 1 addition & 0 deletions packages/core/.test-archive-invalid/bad.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "not": "valid" }
1 change: 1 addition & 0 deletions packages/core/.test-archive/My Snapshot-1e082840.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"version":1,"snapshot":{"name":"My Snapshot","url":"http://localhost:8000"},"resources":[{"url":"http://localhost:8000/","sha":"abc123","mimetype":"text/html","root":true,"content":"PHA+SGVsbG88L3A+"}]}
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
},
"./utils": "./dist/utils.js",
"./config": "./dist/config.js",
"./archive": "./dist/archive.js",
"./install": "./dist/install.js",
"./test/helpers": "./test/helpers/index.js",
"./test/helpers/server": "./test/helpers/server.js"
Expand Down
Loading
Loading