@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/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
- }