@percy/cli-command 1.32.0-beta.6 → 1.32.0-beta.8

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.
@@ -0,0 +1,632 @@
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
+ // Paths under these directories are dependencies / framework wiring rather
119
+ // than first-party source, so we don't track them in the file index.
120
+ const EXCLUDED_DIRS = new Set(['node_modules', '.storybook']);
121
+ const isExcluded = relPath => relPath.split(/[/\\]/).some(seg => EXCLUDED_DIRS.has(seg));
122
+
123
+ // Resolve+index used for `id` and `resolvedFrom` only. Converts absolute paths
124
+ // to projectRoot-relative form, then either returns the existing index for that
125
+ // path or assigns the next one (= current map size). Paths inside node_modules
126
+ // or .storybook are returned as the relative string and *not* indexed; modules
127
+ // whose id falls into that bucket are dropped downstream by the
128
+ // "id is string → drop" contract in transformModule.
129
+ function resolveAndIndex(value, fileIndex, projectRoot) {
130
+ const clean = stripNull(value);
131
+ if (!path.isAbsolute(clean)) return clean;
132
+ const rel = path.relative(projectRoot, clean);
133
+ if (isExcluded(rel)) return rel;
134
+ let idx = fileIndex.get(rel);
135
+ if (idx === undefined) {
136
+ idx = fileIndex.size;
137
+ fileIndex.set(rel, idx);
138
+ }
139
+ return idx;
140
+ }
141
+
142
+ // Transform a stats `modules[]` entry into the indexed shape the SmartSnap graph expects.
143
+ // Returns null when the module's id is a string — those entries are dropped (per BE contract).
144
+ // Each `imports[i]` / `passThroughExports[i]` carries `{ type, source }` from the
145
+ // bundler-plugin: for `type === 'src'` we translate the absolute file path to its
146
+ // project-file index when possible; for `type === 'module'` the source is already
147
+ // the bare package name, so we leave it alone.
148
+ function transformModule(m, fileIndex, projectRoot) {
149
+ const out = {};
150
+ if (m.id != null) out.id = resolveAndIndex(m.id, fileIndex, projectRoot);
151
+ if (typeof out.id === 'string') return null;
152
+ const mapEntry = e => {
153
+ const copy = {
154
+ ...e
155
+ };
156
+ if (copy.type === 'src' && typeof copy.source === 'string') {
157
+ copy.source = resolveAndIndex(copy.source, fileIndex, projectRoot);
158
+ }
159
+ return copy;
160
+ };
161
+ if (Array.isArray(m.imports)) out.imports = m.imports.map(mapEntry);
162
+ if (Array.isArray(m.passThroughExports)) out.passThroughExports = m.passThroughExports.map(mapEntry);
163
+ if (Array.isArray(m.nonPassThroughExports)) out.nonPassThroughExports = m.nonPassThroughExports;
164
+ return out;
165
+ }
166
+ function streamModules(filePath, onModule) {
167
+ return new Promise((resolve, reject) => {
168
+ fs.createReadStream(filePath).pipe(parser()).pipe(pick({
169
+ filter: 'modules'
170
+ })).pipe(streamArray()).on('data', ({
171
+ value
172
+ }) => onModule(value)).on('end', resolve).on('error', reject);
173
+ });
174
+ }
175
+ function readTopLevelKey(filePath, key) {
176
+ return new Promise((resolve, reject) => {
177
+ let value;
178
+ fs.createReadStream(filePath).pipe(parser()).pipe(pick({
179
+ filter: key
180
+ })).pipe(streamValues()).on('data', ({
181
+ value: v
182
+ }) => {
183
+ value = v;
184
+ }).on('end', () => resolve(value)).on('error', reject);
185
+ });
186
+ }
187
+ async function readStats(statsFile, projectRoot) {
188
+ const fileIndex = new Map();
189
+ const modules = [];
190
+ await streamModules(statsFile, m => {
191
+ const t = transformModule(m, fileIndex, projectRoot);
192
+ if (t) modules.push(t);
193
+ });
194
+
195
+ // Emit `files` ordered by encounter-time index so files[N] corresponds to
196
+ // every module/source ref that was assigned index N during streaming.
197
+ const files = [...fileIndex.entries()].sort((a, b) => a[1] - b[1]).map(([p]) => p);
198
+
199
+ // The bundler-plugin-smartsnap emits a unique buildId per storybook build
200
+ // so concurrent runs against the same project don't share Redis state.
201
+ const buildId = await readTopLevelKey(statsFile, 'buildId');
202
+ return {
203
+ files,
204
+ modules,
205
+ buildId
206
+ };
207
+ }
208
+
209
+ // Polls `job_status?sync=true&type=smartsnap_graph&id=<buildId>` — the sync
210
+ // response blocks server-side until the job moves off `in_progress`, but the
211
+ // API enforces a shorter timeout than the job can take, so we retry up to
212
+ // POLL_ATTEMPTS times. On `done` the response also carries the graph payload
213
+ // (affected stories + vertices/edges/transitive closure for trace
214
+ // rendering), so the caller reads it directly without a second fetch.
215
+ //
216
+ // Response shape is the unwrapped `{ status, data }` — the smartsnap_graph
217
+ // status response no longer keys the result by buildId.
218
+ async function pollGraphStatus(percy, buildId, log) {
219
+ for (let i = 0; i < POLL_ATTEMPTS; i++) {
220
+ const res = await percy.client.getStatus('smartsnap_graph', [buildId]);
221
+ const status = res === null || res === void 0 ? void 0 : res.status;
222
+ log.debug(`SmartSnap: graph status (attempt ${i + 1}) = ${status}`);
223
+ if (status === 'done' || status === 'failure') return {
224
+ status,
225
+ data: res === null || res === void 0 ? void 0 : res.data
226
+ };
227
+ if (i < POLL_ATTEMPTS - 1) await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
228
+ }
229
+ return {
230
+ status: null
231
+ };
232
+ }
233
+
234
+ // First component: resolve + validate the statsFile path, stream-read it, and
235
+ // assert it carries a usable buildId. Returns { files, modules, buildId }; any
236
+ // problem throws a SmartSnapBailError so the caller falls back to a full set.
237
+ export async function validateAndReadStats(buildDir, statsFile, projectRoot, log) {
238
+ // Treat statsFile as a flat filename anchored inside the build directory.
239
+ // path.basename() strips any traversal segments so the resolved path can't
240
+ // escape buildDir even if the config is hostile.
241
+ const statsName = path.basename(statsFile || 'enriched-stats.json');
242
+ if (!/^[\w.-]+\.json$/i.test(statsName)) {
243
+ throw new SmartSnapBailError(`SmartSnap: invalid statsFile "${statsName}" — must be a .json filename; running full snapshot set`);
244
+ }
245
+ // statsName is path.basename'd and regex-validated above; buildDir is operator-supplied config (reviewed, approved by security)
246
+ const resolvedStatsPath = path.join(path.resolve(buildDir), statsName); // nosemgrep
247
+ let statsStat;
248
+ try {
249
+ statsStat = fs.statSync(resolvedStatsPath);
250
+ } catch {
251
+ throw new SmartSnapBailError(`SmartSnap: stats file "${statsName}" not found in build directory ${buildDir}; running full snapshot set`);
252
+ }
253
+ if (!statsStat.isFile()) {
254
+ throw new SmartSnapBailError(`SmartSnap: stats file "${statsName}" in ${buildDir} is not a regular file; running full snapshot set`);
255
+ }
256
+ log.debug(`SmartSnap: parsing stats file ${resolvedStatsPath}`);
257
+ const {
258
+ files,
259
+ modules,
260
+ buildId
261
+ } = await readStats(resolvedStatsPath, projectRoot);
262
+ if (typeof buildId !== 'string' || !buildId) {
263
+ throw new SmartSnapBailError(`SmartSnap: stats file at ${resolvedStatsPath} is missing a top-level "buildId" — running full snapshot set`);
264
+ }
265
+ return {
266
+ files,
267
+ modules,
268
+ buildId
269
+ };
270
+ }
271
+
272
+ // Resolve the diff base ref + the list of changed files. With an explicit
273
+ // `baseline` we diff against it directly and skip the API base-build lookup
274
+ // entirely (its snapshot map is only consulted on the no-baseline path), so
275
+ // `baselineSnapshots` comes back null in that case.
276
+ export async function getBaselineAndAffectedNodes(percy, baseline, log) {
277
+ let baseRef;
278
+ let baselineSnapshots;
279
+ if (baseline) {
280
+ log.debug(`SmartSnap: diffing against explicit baseline "${baseline}"`);
281
+ baseRef = baseline;
282
+ baselineSnapshots = null;
283
+ } else {
284
+ // New API shape: `{ base_build_commit_sha, snapshots: { <name>: <review_state> } }`.
285
+ // The single base-build commit replaces the previous per-snapshot commit map —
286
+ // baseline prediction now happens server-side via `Percy::BaseBuildService`,
287
+ // so we just diff against whatever commit it picked. The set of snapshot
288
+ // names is no longer sent; the API resolves baselines from the project +
289
+ // git/PR context alone.
290
+ const baseLookup = await percy.client.getSmartsnapSnapshotNameToCommit();
291
+ log.debug(`SmartSnap: base lookup ${JSON.stringify(baseLookup)}`);
292
+ if (!(baseLookup !== null && baseLookup !== void 0 && baseLookup.base_build_commit_sha)) {
293
+ throw new SmartSnapBailError('SmartSnap: API could not predict a base build commit and no explicit baseline was set; running full snapshot set');
294
+ }
295
+ log.debug(`SmartSnap: diffing against predicted base build commit "${baseLookup.base_build_commit_sha}"`);
296
+ baseRef = baseLookup.base_build_commit_sha;
297
+ baselineSnapshots = baseLookup.snapshots || {};
298
+ }
299
+ assertSafeRef(baseRef);
300
+ const affectedNodes = gitDiffNames(baseRef);
301
+ return {
302
+ baseRef,
303
+ affectedNodes,
304
+ baselineSnapshots
305
+ };
306
+ }
307
+
308
+ // A change to anything under `.storybook/` (preview config, addons, manager
309
+ // wiring) can affect every story's render, so the dep graph isn't enough.
310
+ export function assertNoDotStorybookChange(affectedNodes) {
311
+ const dotStorybookHit = affectedNodes.find(p => p.split(/[/\\]/).includes('.storybook'));
312
+ if (dotStorybookHit) {
313
+ throw new SmartSnapBailError(`SmartSnap: change to "${dotStorybookHit}" inside .storybook affects all stories; running full snapshot set`);
314
+ }
315
+ }
316
+
317
+ // Bail to a full snapshot set if any changed file matches a user-supplied
318
+ // bailOnChanges glob.
319
+ export function assertNoBailOnChanges(affectedNodes, bailOnChanges) {
320
+ if (bailOnChanges !== null && bailOnChanges !== void 0 && bailOnChanges.length) {
321
+ const bailed = affectedNodes.find(p => bailOnChanges.some(g => matchesPattern(p, g)));
322
+ if (bailed) {
323
+ throw new SmartSnapBailError(`SmartSnap: change to "${bailed}" matched bailOnChanges; running full snapshot set`);
324
+ }
325
+ }
326
+ }
327
+
328
+ // Drop changed files that match a user-supplied `untraced` glob so they don't
329
+ // drive snapshot selection. Returns the filtered list (unchanged when no
330
+ // untraced patterns are configured).
331
+ export function enforceUntraced(affectedNodes, untraced) {
332
+ if (untraced !== null && untraced !== void 0 && untraced.length) {
333
+ return affectedNodes.filter(p => !untraced.some(g => matchesPattern(p, g)));
334
+ }
335
+ return affectedNodes;
336
+ }
337
+
338
+ // Manifest/lockfile changes can shift the dependency tree, so resolve the
339
+ // diff at the package level via snyk-nodejs-lockfile-parser and return the
340
+ // changed package names to feed back into the graph. Returns [] when there's
341
+ // nothing to resolve; short-circuits below fall back to a full snapshot
342
+ // (via SmartSnapBailError) whenever we can't reason about the change.
343
+ export async function getAffectedPackages(affectedNodes, baseRef, projectRoot, log) {
344
+ const MANIFEST_PATHS = new Set(['package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml']);
345
+ const manifestHits = affectedNodes.filter(p => MANIFEST_PATHS.has(path.basename(p)));
346
+ if (manifestHits.length === 0) return [];
347
+
348
+ // Locate the changed manifest's directory from affectedNodes (NOT the git
349
+ // root) — monorepos keep package.json + lockfile inside a workspace dir.
350
+ // If two changes land in different dirs, we'd need per-workspace resolution
351
+ // we don't try to do yet, so bail.
352
+ const uniqueDirs = [...new Set(manifestHits.map(p => path.dirname(p)))];
353
+ if (uniqueDirs.length > 1) {
354
+ throw new SmartSnapBailError(`SmartSnap: manifest changes span multiple directories (${uniqueDirs.join(', ')}); running full snapshot set`);
355
+ }
356
+ const manifestDir = uniqueDirs[0]; // repo-relative; '.' for root
357
+ // manifestDir is derived from git-tracked paths under projectRoot (reviewed, approved by security)
358
+ const absManifestDir = path.resolve(projectRoot, manifestDir); // nosemgrep
359
+
360
+ // Pick the lockfile that lives next to the changed manifest. If two
361
+ // coexist (e.g. a stray package-lock.json next to yarn.lock) we can't
362
+ // pick a canonical source, so bail.
363
+ const LOCKFILE_NAMES = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'];
364
+ // n is from the hardcoded LOCKFILE_NAMES allowlist (reviewed, approved by security)
365
+ const presentLockfiles = LOCKFILE_NAMES.filter(n => fs.existsSync(path.join(absManifestDir, n))); // nosemgrep
366
+ if (presentLockfiles.length === 0) {
367
+ throw new SmartSnapBailError(`SmartSnap: manifest changed in "${manifestDir}" but no lockfile present there; running full snapshot set`);
368
+ }
369
+ if (presentLockfiles.length > 1) {
370
+ throw new SmartSnapBailError(`SmartSnap: multiple lockfiles in "${manifestDir}" (${presentLockfiles.join(', ')}); cannot pick canonical; running full snapshot set`);
371
+ }
372
+ const lockfileName = presentLockfiles[0];
373
+ // git always uses forward slashes for its <rev>:<path> spec; build it by
374
+ // hand instead of path.join (which would use backslashes on Windows).
375
+ const lockfileRepoPath = manifestDir === '.' ? lockfileName : `${manifestDir}/${lockfileName}`;
376
+
377
+ // Resolve the lockfile at the base commit. If it wasn't tracked there
378
+ // (first SmartSnap run, lockfile renamed, etc.) we can't diff, so bail.
379
+ let oldLockfile;
380
+ try {
381
+ oldLockfile = git(['show', `${baseRef}:${lockfileRepoPath}`]);
382
+ } catch {
383
+ throw new SmartSnapBailError(`SmartSnap: lockfile "${lockfileRepoPath}" not present at base ref ${baseRef}; running full snapshot set`);
384
+ }
385
+
386
+ // lockfileName is one of the hardcoded LOCKFILE_NAMES allowlist (reviewed, approved by security)
387
+ const newLockfile = fs.readFileSync(path.join(absManifestDir, lockfileName), 'utf8'); // nosemgrep
388
+
389
+ // Byte-identical lockfile means only package.json's non-dep fields changed —
390
+ // nothing shifted in the dependency tree, so there are no affected packages.
391
+ if (oldLockfile === newLockfile) return [];
392
+
393
+ // joined with the literal 'package.json' under the resolved absManifestDir (reviewed, approved by security)
394
+ const packageJson = fs.readFileSync(path.join(absManifestDir, 'package.json'), 'utf8'); // nosemgrep
395
+ const packageJsonRepoPath = manifestDir === '.' ? 'package.json' : `${manifestDir}/package.json`;
396
+ const oldPackageJson = git(['show', `${baseRef}:${packageJsonRepoPath}`]);
397
+ try {
398
+ const packageAffected = await diffLockfileDeps({
399
+ packageJson,
400
+ oldLockfile,
401
+ newLockfile,
402
+ lockfileType: lockfileName,
403
+ oldPackageJson
404
+ });
405
+ // The two statements below run only on Node >=18 where the snyk parser
406
+ // loads; the CI matrix runs the suite on Node 14, where the await above
407
+ // throws SNYK_LOCKFILE_PARSER_UNAVAILABLE and we take the catch instead.
408
+ /* istanbul ignore next */
409
+ log.debug(`SmartSnap: lockfile diff produced ${packageAffected.length} affected packages: ${packageAffected.join(', ')}`);
410
+ /* istanbul ignore next */
411
+ return packageAffected;
412
+ } catch (e) {
413
+ // snyk-nodejs-lockfile-parser is an optionalDependency (requires Node >=18).
414
+ // When it's missing we can't reason about the manifest change, so we
415
+ // conservatively bail to a full snapshot rather than under-snapshotting.
416
+ /* istanbul ignore else: a non-snyk diff error can only surface on Node >=18 */
417
+ if (e.code === 'SNYK_LOCKFILE_PARSER_UNAVAILABLE') {
418
+ throw new SmartSnapBailError(`SmartSnap: ${e.message}; running full snapshot set`);
419
+ }
420
+ /* istanbul ignore next: non-snyk diff errors only surface on Node >=18 */
421
+ throw e;
422
+ }
423
+ }
424
+
425
+ // Project each snapshot's importPath into the project-root frame and return
426
+ // the unique set. Logs how many snapshots carried an importPath and warns
427
+ // (with a sample) when none do, which usually means broken story extraction.
428
+ export function extractStorybookPaths(snapshots, normalizeImportPath, log) {
429
+ const storybookPaths = [...new Set(snapshots.map(s => normalizeImportPath(s.importPath)).filter(Boolean))];
430
+ const snapshotsWithImportPath = snapshots.filter(s => s.importPath).length;
431
+ log.debug(`SmartSnap: ${snapshotsWithImportPath}/${snapshots.length} snapshots have importPath; ${storybookPaths.length} unique storybookPaths`);
432
+ if (storybookPaths.length === 0) {
433
+ var _snapshots$, _snapshots$2, _snapshots$3;
434
+ log.warn(`SmartSnap: no snapshots have importPath set — check Storybook story extraction. Sample snapshot: ${JSON.stringify({
435
+ id: (_snapshots$ = snapshots[0]) === null || _snapshots$ === void 0 ? void 0 : _snapshots$.id,
436
+ name: (_snapshots$2 = snapshots[0]) === null || _snapshots$2 === void 0 ? void 0 : _snapshots$2.name,
437
+ importPath: (_snapshots$3 = snapshots[0]) === null || _snapshots$3 === void 0 ? void 0 : _snapshots$3.importPath,
438
+ keys: snapshots[0] ? Object.keys(snapshots[0]) : []
439
+ })}`);
440
+ } else {
441
+ log.debug(`SmartSnap: storybookPaths sample: ${storybookPaths.slice(0, 3).join(', ')}`);
442
+ }
443
+ return storybookPaths;
444
+ }
445
+
446
+ // Kick off the graph generation job and poll it to completion, returning the
447
+ // graph payload. The sync status response carries the graph payload directly
448
+ // on completion, so there's no second fetch — the returned `data` is the same
449
+ // response body that used to come from `getSmartsnapGraphData`. Bails to a
450
+ // full snapshot set if the job doesn't reach `done`.
451
+ export async function runGraphGeneration(percy, buildId, payload, log) {
452
+ const {
453
+ files,
454
+ modules,
455
+ storybookPaths,
456
+ affectedNodes
457
+ } = payload;
458
+ log.debug(`SmartSnap: starting graph generation job ${JSON.stringify({
459
+ buildId,
460
+ files,
461
+ modules,
462
+ storybookPaths,
463
+ affectedNodes
464
+ })}`);
465
+ await percy.client.generateSmartsnapGraph(buildId, {
466
+ files,
467
+ modules,
468
+ storybookPaths,
469
+ affectedNodes
470
+ });
471
+ const {
472
+ status,
473
+ data
474
+ } = await pollGraphStatus(percy, buildId, log);
475
+ if (status !== 'done') {
476
+ throw new SmartSnapBailError(`SmartSnap: graph generation did not complete (status: ${status ?? 'timed out'}); running full snapshot set`);
477
+ }
478
+ log.debug(`SmartSnap: affected stories result ${JSON.stringify(data === null || data === void 0 ? void 0 : data.affected_stories)}`);
479
+ return data;
480
+ }
481
+
482
+ // Trace rendering moved client-side: the API now returns the raw graph
483
+ // (vertices, edges, transitive-closure triples) and we populate the bundled
484
+ // HTML template here. Only runs when `trace` is enabled and the payload is
485
+ // complete — anything missing means the BE couldn't produce a graph, so we
486
+ // skip silently rather than write a broken page.
487
+ export function maybeWriteTrace(trace, data, log) {
488
+ 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) {
489
+ const tracePath = path.resolve(process.cwd(), 'trace.html');
490
+ try {
491
+ const html = renderGraphTraceHtml({
492
+ vertices: data.vertices,
493
+ edges: data.edges,
494
+ transitiveClosureMatrixSparse: data.transitive_closure_matrix_sparse
495
+ });
496
+ fs.writeFileSync(tracePath, html);
497
+ log.info(`SmartSnap: trace written to ${tracePath}`);
498
+ } catch (e) {
499
+ log.warn(`SmartSnap: failed to write trace.html: ${e.message}`);
500
+ }
501
+ }
502
+ }
503
+
504
+ // Filter the snapshots down to those the affected-graph reports plus any that
505
+ // need a forced re-snapshot, log the summary, and return the kept list.
506
+ //
507
+ // Snapshots whose baseline review_state is `failed` or `rejected` have no
508
+ // usable baseline image to diff against, and snapshots that don't appear in
509
+ // the base build at all are brand-new (no baseline exists yet). In both cases
510
+ // SmartSnap can't legitimately skip them — re-snapshot unconditionally
511
+ // regardless of what the affected-graph reports. `baselineSnapshots` is null
512
+ // when an explicit baseline is set, but `needsBaselineRefresh` short-circuits
513
+ // on `baseline` before reading it.
514
+ export function selectAffectedSnapshots(snapshots, data, baseline, baselineSnapshots, normalizeImportPath, log) {
515
+ const affected = new Set((data === null || data === void 0 ? void 0 : data.affected_stories) || []);
516
+ const FORCE_RESNAPSHOT_STATES = new Set(['failed', 'rejected']);
517
+ const needsBaselineRefresh = name => {
518
+ if (baseline) return false;
519
+ const state = baselineSnapshots[name];
520
+ return state === undefined || FORCE_RESNAPSHOT_STATES.has(state);
521
+ };
522
+
523
+ // Use the same normalization on lookup so a snapshot's `./src/...` matches
524
+ // an affected-stories `src/...` from the API.
525
+ let forced = 0;
526
+ let affectedKept = 0;
527
+ const filtered = snapshots.filter(s => {
528
+ if (needsBaselineRefresh(s.name)) {
529
+ forced += 1;
530
+ return true;
531
+ }
532
+ const p = normalizeImportPath(s.importPath);
533
+ if (p && affected.has(p)) {
534
+ affectedKept += 1;
535
+ return true;
536
+ }
537
+ return false;
538
+ });
539
+ log.info(`SmartSnap: ${filtered.length} of ${snapshots.length} snapshots kept (${affectedKept} via affected-graph, ${forced} via missing/failed/rejected baseline)`);
540
+ return filtered;
541
+ }
542
+
543
+ // Given the mapped snapshots and storybook.smartSnap config, returns the subset of snapshots
544
+ // that the SmartSnap graph reports as affected. On any recoverable failure, returns the input
545
+ // list unchanged so the build runs as a full snapshot pass.
546
+ export async function applySmartSnap(percy, snapshots, smartSnapConfig, buildDir) {
547
+ const log = logger('storybook:smartsnap');
548
+ const {
549
+ baseline,
550
+ untraced,
551
+ trace,
552
+ bailOnChanges,
553
+ statsFile
554
+ } = smartSnapConfig || {};
555
+ if (!buildDir) {
556
+ 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');
557
+ }
558
+ const projectRoot = gitProjectRoot();
559
+ const {
560
+ files,
561
+ modules,
562
+ buildId
563
+ } = await validateAndReadStats(buildDir, statsFile, projectRoot, log);
564
+ let {
565
+ baseRef,
566
+ affectedNodes,
567
+ baselineSnapshots
568
+ } = await getBaselineAndAffectedNodes(percy, baseline, log);
569
+ assertNoDotStorybookChange(affectedNodes);
570
+ assertNoBailOnChanges(affectedNodes, bailOnChanges);
571
+ affectedNodes = enforceUntraced(affectedNodes, untraced);
572
+ const packageAffectedNodes = await getAffectedPackages(affectedNodes, baseRef, projectRoot, log);
573
+
574
+ // With no traced files and no package-level changes there's nothing for the
575
+ // graph to reason about — bail to a full snapshot set rather than send an
576
+ // empty diff.
577
+ if (!affectedNodes.length && !packageAffectedNodes.length) {
578
+ throw new SmartSnapBailError('SmartSnap: no affected files or packages detected after filtering; running full snapshot set');
579
+ }
580
+
581
+ // Storybook's `entries[id].importPath` (and v6 `parameters.fileName`)
582
+ // is resolved relative to the directory percy was invoked from — for a
583
+ // monorepo storybook that's typically the package dir (e.g.
584
+ // `frontend/packages/design-stack`), not the git root. Example:
585
+ // `./modules/AgentCard/AgentCard.stories.tsx`.
586
+ //
587
+ // `affectedNodes` from `git diff --name-only` and the `files` array
588
+ // built by the stats compactor are both project-root-relative
589
+ // (e.g. `packages/design-stack/modules/AgentCard/AgentCard.stories.tsx`).
590
+ // Project them into the same frame so the BE matches importPath against
591
+ // affectedNodes without a cross-frame translation.
592
+ const dotPosix = './';
593
+ const dotPlatform = `.${path.sep}`;
594
+ const invocationDir = process.cwd();
595
+ const normalizeImportPath = p => {
596
+ if (typeof p !== 'string' || !p) return p;
597
+ let rel = p;
598
+ /* istanbul ignore next: on POSIX CI dotPlatform === dotPosix ('./'), so the
599
+ dotPosix else-if (a Windows-only '.\\' case) is unreachable there; ignore
600
+ the whole prefix-strip chain rather than a single dead else-if branch */
601
+ if (rel.startsWith(dotPlatform)) rel = rel.slice(dotPlatform.length);else if (rel.startsWith(dotPosix)) rel = rel.slice(dotPosix.length);
602
+ // If the importPath happens to be absolute (older Storybook configs),
603
+ // path.resolve treats it as the target directly; otherwise it's joined
604
+ // against `invocationDir`. Then re-base against the git project root.
605
+ // rel comes from build stats file paths, re-based against projectRoot on the next line (reviewed, approved by security)
606
+ const abs = path.resolve(invocationDir, rel); // nosemgrep
607
+ const projRel = path.relative(projectRoot, abs);
608
+ // path.relative('','') → '' and `path.relative` produces backslashes
609
+ // on Windows; the stats `files` array uses the same — leave platform
610
+ // sep alone so the two stay byte-identical for the BE match.
611
+ /* istanbul ignore next: the `|| rel` fallback only triggers when an importPath
612
+ resolves exactly to projectRoot (projRel === ''), an edge that's unstable to
613
+ reproduce across OS symlink/tmpdir differences */
614
+ return projRel || rel;
615
+ };
616
+ const storybookPaths = extractStorybookPaths(snapshots, normalizeImportPath, log);
617
+
618
+ /* istanbul ignore next: packageAffectedNodes is only non-empty on Node >=18
619
+ (the snyk lockfile diff); on the Node-14 CI getAffectedPackages either
620
+ returns [] or bails before yielding packages, so this never runs there */
621
+ if (packageAffectedNodes.length) {
622
+ affectedNodes = [...affectedNodes, ...packageAffectedNodes];
623
+ }
624
+ const data = await runGraphGeneration(percy, buildId, {
625
+ files,
626
+ modules,
627
+ storybookPaths,
628
+ affectedNodes
629
+ }, log);
630
+ maybeWriteTrace(trace, data, log);
631
+ return selectAffectedSnapshots(snapshots, data, baseline, baselineSnapshots, normalizeImportPath, log);
632
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percy/cli-command",
3
- "version": "1.32.0-beta.6",
3
+ "version": "1.32.0-beta.8",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -27,6 +27,7 @@
27
27
  ".": "./dist/index.js",
28
28
  "./flags": "./dist/flags.js",
29
29
  "./utils": "./dist/utils.js",
30
+ "./smartsnap": "./dist/smartsnap.js",
30
31
  "./test/helpers": "./test/helpers.js"
31
32
  },
32
33
  "scripts": {
@@ -36,9 +37,14 @@
36
37
  "test:coverage": "yarn test --coverage"
37
38
  },
38
39
  "dependencies": {
39
- "@percy/config": "1.32.0-beta.6",
40
- "@percy/core": "1.32.0-beta.6",
41
- "@percy/logger": "1.32.0-beta.6"
40
+ "@percy/config": "1.32.0-beta.8",
41
+ "@percy/core": "1.32.0-beta.8",
42
+ "@percy/logger": "1.32.0-beta.8",
43
+ "glob-to-regexp": "^0.4.1",
44
+ "stream-json": "^1.8.0"
42
45
  },
43
- "gitHead": "a0757ac144c5d65ecf172a5d257935ab525daa52"
46
+ "optionalDependencies": {
47
+ "snyk-nodejs-lockfile-parser": "2.7.1"
48
+ },
49
+ "gitHead": "8e91413b6ed95aeefad697f058709e2a0fe56f0e"
44
50
  }