@percy/core 1.31.14-beta.1 → 1.31.14-beta.3
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/closed-shadow.js +194 -0
- package/dist/config.js +14 -0
- package/dist/page.js +81 -7
- 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
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Closed-shadow capture helper. CLI-side only.
|
|
2
|
+
//
|
|
3
|
+
// External Percy SDK plugins (puppeteer-percy, playwright-percy,
|
|
4
|
+
// cypress-percy, selenium-chrome-percy) will get their own copy when
|
|
5
|
+
// SDK-side closed-shadow capture is added — that work is intentionally
|
|
6
|
+
// scoped to a separate change so this PR stays focused on the CLI path.
|
|
7
|
+
//
|
|
8
|
+
// Discovers closed shadow roots in the live page and exposes them to
|
|
9
|
+
// PercyDOM.serialize() via per-document `__percyClosedShadowRoots`
|
|
10
|
+
// WeakMaps that clone-dom.js reads through shadow-utils.getRuntime().
|
|
11
|
+
//
|
|
12
|
+
// Closed shadow roots are inaccessible from JavaScript
|
|
13
|
+
// (`element.shadowRoot === null`), but Chrome DevTools Protocol's DOM domain
|
|
14
|
+
// can pierce them. We get the full DOM tree with `pierce: true` (which also
|
|
15
|
+
// traverses iframe boundaries — closed shadow hosts inside iframes are
|
|
16
|
+
// captured by the same walk), collect every closed-shadow host/root pair,
|
|
17
|
+
// resolve both to JS object references via `DOM.resolveNode`, then call
|
|
18
|
+
// `Runtime.callFunctionOn` to write the mapping. The function body installs
|
|
19
|
+
// the WeakMap on the host's *own* `ownerDocument.defaultView` — so a host
|
|
20
|
+
// inside an iframe writes into the iframe's realm, where shadow-utils will
|
|
21
|
+
// later read it.
|
|
22
|
+
//
|
|
23
|
+
// Works for any caller that has a CDP session-like object exposing
|
|
24
|
+
// `send(method, params) => Promise`:
|
|
25
|
+
// - Puppeteer: `await page.target().createCDPSession()`
|
|
26
|
+
// - Playwright: `await context.newCDPSession(page)`
|
|
27
|
+
// - Selenium: `await driver.getDevTools()` (Chromium only)
|
|
28
|
+
// - Percy CLI: Percy's own session.send wrapper
|
|
29
|
+
//
|
|
30
|
+
// Side effect: temporarily enables and then disables the CDP `DOM` domain
|
|
31
|
+
// on the supplied session. Don't run concurrently with another `DOM`-domain
|
|
32
|
+
// consumer on the same session — the helper installs an in-flight guard
|
|
33
|
+
// against itself, but can't see other consumers.
|
|
34
|
+
//
|
|
35
|
+
// Limitation: captures the closed shadow roots present at the time of the
|
|
36
|
+
// call. Custom elements that lazy-attach a closed shadow root after this
|
|
37
|
+
// returns (e.g. inside `requestIdleCallback` or `IntersectionObserver`)
|
|
38
|
+
// won't be captured. The caller is responsible for waiting until the page
|
|
39
|
+
// is settled before invoking.
|
|
40
|
+
//
|
|
41
|
+
// Returns the number of closed shadow roots successfully exposed (0 if none,
|
|
42
|
+
// -1 on top-level error). Per-pair errors are swallowed and surfaced via the
|
|
43
|
+
// optional `log` callback — closed-shadow capture is best-effort and must
|
|
44
|
+
// never break a snapshot run.
|
|
45
|
+
|
|
46
|
+
const DEFAULT_LOG = () => {};
|
|
47
|
+
|
|
48
|
+
// Mirrors HARD_MAX_IFRAME_DEPTH from serialize-frames so every recursive
|
|
49
|
+
// walk in the capture pipeline shares the same ceiling. Counted only across
|
|
50
|
+
// shadow / iframe boundary crossings — not plain children — otherwise a
|
|
51
|
+
// normal deep DOM (html → body → div → … → custom-element) would burn
|
|
52
|
+
// through the budget before reaching any shadow host.
|
|
53
|
+
const MAX_SHADOW_DEPTH = 10;
|
|
54
|
+
|
|
55
|
+
// Bound concurrent CDP messages so we don't flood a session with hundreds
|
|
56
|
+
// of in-flight resolveNode/callFunctionOn calls when a page has many
|
|
57
|
+
// closed shadow hosts. Phase 1 (resolve) issues 2 calls per pair, so peak
|
|
58
|
+
// in-flight there is 2 * CDP_BATCH_SIZE; phase 2 (stamp) is 1 per pair so
|
|
59
|
+
// peak is exactly CDP_BATCH_SIZE. 8 chosen as a conservative default that
|
|
60
|
+
// keeps both phases well under typical CDP message-queue depths.
|
|
61
|
+
const CDP_BATCH_SIZE = 8;
|
|
62
|
+
|
|
63
|
+
// The function body that installs the WeakMap and writes the host→shadow
|
|
64
|
+
// pair. Runs inside Runtime.callFunctionOn with the host as `this`, so
|
|
65
|
+
// `this.ownerDocument.defaultView` is the host's *own* realm — the iframe's
|
|
66
|
+
// window when the host is inside an iframe.
|
|
67
|
+
//
|
|
68
|
+
// IMPORTANT: this is a string (required by Runtime.callFunctionOn) AND it
|
|
69
|
+
// is intentionally ES5 — it executes in the page's realm, which may be any
|
|
70
|
+
// browser/JS target the page itself targets. Don't "modernize" with arrow
|
|
71
|
+
// functions, let/const, or optional chaining.
|
|
72
|
+
const STAMP_FUNCTION = 'function(shadowRoot) {' + ' var w = this.ownerDocument && this.ownerDocument.defaultView;' + ' if (!w) return;' + ' if (!w.__percyClosedShadowRoots) w.__percyClosedShadowRoots = new WeakMap();' + ' w.__percyClosedShadowRoots.set(this, shadowRoot);' + '}';
|
|
73
|
+
|
|
74
|
+
// Marker for the in-flight guard — prevents concurrent invocations on the
|
|
75
|
+
// same session from racing each other's DOM.enable / DOM.disable lifecycle.
|
|
76
|
+
// Module-local Symbol (not Symbol.for) so it can't collide with any other
|
|
77
|
+
// global registry entry.
|
|
78
|
+
const IN_FLIGHT = Symbol('percy.closedShadow.inFlight');
|
|
79
|
+
export async function exposeClosedShadowRoots(cdp, log = DEFAULT_LOG) {
|
|
80
|
+
if (!cdp || typeof cdp.send !== 'function') return -1;
|
|
81
|
+
if (cdp[IN_FLIGHT]) {
|
|
82
|
+
log('Skipping concurrent closed-shadow CDP discovery on the same session');
|
|
83
|
+
return -1;
|
|
84
|
+
}
|
|
85
|
+
cdp[IN_FLIGHT] = true;
|
|
86
|
+
let domEnabled = false;
|
|
87
|
+
try {
|
|
88
|
+
await cdp.send('DOM.enable');
|
|
89
|
+
domEnabled = true;
|
|
90
|
+
const {
|
|
91
|
+
root
|
|
92
|
+
} = await cdp.send('DOM.getDocument', {
|
|
93
|
+
depth: -1,
|
|
94
|
+
pierce: true
|
|
95
|
+
});
|
|
96
|
+
const closedPairs = [];
|
|
97
|
+
walkCDPNodes(root, closedPairs);
|
|
98
|
+
if (closedPairs.length === 0) {
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
log(`Found ${closedPairs.length} closed shadow root(s), exposing via CDP`);
|
|
102
|
+
|
|
103
|
+
// Phase 1: resolve every backendNodeId → objectId in parallel batches.
|
|
104
|
+
const resolved = [];
|
|
105
|
+
for (let i = 0; i < closedPairs.length; i += CDP_BATCH_SIZE) {
|
|
106
|
+
const slice = closedPairs.slice(i, i + CDP_BATCH_SIZE);
|
|
107
|
+
const out = await Promise.all(slice.map(async pair => {
|
|
108
|
+
try {
|
|
109
|
+
const [hostRes, shadowRes] = await Promise.all([cdp.send('DOM.resolveNode', {
|
|
110
|
+
backendNodeId: pair.hostBackendNodeId
|
|
111
|
+
}), cdp.send('DOM.resolveNode', {
|
|
112
|
+
backendNodeId: pair.shadowBackendNodeId
|
|
113
|
+
})]);
|
|
114
|
+
return {
|
|
115
|
+
hostObj: hostRes.object,
|
|
116
|
+
shadowObj: shadowRes.object,
|
|
117
|
+
pair
|
|
118
|
+
};
|
|
119
|
+
} catch (err) {
|
|
120
|
+
const msg = err && err.message ? err.message : err;
|
|
121
|
+
log(`Skipping a closed shadow pair: ${msg} (host=${pair.hostBackendNodeId}, shadow=${pair.shadowBackendNodeId})`);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}));
|
|
125
|
+
for (const entry of out) if (entry) resolved.push(entry);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Phase 2: stamp the WeakMap (per-realm), also batched. Track real
|
|
129
|
+
// successes — earlier shapes returned closedPairs.length and overstated
|
|
130
|
+
// success when stamps failed.
|
|
131
|
+
let stamped = 0;
|
|
132
|
+
for (let i = 0; i < resolved.length; i += CDP_BATCH_SIZE) {
|
|
133
|
+
const slice = resolved.slice(i, i + CDP_BATCH_SIZE);
|
|
134
|
+
const results = await Promise.all(slice.map(({
|
|
135
|
+
hostObj,
|
|
136
|
+
shadowObj,
|
|
137
|
+
pair
|
|
138
|
+
}) => cdp.send('Runtime.callFunctionOn', {
|
|
139
|
+
functionDeclaration: STAMP_FUNCTION,
|
|
140
|
+
objectId: hostObj.objectId,
|
|
141
|
+
arguments: [{
|
|
142
|
+
objectId: shadowObj.objectId
|
|
143
|
+
}]
|
|
144
|
+
}).then(() => true).catch(err => {
|
|
145
|
+
const msg = err && err.message ? err.message : err;
|
|
146
|
+
log(`Skipping a closed shadow pair: ${msg} (host=${pair.hostBackendNodeId}, shadow=${pair.shadowBackendNodeId})`);
|
|
147
|
+
return false;
|
|
148
|
+
})));
|
|
149
|
+
for (const ok of results) if (ok) stamped++;
|
|
150
|
+
}
|
|
151
|
+
return stamped;
|
|
152
|
+
} catch (err) {
|
|
153
|
+
log(`Could not expose closed shadow roots via CDP: ${err && err.message ? err.message : err}`);
|
|
154
|
+
return -1;
|
|
155
|
+
} finally {
|
|
156
|
+
if (domEnabled) {
|
|
157
|
+
await cdp.send('DOM.disable').catch(disableErr => {
|
|
158
|
+
log(`DOM.disable failed during closed-shadow cleanup: ${disableErr && disableErr.message ? disableErr.message : disableErr}`);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
delete cdp[IN_FLIGHT];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Walk a DOM.getDocument tree (with pierce: true) collecting every
|
|
166
|
+
// closed-shadow host/root pair we encounter. `pierce: true` traverses both
|
|
167
|
+
// shadow boundaries and iframe `contentDocument` boundaries, so a single
|
|
168
|
+
// walk reaches closed shadow hosts inside nested iframes. Recursion is
|
|
169
|
+
// bounded at MAX_SHADOW_DEPTH levels — counted only across shadow/iframe
|
|
170
|
+
// boundary crossings, not plain children — so a deep ordinary DOM doesn't
|
|
171
|
+
// exhaust the budget before reaching its shadow hosts. Exported for tests.
|
|
172
|
+
export function walkCDPNodes(node, pairs, depth = 0) {
|
|
173
|
+
if (!node || depth >= MAX_SHADOW_DEPTH) return;
|
|
174
|
+
if (node.shadowRoots) {
|
|
175
|
+
for (const sr of node.shadowRoots) {
|
|
176
|
+
if (sr.shadowRootType === 'closed') {
|
|
177
|
+
pairs.push({
|
|
178
|
+
hostBackendNodeId: node.backendNodeId,
|
|
179
|
+
shadowBackendNodeId: sr.backendNodeId
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
// crossing a shadow boundary — increment depth
|
|
183
|
+
walkCDPNodes(sr, pairs, depth + 1);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (node.children) {
|
|
187
|
+
// plain children — same realm, same depth
|
|
188
|
+
for (const child of node.children) walkCDPNodes(child, pairs, depth);
|
|
189
|
+
}
|
|
190
|
+
// pierce: true surfaces iframe content documents on the iframe node;
|
|
191
|
+
// crossing into the iframe's realm — increment depth.
|
|
192
|
+
if (node.contentDocument) walkCDPNodes(node.contentDocument, pairs, depth + 1);
|
|
193
|
+
}
|
|
194
|
+
export default exposeClosedShadowRoots;
|
package/dist/config.js
CHANGED
|
@@ -7,6 +7,9 @@ export const configSchema = {
|
|
|
7
7
|
deferUploads: {
|
|
8
8
|
type: 'boolean'
|
|
9
9
|
},
|
|
10
|
+
archiveDir: {
|
|
11
|
+
type: 'string'
|
|
12
|
+
},
|
|
10
13
|
useSystemProxy: {
|
|
11
14
|
type: 'boolean',
|
|
12
15
|
default: false
|
|
@@ -329,6 +332,14 @@ export const configSchema = {
|
|
|
329
332
|
type: 'boolean',
|
|
330
333
|
default: false
|
|
331
334
|
},
|
|
335
|
+
ignoreIframeSelectors: {
|
|
336
|
+
type: 'array',
|
|
337
|
+
default: [],
|
|
338
|
+
items: {
|
|
339
|
+
type: 'string',
|
|
340
|
+
minLength: 1
|
|
341
|
+
}
|
|
342
|
+
},
|
|
332
343
|
pseudoClassEnabledElements: {
|
|
333
344
|
type: 'object',
|
|
334
345
|
additionalProperties: false,
|
|
@@ -637,6 +648,9 @@ export const snapshotSchema = {
|
|
|
637
648
|
ignoreStyleSheetSerializationErrors: {
|
|
638
649
|
$ref: '/config/snapshot#/properties/ignoreStyleSheetSerializationErrors'
|
|
639
650
|
},
|
|
651
|
+
ignoreIframeSelectors: {
|
|
652
|
+
$ref: '/config/snapshot#/properties/ignoreIframeSelectors'
|
|
653
|
+
},
|
|
640
654
|
pseudoClassEnabledElements: {
|
|
641
655
|
$ref: '/config/snapshot#/properties/pseudoClassEnabledElements'
|
|
642
656
|
},
|
package/dist/page.js
CHANGED
|
@@ -1,8 +1,55 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import logger from '@percy/logger';
|
|
3
3
|
import Network from './network.js';
|
|
4
|
+
import { exposeClosedShadowRoots } from './closed-shadow.js';
|
|
4
5
|
import { PERCY_DOM } from './api.js';
|
|
5
6
|
import { hostname, waitFor, waitForTimeout as sleep, serializeFunction } from './utils.js';
|
|
7
|
+
|
|
8
|
+
// Internal ceiling on the customElements wait. Set tight (500ms) so a
|
|
9
|
+
// page with a never-registering custom element — third-party widget whose
|
|
10
|
+
// loader is blocked, typo'd tag name, etc. — doesn't add a full 1500ms to
|
|
11
|
+
// every snapshot. Real cascades of legitimate lazy-defined elements
|
|
12
|
+
// complete well within this budget; the loop also exits early as soon as
|
|
13
|
+
// `:not(:defined)` clears.
|
|
14
|
+
export const DEFAULT_WAIT_FOR_CUSTOM_ELEMENTS_TIMEOUT = 500;
|
|
15
|
+
|
|
16
|
+
// Body of the customElements wait. Runs in the browser via
|
|
17
|
+
// Runtime.callFunctionOn. Re-polls each tick so lazy-defined element
|
|
18
|
+
// cascades are awaited up to the deadline.
|
|
19
|
+
//
|
|
20
|
+
// IMPORTANT: this body is intentionally ES5 — it is evaluated in the
|
|
21
|
+
// page's realm and must work in any browser the page targets. Don't
|
|
22
|
+
// "modernize" with arrow functions, let/const, or optional chaining.
|
|
23
|
+
export const WAIT_FOR_CUSTOM_ELEMENTS_BODY = `
|
|
24
|
+
var deadline = Date.now() + (arguments[0] || 500);
|
|
25
|
+
return new Promise(function(resolve) {
|
|
26
|
+
function tick() {
|
|
27
|
+
var undef = document.querySelectorAll(":not(:defined)");
|
|
28
|
+
if (!undef.length) return resolve();
|
|
29
|
+
if (Date.now() >= deadline) return resolve();
|
|
30
|
+
var names = {};
|
|
31
|
+
for (var i = 0; i < undef.length; i++) names[undef[i].localName] = true;
|
|
32
|
+
var promises = Object.keys(names).map(function(n) {
|
|
33
|
+
return window.customElements.whenDefined(n).catch(function(){});
|
|
34
|
+
});
|
|
35
|
+
Promise.race([
|
|
36
|
+
Promise.all(promises),
|
|
37
|
+
new Promise(function(r) { setTimeout(r, 100); })
|
|
38
|
+
]).then(tick);
|
|
39
|
+
}
|
|
40
|
+
tick();
|
|
41
|
+
});
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
/* istanbul ignore next: runs in the page realm via Runtime.callFunctionOn,
|
|
45
|
+
not in the test process — there is no way to instrument it from here */
|
|
46
|
+
function serializeDomCapture(_, options) {
|
|
47
|
+
/* eslint-disable-next-line no-undef */
|
|
48
|
+
return {
|
|
49
|
+
domSnapshot: PercyDOM.serialize(options),
|
|
50
|
+
url: document.URL
|
|
51
|
+
};
|
|
52
|
+
}
|
|
6
53
|
export class Page {
|
|
7
54
|
static TIMEOUT = undefined;
|
|
8
55
|
log = logger('core:page');
|
|
@@ -195,6 +242,7 @@ export class Page {
|
|
|
195
242
|
reshuffleInvalidTags,
|
|
196
243
|
ignoreCanvasSerializationErrors,
|
|
197
244
|
ignoreStyleSheetSerializationErrors,
|
|
245
|
+
ignoreIframeSelectors,
|
|
198
246
|
pseudoClassEnabledElements
|
|
199
247
|
} = snapshot;
|
|
200
248
|
this.log.debug(`Taking snapshot: ${name}${width ? ` @${width}px` : ''}`, this.meta);
|
|
@@ -219,17 +267,33 @@ export class Page {
|
|
|
219
267
|
|
|
220
268
|
// wait for any final network activity before capturing the dom snapshot
|
|
221
269
|
await this.network.idle();
|
|
270
|
+
|
|
271
|
+
// Pre-snapshot best-effort steps: waiting for lazy custom elements and
|
|
272
|
+
// discovering closed shadow roots via CDP. Both target a fully-loaded
|
|
273
|
+
// page; if the session has already terminated, skip them so the proper
|
|
274
|
+
// crash/close error surfaces from the downstream insertPercyDom +
|
|
275
|
+
// serialize evals (which gate on the same session).
|
|
276
|
+
//
|
|
277
|
+
// Ordering is load-bearing: closed-shadow capture must run AFTER the
|
|
278
|
+
// customElements wait so we catch shadows attached inside upgrade /
|
|
279
|
+
// connectedCallback hooks. Don't reorder or parallelise these.
|
|
280
|
+
if (!this.session.closedReason) {
|
|
281
|
+
// Best-effort: a flaky page should not break the snapshot.
|
|
282
|
+
try {
|
|
283
|
+
await this.eval(WAIT_FOR_CUSTOM_ELEMENTS_BODY, DEFAULT_WAIT_FOR_CUSTOM_ELEMENTS_TIMEOUT);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
/* istanbul ignore next: best-effort log; defensive against non-Error throws */
|
|
286
|
+
this.log.debug(`Custom elements wait failed: ${err.message ?? err}`, this.meta);
|
|
287
|
+
}
|
|
288
|
+
if (!disableShadowDOM) {
|
|
289
|
+
await exposeClosedShadowRoots(this.session, this._logShadowDebug.bind(this));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
222
292
|
await this.insertPercyDom();
|
|
223
293
|
|
|
224
294
|
// serialize and capture a DOM snapshot
|
|
225
295
|
this.log.debug('Serialize DOM', this.meta);
|
|
226
|
-
|
|
227
|
-
/* istanbul ignore next: no instrumenting injected code */
|
|
228
|
-
let capture = await this.eval((_, options) => ({
|
|
229
|
-
/* eslint-disable-next-line no-undef */
|
|
230
|
-
domSnapshot: PercyDOM.serialize(options),
|
|
231
|
-
url: document.URL
|
|
232
|
-
}), {
|
|
296
|
+
let capture = await this.eval(serializeDomCapture, {
|
|
233
297
|
enableJavaScript,
|
|
234
298
|
disableShadowDOM,
|
|
235
299
|
forceShadowAsLightDOM,
|
|
@@ -237,6 +301,7 @@ export class Page {
|
|
|
237
301
|
reshuffleInvalidTags,
|
|
238
302
|
ignoreCanvasSerializationErrors,
|
|
239
303
|
ignoreStyleSheetSerializationErrors,
|
|
304
|
+
ignoreIframeSelectors,
|
|
240
305
|
pseudoClassEnabledElements
|
|
241
306
|
});
|
|
242
307
|
return {
|
|
@@ -245,6 +310,15 @@ export class Page {
|
|
|
245
310
|
};
|
|
246
311
|
}
|
|
247
312
|
|
|
313
|
+
// Logger for the closed-shadow CDP helper. Defined on the prototype (not
|
|
314
|
+
// a class-field arrow) so it's reachable from a unit test that constructs
|
|
315
|
+
// a Page via Object.create without invoking the constructor — gives us a
|
|
316
|
+
// direct way to cover the callback without simulating a closed shadow
|
|
317
|
+
// discovery flow at the integration level.
|
|
318
|
+
_logShadowDebug(msg) {
|
|
319
|
+
this.log.debug(msg, this.meta);
|
|
320
|
+
}
|
|
321
|
+
|
|
248
322
|
// Initialize newly attached pages and iframes with page options
|
|
249
323
|
_handleAttachedToTarget = event => {
|
|
250
324
|
let session = !event ? this.session : this.session.children.get(event.sessionId);
|
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.3",
|
|
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.3",
|
|
48
|
+
"@percy/config": "1.31.14-beta.3",
|
|
49
|
+
"@percy/dom": "1.31.14-beta.3",
|
|
50
|
+
"@percy/logger": "1.31.14-beta.3",
|
|
51
|
+
"@percy/monitoring": "1.31.14-beta.3",
|
|
52
|
+
"@percy/webdriver-utils": "1.31.14-beta.3",
|
|
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.3"
|
|
66
67
|
},
|
|
67
|
-
"gitHead": "
|
|
68
|
+
"gitHead": "a17d4a1453c6bef282fd3da38082b670e125a5be"
|
|
68
69
|
}
|