Skip to content
Draft
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 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;
130 changes: 130 additions & 0 deletions packages/cli-snapshot/test/replay.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { fs, logger, setupTest, api } from '@percy/cli-command/test/helpers';
import { serializeSnapshot } from '@percy/core/archive';
import { replay } from '../src/replay.js';

describe('percy snapshot:replay', () => {
beforeEach(async () => {
replay.packageInformation = { name: '@percy/cli-snapshot' };
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('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
129 changes: 129 additions & 0 deletions packages/core/src/archive.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';

const ARCHIVE_VERSION = 1;
const MAX_FILENAME_LENGTH = 200;
const UNSAFE_CHARS = /[/\\:*?"<>|]/g;

// Validates the archive dir to prevent path traversal attacks.
// Returns the resolved absolute path.
export function validateArchiveDir(archiveDir) {
let resolved = path.resolve(archiveDir);
let normalized = path.normalize(resolved);

// Reject if the normalized path still contains '..' segments
if (normalized.split(path.sep).includes('..')) {
throw new Error(`Invalid archive dir: path traversal detected in "${archiveDir}"`);
}

return resolved;
}

// Sanitizes a snapshot name into a safe filename.
// Strips unsafe characters and appends a hash to prevent collisions.
export function sanitizeFilename(name) {
let safe = name.replace(UNSAFE_CHARS, '_');

if (safe.length > MAX_FILENAME_LENGTH) {
safe = safe.substring(0, MAX_FILENAME_LENGTH);
}

// Append a short hash of the original name for collision prevention
let hash = crypto.createHash('sha256').update(name).digest('hex').substring(0, 8);
return `${safe}-${hash}`;
}

// Serializes a snapshot into a JSON-safe object for archiving.
// Resources have their binary content base64-encoded.
export function serializeSnapshot(snapshot) {
let { resources, ...snapshotData } = snapshot;

return {
version: ARCHIVE_VERSION,
snapshot: snapshotData,
resources: (resources || []).map(r => ({
...r,
content: r.content ? Buffer.from(r.content).toString('base64') : null
}))
};
}

// Validates and deserializes an archived snapshot from parsed JSON.
// Decodes base64 resource content back to Buffers.
export function deserializeSnapshot(data) {
if (!data || typeof data !== 'object') {
throw new Error('Invalid archive: expected an object');
}

if (data.version !== ARCHIVE_VERSION) {
throw new Error(`Unsupported archive version: ${data.version} (expected ${ARCHIVE_VERSION})`);
}

if (!data.snapshot || typeof data.snapshot.name !== 'string' || !data.snapshot.name) {
throw new Error('Invalid archive: missing snapshot name');
}

if (!Array.isArray(data.resources) || data.resources.length === 0) {
throw new Error('Invalid archive: missing or empty resources');
}

return {
...data.snapshot,
resources: data.resources.map(r => ({
...r,
content: r.content ? Buffer.from(r.content, 'base64') : null
}))
};
}

// Archives a single snapshot to the archive directory.
// Creates the directory if it doesn't exist.
export function archiveSnapshot(archiveDir, snapshot) {
fs.mkdirSync(archiveDir, { recursive: true });

let filename = sanitizeFilename(snapshot.name);
let filepath = path.join(archiveDir, `${filename}.json`);
let serialized = serializeSnapshot(snapshot);

fs.writeFileSync(filepath, JSON.stringify(serialized));
}

// Reads all archived snapshots from the given directory.
// Skips symlinks and invalid files with warnings.
export function readArchivedSnapshots(archiveDir, log) {
let resolved = validateArchiveDir(archiveDir);

if (!fs.existsSync(resolved) || !fs.lstatSync(resolved).isDirectory()) {
throw new Error(`Archive directory not found: ${archiveDir}`);
}

let entries = fs.readdirSync(resolved);
let snapshots = [];

for (let entry of entries) {
if (!entry.endsWith('.json')) continue;

let filepath = path.join(resolved, entry);
let stat = fs.lstatSync(filepath);

// Skip symlinks for security
if (stat.isSymbolicLink()) {
log?.warn(`Skipping symlink: ${entry}`);
continue;
}

if (!stat.isFile()) continue;

try {
let raw = fs.readFileSync(filepath, 'utf-8');
let data = JSON.parse(raw);
let snapshot = deserializeSnapshot(data);
snapshots.push(snapshot);
} catch (error) {
log?.warn(`Skipping invalid archive file "${entry}": ${error.message}`);
}
}

return snapshots;
}
3 changes: 3 additions & 0 deletions packages/core/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ export const configSchema = {
deferUploads: {
type: 'boolean'
},
archiveDir: {
type: 'string'
},
useSystemProxy: {
type: 'boolean',
default: false
Expand Down
Loading