@percy/cli-command 1.32.2 → 1.32.3-beta.0
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/index.js +0 -1
- package/package.json +6 -12
- package/dist/graphTrace.js +0 -157
- package/dist/graphTraceTemplate.html +0 -349
- package/dist/lockfileDiff.js +0 -184
- package/dist/smartsnap.js +0 -700
package/dist/smartsnap.js
DELETED
|
@@ -1,700 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import { spawnSync } from 'child_process';
|
|
4
|
-
import { createRequire } from 'module';
|
|
5
|
-
import globToRegExp from 'glob-to-regexp';
|
|
6
|
-
import logger from '@percy/logger';
|
|
7
|
-
import { diffLockfileDeps } from './lockfileDiff.js';
|
|
8
|
-
import { renderGraphTraceHtml } from './graphTrace.js';
|
|
9
|
-
|
|
10
|
-
// stream-json is CommonJS — load via createRequire to avoid named-import interop issues.
|
|
11
|
-
const require = createRequire(import.meta.url);
|
|
12
|
-
const {
|
|
13
|
-
parser
|
|
14
|
-
} = require('stream-json');
|
|
15
|
-
const {
|
|
16
|
-
pick
|
|
17
|
-
} = require('stream-json/filters/Pick');
|
|
18
|
-
const {
|
|
19
|
-
streamArray
|
|
20
|
-
} = require('stream-json/streamers/StreamArray');
|
|
21
|
-
const {
|
|
22
|
-
streamValues
|
|
23
|
-
} = require('stream-json/streamers/StreamValues');
|
|
24
|
-
|
|
25
|
-
// Webpack/Vite stats prefix loader-resolved virtual modules with a NUL byte
|
|
26
|
-
// (e.g. "\u0000/path/to/file?commonjs-es-import"). Strip it so the path lines
|
|
27
|
-
// up with the absolute paths in our file index. Built via String.fromCharCode
|
|
28
|
-
// to avoid embedding a control char in source / tripping no-control-regex.
|
|
29
|
-
const NULL_CHAR = String.fromCharCode(0);
|
|
30
|
-
// split/join instead of String.prototype.replaceAll: replaceAll is Node 15+
|
|
31
|
-
// and cli-command supports Node >=14.
|
|
32
|
-
/* istanbul ignore next: the non-string branch is defensive — resolveAndIndex
|
|
33
|
-
only ever passes stats string values, and a non-string would throw at the
|
|
34
|
-
path.isAbsolute call below anyway */
|
|
35
|
-
const stripNull = s => typeof s === 'string' ? s.split(NULL_CHAR).join('') : s;
|
|
36
|
-
|
|
37
|
-
// Status poll cadence: 12 attempts × 5s = 1 minute total.
|
|
38
|
-
const POLL_INTERVAL_MS = 5000;
|
|
39
|
-
const POLL_ATTEMPTS = 12;
|
|
40
|
-
const GLOB_CHARS = /[*?]/;
|
|
41
|
-
const MAX_PATTERN_LENGTH = 500;
|
|
42
|
-
function patternToRegex(pattern) {
|
|
43
|
-
/* istanbul ignore next: callers (matchesPattern) only ever pass a string, so
|
|
44
|
-
the typeof guard is defensive; the length guard is exercised at runtime by
|
|
45
|
-
the over-long-glob test via the matchesPattern catch below */
|
|
46
|
-
if (typeof pattern !== 'string' || pattern.length > MAX_PATTERN_LENGTH) {
|
|
47
|
-
throw new Error('Invalid pattern: must be a string with max length of 500 characters');
|
|
48
|
-
}
|
|
49
|
-
return globToRegExp(pattern, {
|
|
50
|
-
extended: true,
|
|
51
|
-
globstar: true
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
function matchesPattern(str, pattern) {
|
|
55
|
-
if (GLOB_CHARS.test(pattern)) {
|
|
56
|
-
try {
|
|
57
|
-
return patternToRegex(pattern).test(str);
|
|
58
|
-
} catch {
|
|
59
|
-
return false;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return str === pattern;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Thrown from any pipeline step that wants to fall back to the full snapshot
|
|
66
|
-
// set. The caller in snapshots.js downgrades these to log.info — they're
|
|
67
|
-
// expected, user-visible bail conditions, not crashes.
|
|
68
|
-
export class SmartSnapBailError extends Error {
|
|
69
|
-
constructor(message) {
|
|
70
|
-
super(message);
|
|
71
|
-
this.name = 'SmartSnapBailError';
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Any git failure is treated as a recoverable bail (full snapshot fallback),
|
|
76
|
-
// per this module's contract. A common trigger is CI shallow clones where the
|
|
77
|
-
// predicted base commit isn't fetched locally, so `git diff <sha> HEAD` fails —
|
|
78
|
-
// we must downgrade that to a full snapshot, not crash the build. Callers that
|
|
79
|
-
// want a more specific bail message (e.g. lockfile lookup) catch and re-throw.
|
|
80
|
-
function git(args) {
|
|
81
|
-
let res;
|
|
82
|
-
try {
|
|
83
|
-
res = spawnSync('git', args, {
|
|
84
|
-
encoding: 'utf8'
|
|
85
|
-
});
|
|
86
|
-
} catch (e) {
|
|
87
|
-
/* istanbul ignore next: spawnSync only throws on an exec-level failure (e.g.
|
|
88
|
-
git binary missing / ENOMEM), which the test environment can't induce —
|
|
89
|
-
the non-zero-exit bail below is the path real git failures take */
|
|
90
|
-
throw new SmartSnapBailError(`SmartSnap: git ${args.join(' ')} failed to spawn: ${e.message}; running full snapshot set`);
|
|
91
|
-
}
|
|
92
|
-
if (res.status !== 0) {
|
|
93
|
-
/* istanbul ignore next: the stderr||stdout||exit-code fallbacks in the
|
|
94
|
-
message are defensive — git failures in tests always carry stderr */
|
|
95
|
-
throw new SmartSnapBailError(`SmartSnap: git ${args.join(' ')} failed: ${res.stderr || res.stdout || `exit ${res.status}`}; running full snapshot set`);
|
|
96
|
-
}
|
|
97
|
-
return res.stdout;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// baseRef flows into `git` argv from either user config (`baseline`) or the
|
|
101
|
-
// API. Reject anything that could be parsed as an option (leading `-`) or
|
|
102
|
-
// contains chars outside the safe ref alphabet — git also accepts `--` as an
|
|
103
|
-
// end-of-options separator in `git diff`, but not before the rev in
|
|
104
|
-
// `git show <rev>:<path>`, so validation is the only universal guard.
|
|
105
|
-
function assertSafeRef(ref) {
|
|
106
|
-
if (typeof ref !== 'string' || !/^[A-Za-z0-9_./][A-Za-z0-9_./-]*$/.test(ref)) {
|
|
107
|
-
throw new SmartSnapBailError(`SmartSnap: unsafe baseline ref "${ref}"; running full snapshot set`);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
function gitDiffNames(ref) {
|
|
111
|
-
assertSafeRef(ref);
|
|
112
|
-
return git(['diff', '--name-only', ref, 'HEAD', '--']).split('\n').filter(Boolean);
|
|
113
|
-
}
|
|
114
|
-
function gitProjectRoot() {
|
|
115
|
-
return git(['rev-parse', '--show-toplevel']).trim();
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Parse `git diff --unified=0` hunk headers to find which line ranges are new
|
|
119
|
-
// or changed (on the HEAD side) for each file, then key them by the file's
|
|
120
|
-
// index in the stats `files` array — the same index module/source refs use, so
|
|
121
|
-
// the graph can join them without a path lookup. `--unified=0` drops context
|
|
122
|
-
// lines so every hunk header bounds an actual change; `--no-renames` makes a
|
|
123
|
-
// rename surface as add+delete so the new path is always concrete.
|
|
124
|
-
//
|
|
125
|
-
// Files in the diff that aren't tracked in `files` (e.g. node_modules,
|
|
126
|
-
// .storybook, or anything the stats compactor didn't index) are skipped — the
|
|
127
|
-
// graph can only reason about indexed files. Returns { <fileIndex>: [[start, end], ...] }.
|
|
128
|
-
export function getAffectedFileLocations(baseRef, files) {
|
|
129
|
-
assertSafeRef(baseRef);
|
|
130
|
-
const diff = git(['diff', '--unified=0', '--no-color', '--no-renames', baseRef, 'HEAD', '--']);
|
|
131
|
-
|
|
132
|
-
// The `files` array uses the platform separator (path.relative → back-slash
|
|
133
|
-
// on Windows); git diff always emits forward slashes. Normalize both to
|
|
134
|
-
// forward-slash for the path→index lookup.
|
|
135
|
-
const toPosix = p => p.split(path.sep).join('/');
|
|
136
|
-
const indexByPath = new Map(files.map((f, i) => [toPosix(f), i]));
|
|
137
|
-
|
|
138
|
-
// @@ -a,b +c,d @@ — the new-side `+c,d` is what we want. `d` defaults to 1
|
|
139
|
-
// when omitted; `d === 0` is a pure deletion anchored at line c (no added
|
|
140
|
-
// lines), so it contributes no range.
|
|
141
|
-
const HUNK = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
|
|
142
|
-
const locations = {};
|
|
143
|
-
let currentIdx;
|
|
144
|
-
for (const line of diff.split('\n')) {
|
|
145
|
-
// `+++ b/<path>` opens a file's hunks with its new-side path; a pure
|
|
146
|
-
// deletion shows `+++ /dev/null`, so leave currentIdx unset and skip it.
|
|
147
|
-
if (line.startsWith('+++ ')) {
|
|
148
|
-
const p = line.slice(4);
|
|
149
|
-
if (p === '/dev/null') {
|
|
150
|
-
currentIdx = undefined;
|
|
151
|
-
continue;
|
|
152
|
-
}
|
|
153
|
-
/* istanbul ignore next: git always prefixes the new-side path with `b/`
|
|
154
|
-
(anything else is `/dev/null`, handled above), so the bare `: p`
|
|
155
|
-
fallback is defensive and unreachable in real diff output */
|
|
156
|
-
const rel = p.startsWith('b/') ? p.slice(2) : p;
|
|
157
|
-
currentIdx = indexByPath.get(rel);
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
160
|
-
if (currentIdx === undefined) continue;
|
|
161
|
-
const m = HUNK.exec(line);
|
|
162
|
-
if (!m) continue;
|
|
163
|
-
const start = parseInt(m[1], 10);
|
|
164
|
-
const count = m[2] === undefined ? 1 : parseInt(m[2], 10);
|
|
165
|
-
if (count === 0) continue;
|
|
166
|
-
if (!locations[currentIdx]) locations[currentIdx] = [];
|
|
167
|
-
locations[currentIdx].push([start, start + count - 1]);
|
|
168
|
-
}
|
|
169
|
-
return locations;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Paths under these directories are dependencies / framework wiring rather
|
|
173
|
-
// than first-party source, so we don't track them in the file index.
|
|
174
|
-
const EXCLUDED_DIRS = new Set(['node_modules', '.storybook']);
|
|
175
|
-
const isExcluded = relPath => relPath.split(/[/\\]/).some(seg => EXCLUDED_DIRS.has(seg));
|
|
176
|
-
|
|
177
|
-
// Resolve+index used for `id` and `resolvedFrom` only. Converts absolute paths
|
|
178
|
-
// to projectRoot-relative form, then either returns the existing index for that
|
|
179
|
-
// path or assigns the next one (= current map size). Paths inside node_modules
|
|
180
|
-
// or .storybook are returned as the relative string and *not* indexed; modules
|
|
181
|
-
// whose id falls into that bucket are dropped downstream by the
|
|
182
|
-
// "id is string → drop" contract in transformModule.
|
|
183
|
-
function resolveAndIndex(value, fileIndex, projectRoot) {
|
|
184
|
-
const clean = stripNull(value);
|
|
185
|
-
if (!path.isAbsolute(clean)) return clean;
|
|
186
|
-
const rel = path.relative(projectRoot, clean);
|
|
187
|
-
if (isExcluded(rel)) return rel;
|
|
188
|
-
let idx = fileIndex.get(rel);
|
|
189
|
-
if (idx === undefined) {
|
|
190
|
-
idx = fileIndex.size;
|
|
191
|
-
fileIndex.set(rel, idx);
|
|
192
|
-
}
|
|
193
|
-
return idx;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Transform a stats `modules[]` entry into the indexed shape the SmartSnap graph expects.
|
|
197
|
-
// Returns null when the module's id is a string — those entries are dropped (per BE contract).
|
|
198
|
-
// Each `imports[i]` / `passThroughExports[i]` carries `{ type, source }` from the
|
|
199
|
-
// bundler-plugin: for `type === 'src'` we translate the absolute file path to its
|
|
200
|
-
// project-file index when possible; for `type === 'module'` the source is already
|
|
201
|
-
// the bare package name, so we leave it alone.
|
|
202
|
-
function transformModule(m, fileIndex, projectRoot) {
|
|
203
|
-
const out = {};
|
|
204
|
-
if (m.id != null) out.id = resolveAndIndex(m.id, fileIndex, projectRoot);
|
|
205
|
-
if (typeof out.id === 'string') return null;
|
|
206
|
-
const mapEntry = e => {
|
|
207
|
-
const copy = {
|
|
208
|
-
...e
|
|
209
|
-
};
|
|
210
|
-
if (copy.type === 'src' && typeof copy.source === 'string') {
|
|
211
|
-
copy.source = resolveAndIndex(copy.source, fileIndex, projectRoot);
|
|
212
|
-
}
|
|
213
|
-
// `loc` (when present) is an array of `{ start, end }` source spans; the
|
|
214
|
-
// graph expects each span as a `[start, end]` tuple instead.
|
|
215
|
-
if (Array.isArray(copy.loc)) {
|
|
216
|
-
copy.loc = copy.loc.map(l => [l.start, l.end]);
|
|
217
|
-
}
|
|
218
|
-
return copy;
|
|
219
|
-
};
|
|
220
|
-
if (Array.isArray(m.imports)) out.imports = m.imports.map(mapEntry);
|
|
221
|
-
if (Array.isArray(m.passThroughExports)) out.passThroughExports = m.passThroughExports.map(mapEntry);
|
|
222
|
-
if (Array.isArray(m.nonPassThroughExports)) out.nonPassThroughExports = m.nonPassThroughExports;
|
|
223
|
-
return out;
|
|
224
|
-
}
|
|
225
|
-
function streamModules(filePath, onModule) {
|
|
226
|
-
return new Promise((resolve, reject) => {
|
|
227
|
-
fs.createReadStream(filePath).pipe(parser()).pipe(pick({
|
|
228
|
-
filter: 'modules'
|
|
229
|
-
})).pipe(streamArray()).on('data', ({
|
|
230
|
-
value
|
|
231
|
-
}) => onModule(value)).on('end', resolve).on('error', reject);
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
function readTopLevelKey(filePath, key) {
|
|
235
|
-
return new Promise((resolve, reject) => {
|
|
236
|
-
let value;
|
|
237
|
-
fs.createReadStream(filePath).pipe(parser()).pipe(pick({
|
|
238
|
-
filter: key
|
|
239
|
-
})).pipe(streamValues()).on('data', ({
|
|
240
|
-
value: v
|
|
241
|
-
}) => {
|
|
242
|
-
value = v;
|
|
243
|
-
}).on('end', () => resolve(value)).on('error', reject);
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
async function readStats(statsFile, projectRoot) {
|
|
247
|
-
const fileIndex = new Map();
|
|
248
|
-
const modules = [];
|
|
249
|
-
await streamModules(statsFile, m => {
|
|
250
|
-
const t = transformModule(m, fileIndex, projectRoot);
|
|
251
|
-
if (t) modules.push(t);
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
// Emit `files` ordered by encounter-time index so files[N] corresponds to
|
|
255
|
-
// every module/source ref that was assigned index N during streaming.
|
|
256
|
-
const files = [...fileIndex.entries()].sort((a, b) => a[1] - b[1]).map(([p]) => p);
|
|
257
|
-
|
|
258
|
-
// The bundler-plugin-smartsnap emits a unique buildId per storybook build
|
|
259
|
-
// so concurrent runs against the same project don't share Redis state.
|
|
260
|
-
const buildId = await readTopLevelKey(statsFile, 'buildId');
|
|
261
|
-
return {
|
|
262
|
-
files,
|
|
263
|
-
modules,
|
|
264
|
-
buildId
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Polls `job_status?sync=true&type=smartsnap_graph&id=<buildId>` — the sync
|
|
269
|
-
// response blocks server-side until the job moves off `in_progress`, but the
|
|
270
|
-
// API enforces a shorter timeout than the job can take, so we retry up to
|
|
271
|
-
// POLL_ATTEMPTS times. On `done` the response also carries the graph payload
|
|
272
|
-
// (affected stories + vertices/edges/transitive closure for trace
|
|
273
|
-
// rendering), so the caller reads it directly without a second fetch.
|
|
274
|
-
//
|
|
275
|
-
// Response shape is the unwrapped `{ status, data }` — the smartsnap_graph
|
|
276
|
-
// status response no longer keys the result by buildId.
|
|
277
|
-
async function pollGraphStatus(percy, buildId, log) {
|
|
278
|
-
for (let i = 0; i < POLL_ATTEMPTS; i++) {
|
|
279
|
-
const res = await percy.client.getStatus('smartsnap_graph', [buildId]);
|
|
280
|
-
const status = res === null || res === void 0 ? void 0 : res.status;
|
|
281
|
-
log.debug(`SmartSnap: graph status (attempt ${i + 1}) = ${status}`);
|
|
282
|
-
if (status === 'done' || status === 'failed') return {
|
|
283
|
-
status,
|
|
284
|
-
data: res === null || res === void 0 ? void 0 : res.data
|
|
285
|
-
};
|
|
286
|
-
if (i < POLL_ATTEMPTS - 1) await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
|
|
287
|
-
}
|
|
288
|
-
return {
|
|
289
|
-
status: null
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// First component: resolve + validate the statsFile path, stream-read it, and
|
|
294
|
-
// assert it carries a usable buildId. Returns { files, modules, buildId }; any
|
|
295
|
-
// problem throws a SmartSnapBailError so the caller falls back to a full set.
|
|
296
|
-
export async function validateAndReadStats(buildDir, statsFile, projectRoot, log) {
|
|
297
|
-
// Treat statsFile as a flat filename anchored inside the build directory.
|
|
298
|
-
// path.basename() strips any traversal segments so the resolved path can't
|
|
299
|
-
// escape buildDir even if the config is hostile.
|
|
300
|
-
const statsName = path.basename(statsFile || 'enriched-stats.json');
|
|
301
|
-
if (!/^[\w.-]+\.json$/i.test(statsName)) {
|
|
302
|
-
throw new SmartSnapBailError(`SmartSnap: invalid statsFile "${statsName}" — must be a .json filename; running full snapshot set`);
|
|
303
|
-
}
|
|
304
|
-
// statsName is path.basename'd and regex-validated above; buildDir is operator-supplied config (reviewed, approved by security)
|
|
305
|
-
const resolvedStatsPath = path.join(path.resolve(buildDir), statsName); // nosemgrep
|
|
306
|
-
let statsStat;
|
|
307
|
-
try {
|
|
308
|
-
statsStat = fs.statSync(resolvedStatsPath);
|
|
309
|
-
} catch {
|
|
310
|
-
throw new SmartSnapBailError(`SmartSnap: stats file "${statsName}" not found in build directory ${buildDir}; running full snapshot set`);
|
|
311
|
-
}
|
|
312
|
-
if (!statsStat.isFile()) {
|
|
313
|
-
throw new SmartSnapBailError(`SmartSnap: stats file "${statsName}" in ${buildDir} is not a regular file; running full snapshot set`);
|
|
314
|
-
}
|
|
315
|
-
log.debug(`SmartSnap: parsing stats file ${resolvedStatsPath}`);
|
|
316
|
-
const {
|
|
317
|
-
files,
|
|
318
|
-
modules,
|
|
319
|
-
buildId
|
|
320
|
-
} = await readStats(resolvedStatsPath, projectRoot);
|
|
321
|
-
if (typeof buildId !== 'string' || !buildId) {
|
|
322
|
-
throw new SmartSnapBailError(`SmartSnap: stats file at ${resolvedStatsPath} is missing a top-level "buildId" — running full snapshot set`);
|
|
323
|
-
}
|
|
324
|
-
return {
|
|
325
|
-
files,
|
|
326
|
-
modules,
|
|
327
|
-
buildId
|
|
328
|
-
};
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// Resolve the diff base ref + the list of changed files. With an explicit
|
|
332
|
-
// `baseline` we diff against it directly and skip the API base-build lookup
|
|
333
|
-
// entirely (its snapshot map is only consulted on the no-baseline path), so
|
|
334
|
-
// `baselineSnapshots` comes back null in that case.
|
|
335
|
-
export async function getBaselineAndAffectedNodes(percy, baseline, log) {
|
|
336
|
-
let baseRef;
|
|
337
|
-
let baselineSnapshots;
|
|
338
|
-
if (baseline) {
|
|
339
|
-
log.debug(`SmartSnap: diffing against explicit baseline "${baseline}"`);
|
|
340
|
-
baseRef = baseline;
|
|
341
|
-
baselineSnapshots = null;
|
|
342
|
-
} else {
|
|
343
|
-
// New API shape: `{ base_build_commit_sha, snapshots: { <name>: <review_state> } }`.
|
|
344
|
-
// The single base-build commit replaces the previous per-snapshot commit map —
|
|
345
|
-
// baseline prediction now happens server-side via `Percy::BaseBuildService`,
|
|
346
|
-
// so we just diff against whatever commit it picked. The set of snapshot
|
|
347
|
-
// names is no longer sent; the API resolves baselines from the project +
|
|
348
|
-
// git/PR context alone.
|
|
349
|
-
const baseLookup = await percy.client.getSmartsnapSnapshotNameToCommit();
|
|
350
|
-
log.debug(`SmartSnap: base lookup ${JSON.stringify(baseLookup)}`);
|
|
351
|
-
if (!(baseLookup !== null && baseLookup !== void 0 && baseLookup.base_build_commit_sha)) {
|
|
352
|
-
throw new SmartSnapBailError('SmartSnap: API could not predict a base build commit and no explicit baseline was set; running full snapshot set');
|
|
353
|
-
}
|
|
354
|
-
log.debug(`SmartSnap: diffing against predicted base build commit "${baseLookup.base_build_commit_sha}"`);
|
|
355
|
-
baseRef = baseLookup.base_build_commit_sha;
|
|
356
|
-
baselineSnapshots = baseLookup.snapshots || {};
|
|
357
|
-
}
|
|
358
|
-
assertSafeRef(baseRef);
|
|
359
|
-
const affectedNodes = gitDiffNames(baseRef);
|
|
360
|
-
return {
|
|
361
|
-
baseRef,
|
|
362
|
-
affectedNodes,
|
|
363
|
-
baselineSnapshots
|
|
364
|
-
};
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// A change to anything under `.storybook/` (preview config, addons, manager
|
|
368
|
-
// wiring) can affect every story's render, so the dep graph isn't enough.
|
|
369
|
-
export function assertNoDotStorybookChange(affectedNodes) {
|
|
370
|
-
const dotStorybookHit = affectedNodes.find(p => p.split(/[/\\]/).includes('.storybook'));
|
|
371
|
-
if (dotStorybookHit) {
|
|
372
|
-
throw new SmartSnapBailError(`SmartSnap: change to "${dotStorybookHit}" inside .storybook affects all stories; running full snapshot set`);
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// Bail to a full snapshot set if any changed file matches a user-supplied
|
|
377
|
-
// bailOnChanges glob.
|
|
378
|
-
export function assertNoBailOnChanges(affectedNodes, bailOnChanges) {
|
|
379
|
-
if (bailOnChanges !== null && bailOnChanges !== void 0 && bailOnChanges.length) {
|
|
380
|
-
const bailed = affectedNodes.find(p => bailOnChanges.some(g => matchesPattern(p, g)));
|
|
381
|
-
if (bailed) {
|
|
382
|
-
throw new SmartSnapBailError(`SmartSnap: change to "${bailed}" matched bailOnChanges; running full snapshot set`);
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// Drop changed files that match a user-supplied `untraced` glob so they don't
|
|
388
|
-
// drive snapshot selection. Returns the filtered list (unchanged when no
|
|
389
|
-
// untraced patterns are configured).
|
|
390
|
-
export function enforceUntraced(affectedNodes, untraced) {
|
|
391
|
-
if (untraced !== null && untraced !== void 0 && untraced.length) {
|
|
392
|
-
return affectedNodes.filter(p => !untraced.some(g => matchesPattern(p, g)));
|
|
393
|
-
}
|
|
394
|
-
return affectedNodes;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// Manifest/lockfile changes can shift the dependency tree, so resolve the
|
|
398
|
-
// diff at the package level via snyk-nodejs-lockfile-parser and return the
|
|
399
|
-
// changed package names to feed back into the graph. Returns [] when there's
|
|
400
|
-
// nothing to resolve; short-circuits below fall back to a full snapshot
|
|
401
|
-
// (via SmartSnapBailError) whenever we can't reason about the change.
|
|
402
|
-
export async function getAffectedPackages(affectedNodes, baseRef, projectRoot, log) {
|
|
403
|
-
const MANIFEST_PATHS = new Set(['package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml']);
|
|
404
|
-
const manifestHits = affectedNodes.filter(p => MANIFEST_PATHS.has(path.basename(p)));
|
|
405
|
-
if (manifestHits.length === 0) return [];
|
|
406
|
-
|
|
407
|
-
// Locate the changed manifest's directory from affectedNodes (NOT the git
|
|
408
|
-
// root) — monorepos keep package.json + lockfile inside a workspace dir.
|
|
409
|
-
// If two changes land in different dirs, we'd need per-workspace resolution
|
|
410
|
-
// we don't try to do yet, so bail.
|
|
411
|
-
const uniqueDirs = [...new Set(manifestHits.map(p => path.dirname(p)))];
|
|
412
|
-
if (uniqueDirs.length > 1) {
|
|
413
|
-
throw new SmartSnapBailError(`SmartSnap: manifest changes span multiple directories (${uniqueDirs.join(', ')}); running full snapshot set`);
|
|
414
|
-
}
|
|
415
|
-
const manifestDir = uniqueDirs[0]; // repo-relative; '.' for root
|
|
416
|
-
// manifestDir is derived from git-tracked paths under projectRoot (reviewed, approved by security)
|
|
417
|
-
const absManifestDir = path.resolve(projectRoot, manifestDir); // nosemgrep
|
|
418
|
-
|
|
419
|
-
// Pick the lockfile that lives next to the changed manifest. If two
|
|
420
|
-
// coexist (e.g. a stray package-lock.json next to yarn.lock) we can't
|
|
421
|
-
// pick a canonical source, so bail.
|
|
422
|
-
const LOCKFILE_NAMES = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'];
|
|
423
|
-
// n is from the hardcoded LOCKFILE_NAMES allowlist (reviewed, approved by security)
|
|
424
|
-
const presentLockfiles = LOCKFILE_NAMES.filter(n => fs.existsSync(path.join(absManifestDir, n))); // nosemgrep
|
|
425
|
-
if (presentLockfiles.length === 0) {
|
|
426
|
-
throw new SmartSnapBailError(`SmartSnap: manifest changed in "${manifestDir}" but no lockfile present there; running full snapshot set`);
|
|
427
|
-
}
|
|
428
|
-
if (presentLockfiles.length > 1) {
|
|
429
|
-
throw new SmartSnapBailError(`SmartSnap: multiple lockfiles in "${manifestDir}" (${presentLockfiles.join(', ')}); cannot pick canonical; running full snapshot set`);
|
|
430
|
-
}
|
|
431
|
-
const lockfileName = presentLockfiles[0];
|
|
432
|
-
// git always uses forward slashes for its <rev>:<path> spec; build it by
|
|
433
|
-
// hand instead of path.join (which would use backslashes on Windows).
|
|
434
|
-
const lockfileRepoPath = manifestDir === '.' ? lockfileName : `${manifestDir}/${lockfileName}`;
|
|
435
|
-
|
|
436
|
-
// Resolve the lockfile at the base commit. If it wasn't tracked there
|
|
437
|
-
// (first SmartSnap run, lockfile renamed, etc.) we can't diff, so bail.
|
|
438
|
-
let oldLockfile;
|
|
439
|
-
try {
|
|
440
|
-
oldLockfile = git(['show', `${baseRef}:${lockfileRepoPath}`]);
|
|
441
|
-
} catch {
|
|
442
|
-
throw new SmartSnapBailError(`SmartSnap: lockfile "${lockfileRepoPath}" not present at base ref ${baseRef}; running full snapshot set`);
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
// lockfileName is one of the hardcoded LOCKFILE_NAMES allowlist (reviewed, approved by security)
|
|
446
|
-
const newLockfile = fs.readFileSync(path.join(absManifestDir, lockfileName), 'utf8'); // nosemgrep
|
|
447
|
-
|
|
448
|
-
// Byte-identical lockfile means only package.json's non-dep fields changed —
|
|
449
|
-
// nothing shifted in the dependency tree, so there are no affected packages.
|
|
450
|
-
if (oldLockfile === newLockfile) return [];
|
|
451
|
-
|
|
452
|
-
// joined with the literal 'package.json' under the resolved absManifestDir (reviewed, approved by security)
|
|
453
|
-
const packageJson = fs.readFileSync(path.join(absManifestDir, 'package.json'), 'utf8'); // nosemgrep
|
|
454
|
-
const packageJsonRepoPath = manifestDir === '.' ? 'package.json' : `${manifestDir}/package.json`;
|
|
455
|
-
const oldPackageJson = git(['show', `${baseRef}:${packageJsonRepoPath}`]);
|
|
456
|
-
try {
|
|
457
|
-
const packageAffected = await diffLockfileDeps({
|
|
458
|
-
packageJson,
|
|
459
|
-
oldLockfile,
|
|
460
|
-
newLockfile,
|
|
461
|
-
lockfileType: lockfileName,
|
|
462
|
-
oldPackageJson
|
|
463
|
-
});
|
|
464
|
-
// The two statements below run only on Node >=18 where the snyk parser
|
|
465
|
-
// loads; the CI matrix runs the suite on Node 14, where the await above
|
|
466
|
-
// throws SNYK_LOCKFILE_PARSER_UNAVAILABLE and we take the catch instead.
|
|
467
|
-
/* istanbul ignore next */
|
|
468
|
-
log.debug(`SmartSnap: lockfile diff produced ${packageAffected.length} affected packages: ${packageAffected.join(', ')}`);
|
|
469
|
-
/* istanbul ignore next */
|
|
470
|
-
return packageAffected;
|
|
471
|
-
} catch (e) {
|
|
472
|
-
// snyk-nodejs-lockfile-parser is an optionalDependency (requires Node >=18).
|
|
473
|
-
// When it's missing we can't reason about the manifest change, so we
|
|
474
|
-
// conservatively bail to a full snapshot rather than under-snapshotting.
|
|
475
|
-
/* istanbul ignore else: a non-snyk diff error can only surface on Node >=18 */
|
|
476
|
-
if (e.code === 'SNYK_LOCKFILE_PARSER_UNAVAILABLE') {
|
|
477
|
-
throw new SmartSnapBailError(`SmartSnap: ${e.message}; running full snapshot set`);
|
|
478
|
-
}
|
|
479
|
-
/* istanbul ignore next: non-snyk diff errors only surface on Node >=18 */
|
|
480
|
-
throw e;
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
// Project each snapshot's importPath into the project-root frame and return
|
|
485
|
-
// the unique set. Logs how many snapshots carried an importPath and warns
|
|
486
|
-
// (with a sample) when none do, which usually means broken story extraction.
|
|
487
|
-
export function extractStorybookPaths(snapshots, normalizeImportPath, log) {
|
|
488
|
-
const storybookPaths = [...new Set(snapshots.map(s => normalizeImportPath(s.importPath)).filter(Boolean))];
|
|
489
|
-
const snapshotsWithImportPath = snapshots.filter(s => s.importPath).length;
|
|
490
|
-
log.debug(`SmartSnap: ${snapshotsWithImportPath}/${snapshots.length} snapshots have importPath; ${storybookPaths.length} unique storybookPaths`);
|
|
491
|
-
if (storybookPaths.length === 0) {
|
|
492
|
-
var _snapshots$, _snapshots$2, _snapshots$3;
|
|
493
|
-
log.warn(`SmartSnap: no snapshots have importPath set — check Storybook story extraction. Sample snapshot: ${JSON.stringify({
|
|
494
|
-
id: (_snapshots$ = snapshots[0]) === null || _snapshots$ === void 0 ? void 0 : _snapshots$.id,
|
|
495
|
-
name: (_snapshots$2 = snapshots[0]) === null || _snapshots$2 === void 0 ? void 0 : _snapshots$2.name,
|
|
496
|
-
importPath: (_snapshots$3 = snapshots[0]) === null || _snapshots$3 === void 0 ? void 0 : _snapshots$3.importPath,
|
|
497
|
-
keys: snapshots[0] ? Object.keys(snapshots[0]) : []
|
|
498
|
-
})}`);
|
|
499
|
-
} else {
|
|
500
|
-
log.debug(`SmartSnap: storybookPaths sample: ${storybookPaths.slice(0, 3).join(', ')}`);
|
|
501
|
-
}
|
|
502
|
-
return storybookPaths;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// Kick off the graph generation job and poll it to completion, returning the
|
|
506
|
-
// graph payload. The sync status response carries the graph payload directly
|
|
507
|
-
// on completion, so there's no second fetch — the returned `data` is the same
|
|
508
|
-
// response body that used to come from `getSmartsnapGraphData`. Bails to a
|
|
509
|
-
// full snapshot set if the job doesn't reach `done`.
|
|
510
|
-
export async function runGraphGeneration(percy, buildId, payload, log) {
|
|
511
|
-
const {
|
|
512
|
-
files,
|
|
513
|
-
modules,
|
|
514
|
-
storybookPaths,
|
|
515
|
-
affectedNodes,
|
|
516
|
-
affectedFileLocations
|
|
517
|
-
} = payload;
|
|
518
|
-
log.debug(`SmartSnap: starting graph generation job ${JSON.stringify({
|
|
519
|
-
buildId,
|
|
520
|
-
files,
|
|
521
|
-
modules,
|
|
522
|
-
storybookPaths,
|
|
523
|
-
affectedNodes,
|
|
524
|
-
affectedFileLocations
|
|
525
|
-
})}`);
|
|
526
|
-
// Pass camelCase to the client, which snake_cases it to `affected_file_locations`
|
|
527
|
-
// for the API (same convention as storybookPaths → storybook_paths).
|
|
528
|
-
await percy.client.generateSmartsnapGraph(buildId, {
|
|
529
|
-
files,
|
|
530
|
-
modules,
|
|
531
|
-
storybookPaths,
|
|
532
|
-
affectedNodes,
|
|
533
|
-
affectedFileLocations
|
|
534
|
-
});
|
|
535
|
-
const {
|
|
536
|
-
status,
|
|
537
|
-
data
|
|
538
|
-
} = await pollGraphStatus(percy, buildId, log);
|
|
539
|
-
if (status !== 'done') {
|
|
540
|
-
throw new SmartSnapBailError(`SmartSnap: graph generation did not complete (status: ${status ?? 'timed out'}); running full snapshot set`);
|
|
541
|
-
}
|
|
542
|
-
log.debug(`SmartSnap: affected stories result ${JSON.stringify(data === null || data === void 0 ? void 0 : data.affected_stories)}`);
|
|
543
|
-
return data;
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// Trace rendering moved client-side: the API now returns the raw graph
|
|
547
|
-
// (vertices, edges, transitive-closure triples) and we populate the bundled
|
|
548
|
-
// HTML template here. Only runs when `trace` is enabled and the payload is
|
|
549
|
-
// complete — anything missing means the BE couldn't produce a graph, so we
|
|
550
|
-
// skip silently rather than write a broken page.
|
|
551
|
-
export function maybeWriteTrace(trace, data, log) {
|
|
552
|
-
if (trace && data !== null && data !== void 0 && data.vertices && data !== null && data !== void 0 && data.edges && data !== null && data !== void 0 && data.transitive_closure_matrix_sparse) {
|
|
553
|
-
const tracePath = path.resolve(process.cwd(), 'trace.html');
|
|
554
|
-
try {
|
|
555
|
-
const html = renderGraphTraceHtml({
|
|
556
|
-
vertices: data.vertices,
|
|
557
|
-
edges: data.edges,
|
|
558
|
-
transitiveClosureMatrixSparse: data.transitive_closure_matrix_sparse
|
|
559
|
-
});
|
|
560
|
-
fs.writeFileSync(tracePath, html);
|
|
561
|
-
log.info(`SmartSnap: trace written to ${tracePath}`);
|
|
562
|
-
} catch (e) {
|
|
563
|
-
log.warn(`SmartSnap: failed to write trace.html: ${e.message}`);
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
// Filter the snapshots down to those the affected-graph reports plus any that
|
|
569
|
-
// need a forced re-snapshot, log the summary, and return the kept list.
|
|
570
|
-
//
|
|
571
|
-
// Snapshots whose baseline review_state is `failed` or `rejected` have no
|
|
572
|
-
// usable baseline image to diff against, and snapshots that don't appear in
|
|
573
|
-
// the base build at all are brand-new (no baseline exists yet). In both cases
|
|
574
|
-
// SmartSnap can't legitimately skip them — re-snapshot unconditionally
|
|
575
|
-
// regardless of what the affected-graph reports. `baselineSnapshots` is null
|
|
576
|
-
// when an explicit baseline is set, but `needsBaselineRefresh` short-circuits
|
|
577
|
-
// on `baseline` before reading it.
|
|
578
|
-
export function selectAffectedSnapshots(snapshots, data, baseline, baselineSnapshots, normalizeImportPath, log) {
|
|
579
|
-
const affected = new Set((data === null || data === void 0 ? void 0 : data.affected_stories) || []);
|
|
580
|
-
const FORCE_RESNAPSHOT_STATES = new Set(['failed', 'rejected']);
|
|
581
|
-
const needsBaselineRefresh = name => {
|
|
582
|
-
if (baseline) return false;
|
|
583
|
-
const state = baselineSnapshots[name];
|
|
584
|
-
return state === undefined || FORCE_RESNAPSHOT_STATES.has(state);
|
|
585
|
-
};
|
|
586
|
-
|
|
587
|
-
// Use the same normalization on lookup so a snapshot's `./src/...` matches
|
|
588
|
-
// an affected-stories `src/...` from the API.
|
|
589
|
-
let forced = 0;
|
|
590
|
-
let affectedKept = 0;
|
|
591
|
-
const filtered = snapshots.filter(s => {
|
|
592
|
-
if (needsBaselineRefresh(s.name)) {
|
|
593
|
-
forced += 1;
|
|
594
|
-
return true;
|
|
595
|
-
}
|
|
596
|
-
const p = normalizeImportPath(s.importPath);
|
|
597
|
-
if (p && affected.has(p)) {
|
|
598
|
-
affectedKept += 1;
|
|
599
|
-
return true;
|
|
600
|
-
}
|
|
601
|
-
return false;
|
|
602
|
-
});
|
|
603
|
-
log.info(`SmartSnap: ${filtered.length} of ${snapshots.length} snapshots kept (${affectedKept} via affected-graph, ${forced} via missing/failed/rejected baseline)`);
|
|
604
|
-
return filtered;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
// Given the mapped snapshots and storybook.smartSnap config, returns the subset of snapshots
|
|
608
|
-
// that the SmartSnap graph reports as affected. On any recoverable failure, returns the input
|
|
609
|
-
// list unchanged so the build runs as a full snapshot pass.
|
|
610
|
-
export async function applySmartSnap(percy, snapshots, smartSnapConfig, buildDir) {
|
|
611
|
-
const log = logger('storybook:smartsnap');
|
|
612
|
-
const {
|
|
613
|
-
baseline,
|
|
614
|
-
untraced,
|
|
615
|
-
trace,
|
|
616
|
-
bailOnChanges,
|
|
617
|
-
statsFile
|
|
618
|
-
} = smartSnapConfig || {};
|
|
619
|
-
if (!buildDir) {
|
|
620
|
-
throw new SmartSnapBailError('SmartSnap requires the Storybook build directory (e.g. `percy storybook ./storybook-static`); URL and `start` modes are not supported. Running full snapshot set');
|
|
621
|
-
}
|
|
622
|
-
const projectRoot = gitProjectRoot();
|
|
623
|
-
const {
|
|
624
|
-
files,
|
|
625
|
-
modules,
|
|
626
|
-
buildId
|
|
627
|
-
} = await validateAndReadStats(buildDir, statsFile, projectRoot, log);
|
|
628
|
-
let {
|
|
629
|
-
baseRef,
|
|
630
|
-
affectedNodes,
|
|
631
|
-
baselineSnapshots
|
|
632
|
-
} = await getBaselineAndAffectedNodes(percy, baseline, log);
|
|
633
|
-
assertNoDotStorybookChange(affectedNodes);
|
|
634
|
-
assertNoBailOnChanges(affectedNodes, bailOnChanges);
|
|
635
|
-
affectedNodes = enforceUntraced(affectedNodes, untraced);
|
|
636
|
-
const packageAffectedNodes = await getAffectedPackages(affectedNodes, baseRef, projectRoot, log);
|
|
637
|
-
|
|
638
|
-
// With no traced files and no package-level changes there's nothing for the
|
|
639
|
-
// graph to reason about — bail to a full snapshot set rather than send an
|
|
640
|
-
// empty diff.
|
|
641
|
-
if (!affectedNodes.length && !packageAffectedNodes.length) {
|
|
642
|
-
throw new SmartSnapBailError('SmartSnap: no affected files or packages detected after filtering; running full snapshot set');
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
// Storybook's `entries[id].importPath` (and v6 `parameters.fileName`)
|
|
646
|
-
// is resolved relative to the directory percy was invoked from — for a
|
|
647
|
-
// monorepo storybook that's typically the package dir (e.g.
|
|
648
|
-
// `frontend/packages/design-stack`), not the git root. Example:
|
|
649
|
-
// `./modules/AgentCard/AgentCard.stories.tsx`.
|
|
650
|
-
//
|
|
651
|
-
// `affectedNodes` from `git diff --name-only` and the `files` array
|
|
652
|
-
// built by the stats compactor are both project-root-relative
|
|
653
|
-
// (e.g. `packages/design-stack/modules/AgentCard/AgentCard.stories.tsx`).
|
|
654
|
-
// Project them into the same frame so the BE matches importPath against
|
|
655
|
-
// affectedNodes without a cross-frame translation.
|
|
656
|
-
const dotPosix = './';
|
|
657
|
-
const dotPlatform = `.${path.sep}`;
|
|
658
|
-
const invocationDir = process.cwd();
|
|
659
|
-
const normalizeImportPath = p => {
|
|
660
|
-
if (typeof p !== 'string' || !p) return p;
|
|
661
|
-
let rel = p;
|
|
662
|
-
/* istanbul ignore next: on POSIX CI dotPlatform === dotPosix ('./'), so the
|
|
663
|
-
dotPosix else-if (a Windows-only '.\\' case) is unreachable there; ignore
|
|
664
|
-
the whole prefix-strip chain rather than a single dead else-if branch */
|
|
665
|
-
if (rel.startsWith(dotPlatform)) rel = rel.slice(dotPlatform.length);else if (rel.startsWith(dotPosix)) rel = rel.slice(dotPosix.length);
|
|
666
|
-
// If the importPath happens to be absolute (older Storybook configs),
|
|
667
|
-
// path.resolve treats it as the target directly; otherwise it's joined
|
|
668
|
-
// against `invocationDir`. Then re-base against the git project root.
|
|
669
|
-
// rel comes from build stats file paths, re-based against projectRoot on the next line (reviewed, approved by security)
|
|
670
|
-
const abs = path.resolve(invocationDir, rel); // nosemgrep
|
|
671
|
-
const projRel = path.relative(projectRoot, abs);
|
|
672
|
-
// path.relative('','') → '' and `path.relative` produces backslashes
|
|
673
|
-
// on Windows; the stats `files` array uses the same — leave platform
|
|
674
|
-
// sep alone so the two stay byte-identical for the BE match.
|
|
675
|
-
/* istanbul ignore next: the `|| rel` fallback only triggers when an importPath
|
|
676
|
-
resolves exactly to projectRoot (projRel === ''), an edge that's unstable to
|
|
677
|
-
reproduce across OS symlink/tmpdir differences */
|
|
678
|
-
return projRel || rel;
|
|
679
|
-
};
|
|
680
|
-
const storybookPaths = extractStorybookPaths(snapshots, normalizeImportPath, log);
|
|
681
|
-
|
|
682
|
-
/* istanbul ignore next: packageAffectedNodes is only non-empty on Node >=18
|
|
683
|
-
(the snyk lockfile diff); on the Node-14 CI getAffectedPackages either
|
|
684
|
-
returns [] or bails before yielding packages, so this never runs there */
|
|
685
|
-
if (packageAffectedNodes.length) {
|
|
686
|
-
affectedNodes = [...affectedNodes, ...packageAffectedNodes];
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
// Line-level diff ranges for the indexed files, keyed by their `files` index.
|
|
690
|
-
const affectedFileLocations = getAffectedFileLocations(baseRef, files);
|
|
691
|
-
const data = await runGraphGeneration(percy, buildId, {
|
|
692
|
-
files,
|
|
693
|
-
modules,
|
|
694
|
-
storybookPaths,
|
|
695
|
-
affectedNodes,
|
|
696
|
-
affectedFileLocations
|
|
697
|
-
}, log);
|
|
698
|
-
maybeWriteTrace(trace, data, log);
|
|
699
|
-
return selectAffectedSnapshots(snapshots, data, baseline, baselineSnapshots, normalizeImportPath, log);
|
|
700
|
-
}
|