@percy/core 1.31.14-beta.1 → 1.31.14-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/archive.js +117 -0
- package/dist/config.js +3 -0
- package/dist/percy.js +54 -6
- package/package.json +10 -9
package/dist/archive.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
const ARCHIVE_VERSION = 1;
|
|
5
|
+
const MAX_FILENAME_LENGTH = 200;
|
|
6
|
+
const UNSAFE_CHARS = /[/\\:*?"<>|]/g;
|
|
7
|
+
|
|
8
|
+
// Validates the archive dir to prevent path traversal attacks.
|
|
9
|
+
// Returns the resolved absolute path.
|
|
10
|
+
export function validateArchiveDir(archiveDir) {
|
|
11
|
+
let resolved = path.resolve(archiveDir);
|
|
12
|
+
let normalized = path.normalize(resolved);
|
|
13
|
+
|
|
14
|
+
// Reject if the normalized path still contains '..' segments
|
|
15
|
+
if (normalized.split(path.sep).includes('..')) {
|
|
16
|
+
throw new Error(`Invalid archive dir: path traversal detected in "${archiveDir}"`);
|
|
17
|
+
}
|
|
18
|
+
return resolved;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Sanitizes a snapshot name into a safe filename.
|
|
22
|
+
// Strips unsafe characters and appends a hash to prevent collisions.
|
|
23
|
+
export function sanitizeFilename(name) {
|
|
24
|
+
let safe = name.replace(UNSAFE_CHARS, '_');
|
|
25
|
+
if (safe.length > MAX_FILENAME_LENGTH) {
|
|
26
|
+
safe = safe.substring(0, MAX_FILENAME_LENGTH);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Append a short hash of the original name for collision prevention
|
|
30
|
+
let hash = crypto.createHash('sha256').update(name).digest('hex').substring(0, 8);
|
|
31
|
+
return `${safe}-${hash}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Serializes a snapshot into a JSON-safe object for archiving.
|
|
35
|
+
// Resources have their binary content base64-encoded.
|
|
36
|
+
export function serializeSnapshot(snapshot) {
|
|
37
|
+
let {
|
|
38
|
+
resources,
|
|
39
|
+
...snapshotData
|
|
40
|
+
} = snapshot;
|
|
41
|
+
return {
|
|
42
|
+
version: ARCHIVE_VERSION,
|
|
43
|
+
snapshot: snapshotData,
|
|
44
|
+
resources: (resources || []).map(r => ({
|
|
45
|
+
...r,
|
|
46
|
+
content: r.content ? Buffer.from(r.content).toString('base64') : null
|
|
47
|
+
}))
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Validates and deserializes an archived snapshot from parsed JSON.
|
|
52
|
+
// Decodes base64 resource content back to Buffers.
|
|
53
|
+
export function deserializeSnapshot(data) {
|
|
54
|
+
if (!data || typeof data !== 'object') {
|
|
55
|
+
throw new Error('Invalid archive: expected an object');
|
|
56
|
+
}
|
|
57
|
+
if (data.version !== ARCHIVE_VERSION) {
|
|
58
|
+
throw new Error(`Unsupported archive version: ${data.version} (expected ${ARCHIVE_VERSION})`);
|
|
59
|
+
}
|
|
60
|
+
if (!data.snapshot || typeof data.snapshot.name !== 'string' || !data.snapshot.name) {
|
|
61
|
+
throw new Error('Invalid archive: missing snapshot name');
|
|
62
|
+
}
|
|
63
|
+
if (!Array.isArray(data.resources) || data.resources.length === 0) {
|
|
64
|
+
throw new Error('Invalid archive: missing or empty resources');
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
...data.snapshot,
|
|
68
|
+
resources: data.resources.map(r => ({
|
|
69
|
+
...r,
|
|
70
|
+
content: r.content ? Buffer.from(r.content, 'base64') : null
|
|
71
|
+
}))
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Archives a single snapshot to the archive directory.
|
|
76
|
+
// Creates the directory if it doesn't exist.
|
|
77
|
+
export function archiveSnapshot(archiveDir, snapshot) {
|
|
78
|
+
fs.mkdirSync(archiveDir, {
|
|
79
|
+
recursive: true
|
|
80
|
+
});
|
|
81
|
+
let filename = sanitizeFilename(snapshot.name);
|
|
82
|
+
let filepath = path.join(archiveDir, `${filename}.json`);
|
|
83
|
+
let serialized = serializeSnapshot(snapshot);
|
|
84
|
+
fs.writeFileSync(filepath, JSON.stringify(serialized));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Reads all archived snapshots from the given directory.
|
|
88
|
+
// Skips symlinks and invalid files with warnings.
|
|
89
|
+
export function readArchivedSnapshots(archiveDir, log) {
|
|
90
|
+
let resolved = validateArchiveDir(archiveDir);
|
|
91
|
+
if (!fs.existsSync(resolved) || !fs.lstatSync(resolved).isDirectory()) {
|
|
92
|
+
throw new Error(`Archive directory not found: ${archiveDir}`);
|
|
93
|
+
}
|
|
94
|
+
let entries = fs.readdirSync(resolved);
|
|
95
|
+
let snapshots = [];
|
|
96
|
+
for (let entry of entries) {
|
|
97
|
+
if (!entry.endsWith('.json')) continue;
|
|
98
|
+
let filepath = path.join(resolved, entry);
|
|
99
|
+
let stat = fs.lstatSync(filepath);
|
|
100
|
+
|
|
101
|
+
// Skip symlinks for security
|
|
102
|
+
if (stat.isSymbolicLink()) {
|
|
103
|
+
log === null || log === void 0 || log.warn(`Skipping symlink: ${entry}`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (!stat.isFile()) continue;
|
|
107
|
+
try {
|
|
108
|
+
let raw = fs.readFileSync(filepath, 'utf-8');
|
|
109
|
+
let data = JSON.parse(raw);
|
|
110
|
+
let snapshot = deserializeSnapshot(data);
|
|
111
|
+
snapshots.push(snapshot);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
log === null || log === void 0 || log.warn(`Skipping invalid archive file "${entry}": ${error.message}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return snapshots;
|
|
117
|
+
}
|
package/dist/config.js
CHANGED
package/dist/percy.js
CHANGED
|
@@ -43,6 +43,8 @@ export class Percy {
|
|
|
43
43
|
constructor({
|
|
44
44
|
// initial log level
|
|
45
45
|
loglevel,
|
|
46
|
+
// path to save snapshot data to disk
|
|
47
|
+
archiveDir,
|
|
46
48
|
// process uploads before the next snapshot
|
|
47
49
|
delayUploads,
|
|
48
50
|
// process uploads after all snapshots
|
|
@@ -83,6 +85,7 @@ export class Percy {
|
|
|
83
85
|
});
|
|
84
86
|
labels ?? (labels = (_config$percy = config.percy) === null || _config$percy === void 0 ? void 0 : _config$percy.labels);
|
|
85
87
|
deferUploads ?? (deferUploads = (_config$percy2 = config.percy) === null || _config$percy2 === void 0 ? void 0 : _config$percy2.deferUploads);
|
|
88
|
+
if (archiveDir) skipUploads = skipUploads != null ? skipUploads : true;
|
|
86
89
|
this.config = config;
|
|
87
90
|
this.cliStartTime = null;
|
|
88
91
|
if (testing) loglevel = 'silent';
|
|
@@ -95,6 +98,7 @@ export class Percy {
|
|
|
95
98
|
this.skipDiscovery = this.dryRun || !!skipDiscovery;
|
|
96
99
|
this.delayUploads = this.skipUploads || !!delayUploads;
|
|
97
100
|
this.deferUploads = this.skipUploads || !!deferUploads;
|
|
101
|
+
this.archiveDir = this.skipUploads && archiveDir ? archiveDir : null;
|
|
98
102
|
this.labels = labels;
|
|
99
103
|
this.suggestionsCallCounter = suggestionsCallCounter;
|
|
100
104
|
this.client = new PercyClient({
|
|
@@ -132,7 +136,7 @@ export class Percy {
|
|
|
132
136
|
};
|
|
133
137
|
|
|
134
138
|
// generator methods are wrapped to autorun and return promises
|
|
135
|
-
for (let m of ['start', 'stop', 'flush', 'idle', 'snapshot', 'upload']) {
|
|
139
|
+
for (let m of ['start', 'stop', 'flush', 'idle', 'snapshot', 'upload', 'replaySnapshot']) {
|
|
136
140
|
// the original generator can be referenced with percy.yield.<method>
|
|
137
141
|
let method = (this.yield || (this.yield = {}))[m] = this[m].bind(this);
|
|
138
142
|
this[m] = (...args) => generatePromise(method(...args));
|
|
@@ -259,6 +263,15 @@ export class Percy {
|
|
|
259
263
|
await this.loadAutoConfiguredHostnames();
|
|
260
264
|
}
|
|
261
265
|
|
|
266
|
+
// validate and log archive dir if configured
|
|
267
|
+
if (this.archiveDir) {
|
|
268
|
+
let {
|
|
269
|
+
validateArchiveDir
|
|
270
|
+
} = await import('./archive.js');
|
|
271
|
+
this.archiveDir = validateArchiveDir(this.archiveDir);
|
|
272
|
+
this.log.info(`Archiving snapshots to: ${this.archiveDir}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
262
275
|
// start the snapshots queue immediately when not delayed or deferred
|
|
263
276
|
if (!this.delayUploads && !this.deferUploads) yield _classPrivateFieldGet(_snapshots, this).start();
|
|
264
277
|
// do not start the discovery queue when not needed
|
|
@@ -400,6 +413,11 @@ export class Percy {
|
|
|
400
413
|
this.log.info(info('Found', _classPrivateFieldGet(_snapshots, this).size));
|
|
401
414
|
}
|
|
402
415
|
|
|
416
|
+
// log archive summary
|
|
417
|
+
if (this.archiveDir && _classPrivateFieldGet(_snapshots, this).size) {
|
|
418
|
+
this.log.info(`Archived ${_classPrivateFieldGet(_snapshots, this).size} snapshot(s) to: ${this.archiveDir}`);
|
|
419
|
+
}
|
|
420
|
+
|
|
403
421
|
// Save domain validation config before closing
|
|
404
422
|
if (!this.skipUploads && !this.skipDiscovery) {
|
|
405
423
|
await this.saveHostnamesToAutoConfigure();
|
|
@@ -611,6 +629,17 @@ export class Percy {
|
|
|
611
629
|
config: this.config
|
|
612
630
|
})
|
|
613
631
|
}, snapshot => {
|
|
632
|
+
// archive snapshot to disk if configured
|
|
633
|
+
if (this.archiveDir) {
|
|
634
|
+
import('./archive.js').then(({
|
|
635
|
+
archiveSnapshot
|
|
636
|
+
}) => {
|
|
637
|
+
archiveSnapshot(this.archiveDir, snapshot);
|
|
638
|
+
}).catch(err => {
|
|
639
|
+
this.log.error(`Failed to archive snapshot "${snapshot.name}": ${err.message}`);
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
614
643
|
// attaching promise resolve reject so to wait for snapshot to complete
|
|
615
644
|
if (this.syncMode(snapshot)) {
|
|
616
645
|
snapshotPromise[snapshot.name] = new Promise((resolve, reject) => {
|
|
@@ -700,6 +729,25 @@ export class Percy {
|
|
|
700
729
|
}
|
|
701
730
|
}.call(this);
|
|
702
731
|
}
|
|
732
|
+
|
|
733
|
+
// Pushes a pre-built snapshot directly to the upload queue without discovery.
|
|
734
|
+
// Used by the replay command to upload previously archived snapshots.
|
|
735
|
+
*replaySnapshot(snapshot) {
|
|
736
|
+
var _this$build4;
|
|
737
|
+
if (this.readyState !== 1) {
|
|
738
|
+
throw new Error('Not running');
|
|
739
|
+
} else if ((_this$build4 = this.build) !== null && _this$build4 !== void 0 && _this$build4.error) {
|
|
740
|
+
throw new Error(this.build.error);
|
|
741
|
+
}
|
|
742
|
+
snapshot.meta = {
|
|
743
|
+
snapshot: {
|
|
744
|
+
name: snapshot.name,
|
|
745
|
+
testCase: snapshot.testCase
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
this.log.info(`Replaying snapshot: ${snapshot.name}`, snapshot.meta);
|
|
749
|
+
_classPrivateFieldGet(_snapshots, this).push(snapshot);
|
|
750
|
+
}
|
|
703
751
|
shouldSkipAssetDiscovery(tokenType) {
|
|
704
752
|
if (this.testing && JSON.stringify(this.testing) === JSON.stringify({})) {
|
|
705
753
|
return true;
|
|
@@ -780,7 +828,7 @@ export class Percy {
|
|
|
780
828
|
async sendBuildLogs() {
|
|
781
829
|
if (!process.env.PERCY_TOKEN) return;
|
|
782
830
|
try {
|
|
783
|
-
var _this$
|
|
831
|
+
var _this$build5, _this$build6, _this$build7, _this$build8;
|
|
784
832
|
const logsObject = {
|
|
785
833
|
clilogs: logger.query(log => !['ci'].includes(log.debug))
|
|
786
834
|
};
|
|
@@ -792,10 +840,10 @@ export class Percy {
|
|
|
792
840
|
logsObject.cilogs = redactedContent;
|
|
793
841
|
}
|
|
794
842
|
const content = base64encode(Pako.gzip(JSON.stringify(logsObject)));
|
|
795
|
-
const referenceId = (_this$
|
|
843
|
+
const referenceId = (_this$build5 = this.build) !== null && _this$build5 !== void 0 && _this$build5.id ? `build_${(_this$build6 = this.build) === null || _this$build6 === void 0 ? void 0 : _this$build6.id}` : (_this$build7 = this.build) === null || _this$build7 === void 0 ? void 0 : _this$build7.id;
|
|
796
844
|
const eventObject = {
|
|
797
845
|
content: content,
|
|
798
|
-
build_id: (_this$
|
|
846
|
+
build_id: (_this$build8 = this.build) === null || _this$build8 === void 0 ? void 0 : _this$build8.id,
|
|
799
847
|
reference_id: referenceId,
|
|
800
848
|
service_name: 'cli',
|
|
801
849
|
base64encoded: true
|
|
@@ -868,9 +916,9 @@ export class Percy {
|
|
|
868
916
|
const newAllowedDomains = Array.from(processedHosts).filter(domain => !autoConfiguredHosts.has(domain));
|
|
869
917
|
const hasNewDomains = newAllowedDomains.length > 0 || newErrorHosts.size > 0;
|
|
870
918
|
try {
|
|
871
|
-
var _this$
|
|
919
|
+
var _this$build9;
|
|
872
920
|
await this.client.updateProjectDomainConfig({
|
|
873
|
-
buildId: (_this$
|
|
921
|
+
buildId: (_this$build9 = this.build) === null || _this$build9 === void 0 ? void 0 : _this$build9.id,
|
|
874
922
|
allowedDomains: Array.from(processedHosts),
|
|
875
923
|
errorDomains: Array.from(newErrorHosts)
|
|
876
924
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@percy/core",
|
|
3
|
-
"version": "1.31.14-beta.
|
|
3
|
+
"version": "1.31.14-beta.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"./utils": "./dist/utils.js",
|
|
32
32
|
"./config": "./dist/config.js",
|
|
33
|
+
"./archive": "./dist/archive.js",
|
|
33
34
|
"./install": "./dist/install.js",
|
|
34
35
|
"./test/helpers": "./test/helpers/index.js",
|
|
35
36
|
"./test/helpers/server": "./test/helpers/server.js"
|
|
@@ -43,12 +44,12 @@
|
|
|
43
44
|
"test:types": "tsd"
|
|
44
45
|
},
|
|
45
46
|
"dependencies": {
|
|
46
|
-
"@percy/client": "1.31.14-beta.
|
|
47
|
-
"@percy/config": "1.31.14-beta.
|
|
48
|
-
"@percy/dom": "1.31.14-beta.
|
|
49
|
-
"@percy/logger": "1.31.14-beta.
|
|
50
|
-
"@percy/monitoring": "1.31.14-beta.
|
|
51
|
-
"@percy/webdriver-utils": "1.31.14-beta.
|
|
47
|
+
"@percy/client": "1.31.14-beta.2",
|
|
48
|
+
"@percy/config": "1.31.14-beta.2",
|
|
49
|
+
"@percy/dom": "1.31.14-beta.2",
|
|
50
|
+
"@percy/logger": "1.31.14-beta.2",
|
|
51
|
+
"@percy/monitoring": "1.31.14-beta.2",
|
|
52
|
+
"@percy/webdriver-utils": "1.31.14-beta.2",
|
|
52
53
|
"content-disposition": "^0.5.4",
|
|
53
54
|
"cross-spawn": "^7.0.3",
|
|
54
55
|
"extract-zip": "^2.0.1",
|
|
@@ -62,7 +63,7 @@
|
|
|
62
63
|
"yaml": "^2.4.1"
|
|
63
64
|
},
|
|
64
65
|
"optionalDependencies": {
|
|
65
|
-
"@percy/cli-doctor": "1.31.14-beta.
|
|
66
|
+
"@percy/cli-doctor": "1.31.14-beta.2"
|
|
66
67
|
},
|
|
67
|
-
"gitHead": "
|
|
68
|
+
"gitHead": "e4fce73023453b77cdef50aac1a9bd5eb70cd01a"
|
|
68
69
|
}
|